partforge 0.97.0 → 0.98.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.
@@ -3,8 +3,8 @@
3
3
  //
4
4
  // This file uses createImageBitmap and a canvas, so it must NEVER be reachable
5
5
  // from the geometry worker's import closure — test/worker-layering.test.js
6
- // enforces that. It is exported from src/index.js, the DOM entry documented as
7
- // one a part's `build` must never import.
6
+ // enforces that. It is exported from src/ingest.js (`partforge/ingest`), not
7
+ // from the main entry — see that file's header for why.
8
8
  //
9
9
  // Why PNG and not the source format: core decodes PNG only, in pure JS, so one
10
10
  // decoder produces the geometry in the browser, the CLI and CI alike. Converting
@@ -0,0 +1,69 @@
1
+ // src/framework/ingest/node-dom.js
2
+ // A headless DOM for the `partforge ingest` CLI verb, promoted from the old
3
+ // scripts/ingest-svg.mjs (deleted — see bin/cli.js's `ingest` command).
4
+ // paper-core (imported by svg-ingest.js) builds a canvas and asks for a 2D
5
+ // context at MODULE LOAD time, so installNodeDom() must run and finish BEFORE
6
+ // svg-ingest.js is ever imported — the CLI installs this DOM first and only
7
+ // then dynamically imports the converter (registry.js's `convert` thunk for
8
+ // the "vector" kind).
9
+ //
10
+ // happy-dom is an OPTIONAL peer dependency (see package.json): most consumers
11
+ // never touch the SVG ingest path, and this keeps its ~17 MB out of their
12
+ // installs. It is imported dynamically, HERE, inside installNodeDom() — never
13
+ // at this module's top level — so bin/cli.js (and every other CLI verb) keeps
14
+ // loading and working when happy-dom isn't installed; the failure only
15
+ // surfaces on this path, with a message naming the install command.
16
+ //
17
+ // stubCanvas2dContext is exported separately from installNodeDom's own DOM
18
+ // setup: test/setup/happy-dom-patches.js needs the identical no-op 2D context
19
+ // for VITEST's own happy-dom test environment (a wholly different Window than
20
+ // the one installNodeDom() constructs here — vitest boots its own). Importing
21
+ // this one function from both places is what replaces what used to be two
22
+ // independently hand-typed copies of the same stub.
23
+ export function stubCanvas2dContext() {
24
+ return {
25
+ save() {}, restore() {}, beginPath() {}, closePath() {}, moveTo() {}, lineTo() {},
26
+ bezierCurveTo() {}, quadraticCurveTo() {}, arc() {}, rect() {}, fill() {}, stroke() {},
27
+ clip() {}, translate() {}, scale() {}, rotate() {}, transform() {}, setTransform() {},
28
+ clearRect() {}, fillRect() {}, strokeRect() {}, setLineDash() {},
29
+ fillText() {}, strokeText() {},
30
+ measureText: () => ({ width: 0 }),
31
+ getImageData: () => ({ data: new Uint8ClampedArray(4) }),
32
+ createImageData: (w, h) => ({ data: new Uint8ClampedArray(Math.max(0, w) * Math.max(0, h) * 4), width: w, height: h }),
33
+ putImageData() {}, drawImage() {}, isPointInPath: () => false,
34
+ createLinearGradient: () => ({ addColorStop() {} }),
35
+ canvas: { width: 1, height: 1 },
36
+ };
37
+ }
38
+
39
+ // Installs a headless DOM as globals, for a plain Node process (the CLI) that
40
+ // otherwise has none. Idempotent enough for the CLI's one-shot use — it is not
41
+ // meant to be called more than once per process.
42
+ export async function installNodeDom() {
43
+ let Window;
44
+ try {
45
+ ({ Window } = await import("happy-dom"));
46
+ } catch {
47
+ throw new Error(
48
+ "ingest: converting SVG headlessly needs happy-dom (an optional peer dependency) — " +
49
+ "install it with `npm install happy-dom`",
50
+ );
51
+ }
52
+ const window = new Window();
53
+ // Node 21+ defines a getter-only `navigator` global of its own, so a plain
54
+ // Object.assign throws ("Cannot set property navigator of #<Object> which
55
+ // has only a getter") the moment it reaches that key. Define each property
56
+ // explicitly instead, overriding whatever accessor Node already installed.
57
+ for (const [key, value] of Object.entries({
58
+ window, self: window, document: window.document, navigator: window.navigator,
59
+ DOMParser: window.DOMParser, HTMLCanvasElement: window.HTMLCanvasElement,
60
+ Image: window.Image, SVGElement: window.SVGElement,
61
+ })) {
62
+ Object.defineProperty(globalThis, key, { value, writable: true, configurable: true, enumerable: true });
63
+ }
64
+ // paper-core builds a canvas and asks for a 2D context at module load;
65
+ // happy-dom has no canvas backend, and paper never touches the raster
66
+ // context for geometry (see svg-ingest.js's own header for why a no-op
67
+ // stub is enough).
68
+ globalThis.HTMLCanvasElement.prototype.getContext = stubCanvas2dContext;
69
+ }
@@ -0,0 +1,57 @@
1
+ // src/framework/ingest/registry.js
2
+ // The one table that answers "what does this file become?" — read by the panel's
3
+ // drop widget AND by `partforge ingest`. It does NOT route to part fields: the
4
+ // declaration-function pattern (`images: (p) => ({ relief: p.relief })`) already
5
+ // does that, because a control writes into its own param key and the author's
6
+ // declaration puts that key in the right field.
7
+ //
8
+ // DOM-free, node:-free, and converter-free AT MODULE SCOPE: `convert` is a thunk
9
+ // returning a dynamic import, so reading the table costs nothing and the CLI
10
+ // never loads paper.js for a font.
11
+ import { sniffMediaType } from "./sniff.js";
12
+
13
+ export const ASSET_KINDS = ["image", "vector", "font"];
14
+
15
+ const ROWS = [
16
+ {
17
+ kind: "image",
18
+ name: "Image", // the human slot name — "Try the ${name} control" in a refusal message
19
+ label: "an image (PNG, JPG or WebP)",
20
+ accepts: ["image/png", "image/jpeg", "image/webp"],
21
+ convert: () => import("./image-ingest.js").then((m) => m.imageToPng),
22
+ },
23
+ {
24
+ kind: "vector",
25
+ name: "Artwork",
26
+ label: "artwork (SVG)",
27
+ accepts: ["image/svg+xml"],
28
+ convert: () => import("./svg-ingest.js").then((m) => m.ingestSvg),
29
+ },
30
+ {
31
+ kind: "font",
32
+ name: "Font",
33
+ label: "a font (TTF or OTF)",
34
+ accepts: ["font/ttf", "font/otf"],
35
+ convert: null, // used as-is; validated, never converted
36
+ },
37
+ ];
38
+
39
+ export const rowFor = (kind) => ROWS.find((r) => r.kind === kind);
40
+
41
+ // Which kind, if any, WOULD accept this media type — the "use the Artwork slot"
42
+ // hint. null when no slot accepts it (an unsupported format, e.g. WOFF2).
43
+ const kindAccepting = (mediaType) => ROWS.find((r) => r.accepts.includes(mediaType))?.kind ?? null;
44
+
45
+ export function classify(bytes, kind) {
46
+ const mediaType = sniffMediaType(bytes);
47
+ const row = rowFor(kind);
48
+ if (row && mediaType && row.accepts.includes(mediaType)) return { ok: true, mediaType };
49
+ return { ok: false, reason: mediaType ? "wrong-type" : "unrecognised",
50
+ mediaType, suggestKind: mediaType ? kindAccepting(mediaType) : null };
51
+ }
52
+
53
+ export async function convertFor(kind, mediaType) {
54
+ const row = rowFor(kind);
55
+ if (!row || !row.accepts.includes(mediaType)) return null;
56
+ return row.convert ? row.convert() : null;
57
+ }
@@ -0,0 +1,96 @@
1
+ // src/framework/ingest/sniff.js
2
+ // Bytes -> media type. The whole point is that a caller passes BYTES, never a
3
+ // filename: a file's claimed type is user input, and this is the path a
4
+ // mislabelled upload would travel. Nothing here trusts an extension because
5
+ // nothing here is given one.
6
+ //
7
+ // DOM-free and node:-free, and free of any converter import, so both the panel
8
+ // and the CLI can read it without loading paper.js.
9
+
10
+ const MAGIC = [
11
+ { type: "image/png", bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
12
+ { type: "image/jpeg", bytes: [0xff, 0xd8, 0xff] },
13
+ { type: "font/otf", bytes: [0x4f, 0x54, 0x54, 0x4f] }, // "OTTO"
14
+ { type: "font/woff2", bytes: [0x77, 0x4f, 0x46, 0x32] }, // "wOF2"
15
+ { type: "font/ttf", bytes: [0x00, 0x01, 0x00, 0x00] },
16
+ { type: "font/ttf", bytes: [0x74, 0x72, 0x75, 0x65] }, // "true"
17
+ ];
18
+
19
+ const startsWith = (u8, sig) => {
20
+ if (u8.length < sig.length) return false;
21
+ for (let i = 0; i < sig.length; i++) if (u8[i] !== sig[i]) return false;
22
+ return true;
23
+ };
24
+
25
+ // How far into a text file we look for an <svg root. Enough for an XML
26
+ // declaration, a DOCTYPE and a licence comment; bounded so a huge non-SVG text
27
+ // file is not scanned end to end. An SVG behind more than 4 KB of leading
28
+ // comments returns null rather than being recognised — an accepted bounded false
29
+ // negative.
30
+ const SVG_SCAN = 4096;
31
+
32
+ export function sniffMediaType(input) {
33
+ if (!input) return null;
34
+ const u8 = input instanceof Uint8Array ? input : new Uint8Array(input);
35
+ if (u8.length === 0) return null;
36
+
37
+ for (const m of MAGIC) if (startsWith(u8, m.bytes)) return m.type;
38
+
39
+ // WebP is RIFF????WEBP — the size field sits between the two tags.
40
+ if (u8.length >= 12 && startsWith(u8, [0x52, 0x49, 0x46, 0x46])
41
+ && u8[8] === 0x57 && u8[9] === 0x45 && u8[10] === 0x42 && u8[11] === 0x50) {
42
+ return "image/webp";
43
+ }
44
+
45
+ // SVG has no magic number. Look for an <svg ROOT element by skipping leading
46
+ // prologue (BOM, whitespace, XML declaration, comments, DOCTYPE), then
47
+ // requiring the next real tag to be <svg followed by whitespace, >, or /.
48
+ // This anchors to the document root and rejects HTML files with inline <svg>.
49
+ const decoder = new TextDecoder("utf-8", { fatal: false });
50
+ const head = decoder.decode(u8.subarray(0, SVG_SCAN));
51
+
52
+ let pos = 0;
53
+ const len = head.length;
54
+
55
+ // Skip UTF-8 BOM
56
+ if (head.charCodeAt(0) === 0xFEFF) pos = 1;
57
+
58
+ // Skip leading whitespace
59
+ while (pos < len && /\s/.test(head[pos])) pos++;
60
+
61
+ // Skip XML declaration
62
+ if (head.substring(pos).startsWith("<?xml")) {
63
+ const end = head.indexOf("?>", pos);
64
+ if (end !== -1) {
65
+ pos = end + 2;
66
+ while (pos < len && /\s/.test(head[pos])) pos++;
67
+ }
68
+ }
69
+
70
+ // Skip DOCTYPE
71
+ if (head.substring(pos).startsWith("<!DOCTYPE")) {
72
+ const end = head.indexOf(">", pos);
73
+ if (end !== -1) {
74
+ pos = end + 1;
75
+ while (pos < len && /\s/.test(head[pos])) pos++;
76
+ }
77
+ }
78
+
79
+ // Skip comments
80
+ while (head.substring(pos).startsWith("<!--")) {
81
+ const end = head.indexOf("-->", pos);
82
+ if (end !== -1) {
83
+ pos = end + 3;
84
+ while (pos < len && /\s/.test(head[pos])) pos++;
85
+ } else {
86
+ break;
87
+ }
88
+ }
89
+
90
+ // Check if the next real tag is <svg
91
+ if (head.substring(pos).match(/^<svg[\s>\/]/i)) {
92
+ return "image/svg+xml";
93
+ }
94
+
95
+ return null;
96
+ }
@@ -11,7 +11,8 @@ import { imageControlAllows, imageSourceAllowed, isNoImageSource } from "./image
11
11
  import { imagesFor, ensureImages } from "./images.js";
12
12
  import { ensureImports, resolveImports } from "./imports.js";
13
13
  import { safeName } from "./safe-name.js";
14
- import { ensureVectors } from "./vectors.js";
14
+ import { vectorControlAllows, vectorSourceAllowed, isNoVectorSource } from "./vector-source.js";
15
+ import { vectorsFor, ensureVectors } from "./vectors.js";
15
16
  import { exportSubParts, resolveParams, buildPosed } from "./part-model.js";
16
17
 
17
18
  // The oracle loads LAZILY, per job family, never at worker boot. It is the largest
@@ -148,10 +149,14 @@ export async function handle(kernel, part, msg, post, opts = {}) {
148
149
  // default.
149
150
  const { p, d } = resolveParams(part, msg.params, (params) => {
150
151
  // A param bound to a `type: "font"` control is user input — on a shared
151
- // link it is arbitrary attacker-supplied text that `fonts: (p) => …` would
152
- // turn into a fetch URL. Refuse out-of-`allow` values back to the part's
153
- // own default rather than failing the build: a bad link should show the
154
- // part, not an error page.
152
+ // link a STRING value is arbitrary attacker-supplied text that
153
+ // `fonts: (p) => …` would turn into a fetch URL. A BYTE value
154
+ // (ArrayBuffer/typed array) always passes fontSourceAllowed regardless of
155
+ // `allow` see font-source.js's header: it cannot have arrived via a
156
+ // share link (a URL can't carry megabytes), only from the host's own
157
+ // trusted panel (the drop-target path). Refuse out-of-`allow` values back
158
+ // to the part's own default rather than failing the build: a bad link
159
+ // should show the part, not an error page.
155
160
  for (const [key, allow] of fontControlAllows(part)) {
156
161
  const v = params[key];
157
162
  if (isNoFontSource(v) || fontSourceAllowed(v, allow)) continue;
@@ -179,6 +184,29 @@ export async function handle(kernel, part, msg, post, opts = {}) {
179
184
  jobWarnings.push({ part: null, message });
180
185
  params[key] = part.defaults?.[key];
181
186
  }
187
+ // Same shape again for `type: "vector"` controls — a param bound to one
188
+ // is user input, and on a shared link a STRING value is arbitrary
189
+ // attacker text that `vectors: (p) => …` would turn into a fetch URL.
190
+ // Unlike the two blocks above, a non-string value here does NOT bypass
191
+ // the check on a "can't survive a link" plausibility argument — see
192
+ // vector-source.js's header: a parsed vector document is plain JSON and
193
+ // survives a link just fine. It's permitted for the narrower, still-
194
+ // sufficient structural reason that vectorSourceAllowed applies:
195
+ // asset-resolve.js only ever calls `fetch` for a string/URL source, so
196
+ // an object (or bytes) can never become the SSRF-style request `allow`
197
+ // exists to gate. Runs in this same sanitize hook, not after
198
+ // resolveParams returns, for the reason both comments above state:
199
+ // rewriting p[key] afterwards would leave derive() — and therefore `d`
200
+ // and the geometry — holding the refused value while build() saw the
201
+ // default.
202
+ for (const [key, allow] of vectorControlAllows(part)) {
203
+ const v = params[key];
204
+ if (isNoVectorSource(v) || vectorSourceAllowed(v, allow)) continue;
205
+ const message = `vector source for "${key}" is not allowed — using the default`;
206
+ onProgress(message);
207
+ jobWarnings.push({ part: null, message });
208
+ params[key] = part.defaults?.[key];
209
+ }
182
210
  });
183
211
  // Preload any part-declared fonts into the kernel before building. A lazy
184
212
  // dynamic import because this is async context (unlike the synchronous
@@ -291,7 +319,25 @@ export async function handle(kernel, part, msg, post, opts = {}) {
291
319
  // prevent. Guarding this on `part.vectors` would skip exactly the case where
292
320
  // pruning matters most: a worker rebound from a part WITH artwork to one
293
321
  // WITHOUT would leave the old names resolvable forever.
294
- await ensureVectors(kernel, part.vectors);
322
+ //
323
+ // Empty sources are filtered out first, exactly as the fonts and images
324
+ // blocks above do with their own isNo*Source helper: an empty default is
325
+ // the natural shape for a drop-target control (`defaults: { art: "" }`,
326
+ // with `vectors: (p) => ({ badge: p.art })`), and passing "" through to
327
+ // ensureVectors reaches `fetch("")` and fails with "Failed to parse URL
328
+ // from" — a first-run error message that names nothing an author can act
329
+ // on. An unset source declares NO artwork for that name; a build that
330
+ // still calls k.vector2d on it gets the ordinary unknown-vector throw, and
331
+ // the progress note keeps a genuine typo visible rather than swallowed.
332
+ const vectorsDecl = vectorsFor(part, p) ?? {};
333
+ const declaredVectors = Object.fromEntries(
334
+ Object.entries(vectorsDecl).filter(([name, src]) => {
335
+ if (!isNoVectorSource(src)) return true;
336
+ onProgress(`no vector source declared for "${name}" — skipping`);
337
+ return false;
338
+ }),
339
+ );
340
+ await ensureVectors(kernel, declaredVectors);
295
341
  // Local shorthand over the shared helper: kernel/part/view/p/d are fixed per job.
296
342
  const posed = (name, purpose, prog) => buildPosed(kernel, part, name, { purpose, view: msg.view, p, d, onProgress: prog });
297
343
  // Explicit selection (headless exportParts) overrides view-derived selection.
@@ -1,6 +1,8 @@
1
- // Group 10 — vector-art call well-formedness. All three conditions throw at
2
- // build time anyway; these rules move them ahead of the kernel boot, which is
3
- // where an authoring agent wants them.
1
+ // Group 10 — vector-art call well-formedness, plus vector-control wiring. The
2
+ // three call rules catch conditions that throw at build time anyway; they move
3
+ // them ahead of the kernel boot, which is where an authoring agent wants them.
4
+ // The fourth (vector-control-not-in-vectors) catches the one shape that never
5
+ // throws at all — a picker wired to nothing.
4
6
  //
5
7
  // All read `probe().calls`, whose `args` are JSON.stringify of the RESOLVED
6
8
  // argument values under the part's default params (probe.js's `describe`), not
@@ -16,9 +18,19 @@
16
18
  // package's header), so without a supplied document neither rule can tell
17
19
  // units from shapes and both stay silent rather than guess.
18
20
  import { err } from "./finding.js";
21
+ import { vectorControlAllows } from "../vector-source.js";
19
22
 
20
23
  const declaredVectors = (part) => Object.keys(part?.vectors ?? {});
21
24
 
25
+ const isPlainObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v);
26
+
27
+ // A URL-shaped sentinel (has a "://"), not an arbitrary string — some `vectors`
28
+ // functions run `new URL(v)` or similar on the value before deciding whether to
29
+ // use it, and an arbitrary string would make that throw and short-circuit the
30
+ // probe for reasons that have nothing to do with whether the key is read. Same
31
+ // device, and the same reason, as rules-images.js's.
32
+ const SENTINEL = "pf-lint-sentinel://vector-control-not-in-vectors";
33
+
22
34
  // The name arrives JSON-serialized, so JSON.parse recovers it — and yields null
23
35
  // for anything that is not a string (import-unknown-name reads its name the same way).
24
36
  const literalName = (src) => {
@@ -42,6 +54,15 @@ export const VECTOR_RULES = [
42
54
  {
43
55
  id: "vector-unknown-name",
44
56
  run: ({ part, probe }) => {
57
+ // Only a STATIC `vectors` object has statically-knowable names. A
58
+ // function's return value depends on params lint doesn't enumerate, and
59
+ // `Object.keys(fn)` is `[]` — without this guard every k.vector2d call in
60
+ // a function-form part reports as unknown, and since `partforge measure`
61
+ // runs lint first, the part cannot be measured at all. Exactly the guard
62
+ // rules-fonts.js and rules-images.js already carry; the function form of
63
+ // `vectors` arrived after this rule and nobody carried it across.
64
+ // vector-control-not-in-vectors below is what covers the function form.
65
+ if (typeof part?.vectors === "function") return [];
45
66
  const known = new Set(declaredVectors(part));
46
67
  const seen = new Set();
47
68
  const out = [];
@@ -109,4 +130,48 @@ export const VECTOR_RULES = [
109
130
  return out;
110
131
  },
111
132
  },
133
+ {
134
+ // The vectors twin of image-control-not-in-images / font-control-not-in-fonts:
135
+ // a `type: "vector"` control whose key never reaches `vectors:` changes a
136
+ // param and nothing else — the artwork never moves — and without this the
137
+ // mistake only shows up at build time, or not at all.
138
+ //
139
+ // It covers BOTH shapes of the mistake, because for vectors both are real:
140
+ // a static `vectors` object provably cannot read a param, so a vector
141
+ // control beside one is inert by construction (font-control-not-in-fonts's
142
+ // question); and a function-form `vectors` that simply never returns the
143
+ // picked value is inert too, which can only be established by calling it
144
+ // with a sentinel (image-control-not-in-images's question). Together with
145
+ // vector-unknown-name — which runs only for the static form — every part
146
+ // shape is judged by exactly one of the two rules.
147
+ id: "vector-control-not-in-vectors",
148
+ run: ({ part, p }) => {
149
+ const controls = vectorControlAllows(part);
150
+ if (controls.size === 0) return [];
151
+ const isFn = typeof part?.vectors === "function";
152
+ // A function may wrap the picked value (`new URL(p.art)`) rather than
153
+ // pass it through, so match on the sentinel appearing in the value, not
154
+ // on identity.
155
+ const mentions = (v) => (typeof v === "string" || v instanceof URL) && String(v).includes(SENTINEL);
156
+ const out = [];
157
+ for (const key of controls.keys()) {
158
+ if (isFn) {
159
+ let resolved;
160
+ try { resolved = part.vectors({ ...p, [key]: SENTINEL }); }
161
+ catch { continue; } // can't be probed safely — not evidence either way
162
+ if (isPlainObject(resolved) && Object.values(resolved).some(mentions)) continue;
163
+ out.push(err("vector-control-not-in-vectors",
164
+ `control "${key}" is a vector picker, but this part's \`vectors\` function never returns the picked value — the picked value is never resolved.`,
165
+ `Reference p.${key} from vectors, e.g. vectors: (p) => ({ ${key}: p.${key} }), and consume it with k.vector2d("${key}", { width: 20 }).`,
166
+ "vectors"));
167
+ continue;
168
+ }
169
+ out.push(err("vector-control-not-in-vectors",
170
+ `control "${key}" is a vector picker, but this part's \`vectors\` is ${part?.vectors ? "a static object" : "missing"} — the picked value is never resolved.`,
171
+ `Declare vectors as a function of params, e.g. vectors: (p) => ({ ${key}: p.${key} }), and reference it with k.vector2d("${key}", { width: 20 }).`,
172
+ "vectors"));
173
+ }
174
+ return out;
175
+ },
176
+ },
112
177
  ];
@@ -299,12 +299,24 @@ function createCleanupStack() {
299
299
  // // message — and calls runtime.annotate.send() itself.
300
300
  // // Ignored without onAnnotationSend (there is no toolbar
301
301
  // // to place it in).
302
+ // onAssetUpload(blob, { kind, filename }) // the drop widget's (widgets/file-drop.js) upload hook:
303
+ // // async, resolves to a source string (an https: URL or a
304
+ // // host token) that gets written into the param. `blob` is
305
+ // // the CONVERTED artifact — a PNG, a partforge-vector JSON
306
+ // // blob, or the original file for a font — never the user's
307
+ // // raw drop. Omit it and the converted bytes land straight
308
+ // // in the param instead (what the partforge-cloud sandbox
309
+ // // needs, since it cannot fetch URLs — a correct destination,
310
+ // // not a degraded one). A rejection is reported through the
311
+ // // control's own onError; the widget keeps the converted blob
312
+ // // so a retry costs a network call, not a reconvert.
302
313
  // Every `elements` entry defaults to the legacy global-ID lookup (below), resolved
303
314
  // exactly once here — submodules take element refs and never query the document.
304
315
  // `container`/`controls` remain as deprecated aliases for elements.viewer/.controls.
305
316
  export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDownload, onViewChange, onParamsCommit, onAnnotationSend,
306
317
  fontCatalog,
307
318
  imageCatalog,
319
+ onAssetUpload,
308
320
  viewerState,
309
321
  annotateSend = "viewbar",
310
322
  container: legacyContainer, controls: legacyControls } = {}) {
@@ -964,7 +976,7 @@ export function mount(part, { createWorker, elements = {}, onBuild, onPick, onDo
964
976
  }, onParamsCommit
965
977
  ? (changed) => onParamsCommit({ changed, params: { ...params } })
966
978
  : undefined,
967
- { fontCatalog, imageCatalog });
979
+ { fontCatalog, imageCatalog, onAssetUpload });
968
980
  cleanup.defer(() => panel.dispose());
969
981
  panelRef = panel;
970
982
  const updateRelevance = () => {
@@ -228,6 +228,7 @@ export function buildControls(root, parameters, params, onDirty, onCommit, opts
228
228
  info,
229
229
  fontCatalog: opts.fontCatalog,
230
230
  imageCatalog: opts.imageCatalog,
231
+ onAssetUpload: opts.onAssetUpload,
231
232
  });
232
233
  nodeEls.set(node.id, widget.el);
233
234
  if (node.key && !keyToId.has(node.key)) keyToId.set(node.key, node.id);
@@ -38,6 +38,7 @@ export const WIDGET_SPECS = [
38
38
  { type: "radio", kind: "control", fields: [...AUTHOR_COMMON, "options"] },
39
39
  { type: "font", kind: "control", fields: [...AUTHOR_COMMON, "allow", "preview"] },
40
40
  { type: "image", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
41
+ { type: "vector", kind: "control", fields: [...AUTHOR_COMMON, "allow"] },
41
42
  { type: "readout", kind: "display", fields: ["type", "label", "description", "unit", "derivedKey", "hidden", "when", "whenFalse"] },
42
43
  ];
43
44
 
@@ -58,6 +59,7 @@ const AUTHOR_EXTRAS = {
58
59
  radio: ["options"],
59
60
  font: ["allow", "preview"],
60
61
  image: ["allow"],
62
+ vector: ["allow"],
61
63
  };
62
64
  const AUTHOR_FIELDS = new Map(Object.entries(AUTHOR_EXTRAS).map(
63
65
  ([type, extra]) => [type, [...AUTHOR_COMMON, ...extra]]));