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.
- package/bin/cli.js +115 -9
- package/docs/AUTHORING-PARTS.md +181 -13
- package/docs/ERROR-PATTERNS.md +3 -3
- package/docs/VECTOR-FORMAT.md +23 -17
- package/package.json +9 -1
- package/src/framework/font-source.js +18 -1
- package/src/framework/image-source.js +12 -8
- package/src/framework/images.js +1 -1
- package/src/framework/{image-ingest.js → ingest/image-ingest.js} +2 -2
- package/src/framework/ingest/node-dom.js +69 -0
- package/src/framework/ingest/registry.js +57 -0
- package/src/framework/ingest/sniff.js +96 -0
- package/src/framework/jobs.js +52 -6
- package/src/framework/lint/rules-vector.js +68 -3
- package/src/framework/mount.js +13 -1
- package/src/framework/panel/render.js +1 -0
- package/src/framework/panel/widget-specs.js +2 -0
- package/src/framework/panel/widgets/file-drop.js +321 -0
- package/src/framework/panel/widgets/font.js +51 -12
- package/src/framework/panel/widgets/image.js +17 -3
- package/src/framework/panel/widgets/index.js +2 -0
- package/src/framework/panel/widgets/vector.js +77 -0
- package/src/framework/vector-source.js +127 -0
- package/src/framework/vectors.js +9 -0
- package/src/index.js +0 -1
- package/src/ingest.js +1 -0
- package/src/parts/emblem.js +2 -1
- package/types/index.d.ts +66 -12
- package/types/ingest.d.ts +13 -0
|
@@ -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/
|
|
7
|
-
//
|
|
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
|
+
}
|
package/src/framework/jobs.js
CHANGED
|
@@ -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 {
|
|
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
|
|
152
|
-
// turn into a fetch URL.
|
|
153
|
-
//
|
|
154
|
-
//
|
|
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
|
-
|
|
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
|
|
2
|
-
//
|
|
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
|
];
|
package/src/framework/mount.js
CHANGED
|
@@ -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]]));
|