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.
@@ -0,0 +1,127 @@
1
+ // What a PARAM-supplied vector source may be. Author-declared `vectors`
2
+ // sources are code and get no restriction; this file exists only for the
3
+ // other case — a value that arrived in `params`, which on a shared link is
4
+ // attacker-controlled input that `vectors: (p) => …` would turn into a fetch
5
+ // URL.
6
+ //
7
+ // This file's allow rule differs from its two siblings
8
+ // (font-source.js/image-source.js) in ONE place, and getting that difference
9
+ // right matters: those two exempt every non-string (bytes) source from the
10
+ // allow check on the reasoning that an ArrayBuffer cannot survive a share
11
+ // link — a URL can't carry megabytes, so bytes in params can only have been
12
+ // placed there by the host's own trusted panel. That reasoning does NOT
13
+ // transfer to vector artwork: `type: "vector"`'s drop target writes a PARSED
14
+ // partforge-vector document — plain JSON — into params when there is no upload
15
+ // hook, and unlike raw bytes, plain JSON round-trips a share link perfectly
16
+ // (it's just more of the same params payload). Do not copy the
17
+ // bytes-can't-survive-a-link justification here; it would be wrong.
18
+ //
19
+ // So the exemption this file grants is NOT "any object". The gate has to be
20
+ // read against what asset-resolve.js actually does, in its order:
21
+ // `unwrapModule(v)` runs FIRST, and only THEN does the resolver dispatch on
22
+ // shape. A `{ default: "http://169.254.169.254/…" }` wrapper is therefore
23
+ // unwrapped to a plain string and handed to `fetch` — arbitrary scheme,
24
+ // arbitrary host, in ~40 bytes of JSON a share link carries effortlessly. An
25
+ // earlier version of this file exempted every object on the claim that "an
26
+ // object never reaches the fetch branch"; that claim was FALSE for exactly
27
+ // this shape, and it is deleted rather than qualified.
28
+ //
29
+ // The rule that does hold, and what this file gates on, is: unwrap first, then
30
+ // judge the value the RESOLVER will see.
31
+ //
32
+ // - a string or `URL` — fetchable, so it gets the full `allow` treatment;
33
+ // - bytes (ArrayBuffer/view) — never handed to `fetch`, and (unlike JSON)
34
+ // genuinely cannot ride a link, so exempt;
35
+ // - a function/thunk — refused outright. Its return value is a fetch source
36
+ // that cannot be known at check time, so there is nothing to gate on;
37
+ // - a plain object that does NOT unwrap to any of the above — the genuinely
38
+ // inert case, the already-parsed partforge-vector document vectors.js's
39
+ // `asParsedFile` claims. It is validated downstream by `toInternalDocument`,
40
+ // a pure shape check that never touches the network, so it is exempt;
41
+ // - anything else (arrays, numbers, booleans) — refused. None of them is a
42
+ // valid source, and a refusal simply restores the part's default.
43
+ //
44
+ // `unwrapModule` is imported, never re-implemented: this gate is only sound
45
+ // while it unwraps by exactly the same rule the resolver does.
46
+ //
47
+ // DOM-free and node:-free: jobs.js (worker graph) and the panel both import it.
48
+ // asset-resolve.js is itself dependency-free, so importing it keeps
49
+ // partforge/lint's zero-dependency closure intact.
50
+ import { unwrapModule } from "./asset-resolve.js";
51
+
52
+ export const VECTOR_ALLOW_DEFAULT = ["https"];
53
+
54
+ const ASSET_SCHEME = "pfc-asset:";
55
+
56
+ // The "unset" vector source. An empty value declares NO artwork for that
57
+ // name — mirrors isNoFontSource/isNoImageSource exactly. Never a source to
58
+ // fetch, and never a source to refuse.
59
+ export const isNoVectorSource = (v) => v === undefined || v === null || v === "";
60
+
61
+ const isBytes = (v) => v instanceof ArrayBuffer || ArrayBuffer.isView(v);
62
+
63
+ // An already-parsed partforge-vector document: object-shaped, and not any of
64
+ // the shapes the resolver reads as a way to REACH bytes. Deliberately the same
65
+ // structural test vectors.js's `asParsedFile` applies (arrays excluded — an
66
+ // array is never a file), so "what this gate exempts" and "what the resolver
67
+ // adopts without fetching" stay the same set.
68
+ const isParsedDocument = (v) =>
69
+ v != null && typeof v === "object" && !Array.isArray(v)
70
+ && !isBytes(v) && !(v instanceof URL);
71
+
72
+ // Parse once; an unparseable string is refused rather than guessed at.
73
+ function parse(source) {
74
+ try { return new URL(source); } catch { return null; }
75
+ }
76
+
77
+ export function vectorSourceAllowed(source, allow = VECTOR_ALLOW_DEFAULT) {
78
+ // Unwrap FIRST — asset-resolve.js does, before it dispatches on shape, so a
79
+ // `{ default: … }` wrapper must be judged by what it unwraps to. See header.
80
+ const v = unwrapModule(source);
81
+ // A thunk (before or after unwrapping) resolves to a source this check cannot
82
+ // see, so there is nothing to gate; refuse rather than trust it.
83
+ if (typeof source === "function" || typeof v === "function") return false;
84
+ if (isBytes(v)) return true; // never handed to `fetch`
85
+ if (isParsedDocument(v)) return true; // inert; validated, never fetched
86
+ // Everything the resolver would fetch — a string or a `URL` — gets the full
87
+ // allow treatment. Everything else (arrays, numbers, booleans) is refused.
88
+ if (!(typeof v === "string" || v instanceof URL)) return false;
89
+ const u = v instanceof URL ? v : parse(v);
90
+ if (!u) return false;
91
+ for (const kind of allow) {
92
+ // hostname/protocol, never a substring of the raw string — same rule
93
+ // font-source.js's header explains: a URL merely CONTAINING
94
+ // "pfc-asset://" must not pass, and neither must a lookalike host.
95
+ if (kind === "https" && u.protocol === "https:") return true;
96
+ if (kind === "asset" && u.protocol === ASSET_SCHEME) return true;
97
+ }
98
+ return false;
99
+ }
100
+
101
+ // paramKey → allow list, for every `type: "vector"` control in the authored
102
+ // tree — new-shape (`controls`, including nested groups) AND legacy-shape
103
+ // (`advanced`/`toggles`/`features`, where panel/legacy.js desugars a
104
+ // descriptor's `control:` field to `type:`), mirroring
105
+ // imageControlAllows/fontControlAllows exactly. Missing the legacy arrays
106
+ // here would leave a `{key, control:"vector"}` descriptor with no entry in
107
+ // the returned map, and jobs.js's check only looks at keys present in the
108
+ // map — so a legacy-declared vector control would get silently
109
+ // unrestricted. Tolerant of any array being absent or malformed; it must
110
+ // never throw on an existing part.
111
+ export function vectorControlAllows(part) {
112
+ const out = new Map();
113
+ const visit = (nodes) => {
114
+ for (const n of nodes ?? []) {
115
+ if (!n || typeof n !== "object") continue;
116
+ if (Array.isArray(n.controls)) visit(n.controls);
117
+ if (Array.isArray(n.advanced)) visit(n.advanced);
118
+ if (Array.isArray(n.toggles)) visit(n.toggles);
119
+ if (Array.isArray(n.features)) visit(n.features);
120
+ if ((n.type === "vector" || n.control === "vector") && typeof n.key === "string") {
121
+ out.set(n.key, Array.isArray(n.allow) && n.allow.length ? n.allow : VECTOR_ALLOW_DEFAULT);
122
+ }
123
+ }
124
+ };
125
+ visit(part?.parameters);
126
+ return out;
127
+ }
@@ -83,6 +83,15 @@ const resolveOne = makeAssetResolver(
83
83
  (value) => asParsedFile(value) ?? undefined,
84
84
  );
85
85
 
86
+ // `vectors` may be a plain { name: source } map, or a function of the resolved
87
+ // params — the second form is what lets a `type: "vector"` control drive the
88
+ // artwork. Resolving it needs `p`, which is why this is a separate step from
89
+ // resolveVectors rather than folded into it. Mirrors fontsFor/imagesFor.
90
+ export function vectorsFor(part, p) {
91
+ const decl = part?.vectors;
92
+ return typeof decl === "function" ? decl(p) : decl;
93
+ }
94
+
86
95
  export async function resolveVectors(vectorsDecl) {
87
96
  // A function reaching here means a caller passed `part.vectors` raw, the way
88
97
  // fonts.js's resolveFonts guards against the same mistake for `part.fonts`.
package/src/index.js CHANGED
@@ -4,4 +4,3 @@
4
4
  // Part build functions import geometry helpers from "partforge/geometry" instead.
5
5
  export { mount } from "./framework/index.js";
6
6
  export { viewSubParts } from "./framework/part-model.js";
7
- export { imageToPng } from "./framework/image-ingest.js";
package/src/ingest.js CHANGED
@@ -6,3 +6,4 @@
6
6
  // Deliberately NOT re-exported from `partforge` (the main entry) or from
7
7
  // `partforge/geometry`: this must stay unreachable from the geometry worker.
8
8
  export { ingestSvg } from "./framework/ingest/svg-ingest.js";
9
+ export { imageToPng } from "./framework/ingest/image-ingest.js";
@@ -19,7 +19,8 @@
19
19
  // work under Vite and fail in the CLI.
20
20
  //
21
21
  // The source artwork lives beside it as emblem.svg, and the .json is regenerated
22
- // with `node scripts/ingest-svg.mjs src/parts/assets/emblem.svg`. plate.vector.json
22
+ // with `npx partforge ingest src/parts/assets/emblem.svg --out
23
+ // src/parts/assets/emblem.vector.json`. plate.vector.json
23
24
  // is hand-authored — no ingest step, no source SVG — and is kept legible enough
24
25
  // to serve as documentation's worked example of a multi-shape, role-composed file.
25
26
  import plate from "./assets/plate.vector.json" with { type: "json" };
package/types/index.d.ts CHANGED
@@ -101,6 +101,50 @@ export interface MountElements {
101
101
  };
102
102
  }
103
103
 
104
+ /** Which control family a dropped file belongs to. */
105
+ export type AssetKind = "image" | "vector" | "font";
106
+
107
+ /** One selectable face in a `FontCatalog` family. */
108
+ export interface FontVariant {
109
+ variant: string;
110
+ label: string;
111
+ /** What the picker writes into `params`. */
112
+ url: string;
113
+ bytes?: number;
114
+ }
115
+
116
+ export interface FontFamily {
117
+ id: string;
118
+ family: string;
119
+ category?: string;
120
+ variants: FontVariant[];
121
+ /** A name-only subset used to draw the list row. */
122
+ menuUrl?: string;
123
+ }
124
+
125
+ /** Backs `type: "font"` controls. Supplied by the host; partforge ships none. */
126
+ export interface FontCatalog {
127
+ search: (query: string, opts: { limit?: number }) => Promise<FontFamily[]>;
128
+ /** Reverse lookup, so a hashed-filename URL can still be named in the UI. */
129
+ describe?: (source: string) => { family: string; variant: string } | null;
130
+ }
131
+
132
+ export interface ImageAsset {
133
+ id: string;
134
+ label: string;
135
+ /** What the picker writes into `params`. */
136
+ url: string;
137
+ width?: number;
138
+ height?: number;
139
+ thumbUrl?: string;
140
+ }
141
+
142
+ /** Backs `type: "image"` controls. Supplied by the host; partforge ships none. */
143
+ export interface ImageCatalog {
144
+ search: (query: string, opts: { limit?: number }) => Promise<ImageAsset[]>;
145
+ describe?: (source: string) => { label: string; width: number; height: number } | null;
146
+ }
147
+
104
148
  export interface MountOptions {
105
149
  /**
106
150
  * Spawns a geometry worker. Called once per backend with `name` as the
@@ -143,6 +187,28 @@ export interface MountOptions {
143
187
  * part cannot support is dropped, never fatal.
144
188
  */
145
189
  viewerState?: ViewerState | null;
190
+ /**
191
+ * A provider backing every `type: "font"` control in the part. partforge
192
+ * ships none — without one, a font control renders as a plain URL field.
193
+ */
194
+ fontCatalog?: FontCatalog;
195
+ /**
196
+ * A provider backing every `type: "image"` control in the part. Without one,
197
+ * an image control degrades to a plain URL field.
198
+ */
199
+ imageCatalog?: ImageCatalog;
200
+ /**
201
+ * The upload hook for the drop target shared by the `"image"`, `"vector"`
202
+ * and `"font"` controls. Called with the CONVERTED artifact — a PNG, a
203
+ * partforge-vector document serialized as JSON, or the original file for a
204
+ * font — never the user's raw drop, and must resolve to a non-empty source
205
+ * string (an `https:` URL, or a host-defined `pfc-asset:` token) which is
206
+ * written into the param. Anything else, or a rejection, is reported through
207
+ * the control's own error line and never written. Omit it and the converted
208
+ * artifact lands in the param directly — the path a host that cannot fetch
209
+ * URLs needs, not a degraded fallback.
210
+ */
211
+ onAssetUpload?: (blob: Blob, info: { kind: AssetKind; filename: string }) => Promise<string>;
146
212
  /** @deprecated alias for `elements.viewer`. */
147
213
  container?: HTMLElement | null;
148
214
  /** @deprecated alias for `elements.controls`. */
@@ -455,15 +521,3 @@ export function viewSubParts(
455
521
  params: Record<string, ParamValue>,
456
522
  ): string[];
457
523
 
458
- export interface ImageToPngOptions {
459
- /** Long-edge cap in px; an image already under this is not upscaled. Default 1024. */
460
- maxSize?: number;
461
- }
462
-
463
- /**
464
- * Convert any image the browser can decode into a PNG `Blob`, downsampling the
465
- * long edge to `maxSize` on the way. Main-thread only (uses `createImageBitmap`
466
- * and a canvas) — for a host normalising uploads before storing them, since a
467
- * part's `images` field decodes PNG only. Never call from a part's `build`.
468
- */
469
- export function imageToPng(fileOrBlob: Blob | File, options?: ImageToPngOptions): Promise<Blob>;
package/types/ingest.d.ts CHANGED
@@ -116,3 +116,16 @@ export interface IngestSvgOptions {
116
116
  * element is `fill="none"` with no stroke, hidden, or empty).
117
117
  */
118
118
  export function ingestSvg(svgText: string, opts?: IngestSvgOptions): VectorDocument;
119
+
120
+ export interface ImageToPngOptions {
121
+ /** Long-edge cap in px; an image already under this is not upscaled. Default 1024. */
122
+ maxSize?: number;
123
+ }
124
+
125
+ /**
126
+ * Convert any image the browser can decode into a PNG `Blob`, downsampling the
127
+ * long edge to `maxSize` on the way. Main-thread only (uses `createImageBitmap`
128
+ * and a canvas) — for a host normalising uploads before storing them, since a
129
+ * part's `images` field decodes PNG only. Never call from a part's `build`.
130
+ */
131
+ export function imageToPng(fileOrBlob: Blob | File, options?: ImageToPngOptions): Promise<Blob>;