@openfairygui/functions 0.2.0-alpha.35 → 0.2.0-alpha.36

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/dist/index.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-Bv7E2fZE.cjs");
3
- const require_restore = require("./restore-iTgeWr7-.cjs");
2
+ const require_publish = require("./publish-CykUJfVa.cjs");
3
+ const require_restore = require("./restore-CEywQUHz.cjs");
4
4
  const require_uam_transaction = require("./uam-transaction.cjs");
5
5
  //#region src/inspect.ts
6
6
  function mapResource(resource) {
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import { a as decodeText, g as createTransform, h as atlas, i as buildCodegenClasses, l as resolvePackageCodegenPlan, n as resolvePublishOptions, o as encodeText, r as AUTO_GENERATED_CODE_MARK, s as publishCodeGeneration, t as publish } from "./publish--8vagaSA.js";
2
- import { t as restore } from "./restore-CknfGMJM.js";
1
+ import { a as decodeText, g as createTransform, h as atlas, i as buildCodegenClasses, l as resolvePackageCodegenPlan, n as resolvePublishOptions, o as encodeText, r as AUTO_GENERATED_CODE_MARK, s as publishCodeGeneration, t as publish } from "./publish-DXoaC1Nl.js";
2
+ import { t as restore } from "./restore-BQp01WY3.js";
3
3
  import { applyUamTransactionApp, applyUamTransactionAppAsync } from "./uam-transaction.js";
4
4
  //#region src/inspect.ts
5
5
  function mapResource(resource) {
package/dist/node.cjs CHANGED
@@ -1,6 +1,6 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-Bv7E2fZE.cjs");
3
- const require_restore = require("./restore-iTgeWr7-.cjs");
2
+ const require_publish = require("./publish-CykUJfVa.cjs");
3
+ const require_restore = require("./restore-CEywQUHz.cjs");
4
4
  //#region src/adapters/node/plugins.ts
5
5
  const importNative$2 = new Function("id", "return import(id)");
6
6
  async function loadPlugins(doc, pluginsDir) {
package/dist/node.js CHANGED
@@ -1,5 +1,5 @@
1
- import { m as formatPluginError, t as publish, u as resolveProjectBasePath } from "./publish--8vagaSA.js";
2
- import { t as restore } from "./restore-CknfGMJM.js";
1
+ import { m as formatPluginError, t as publish, u as resolveProjectBasePath } from "./publish-DXoaC1Nl.js";
2
+ import { t as restore } from "./restore-BQp01WY3.js";
3
3
  //#region src/adapters/node/plugins.ts
4
4
  const importNative$2 = new Function("id", "return import(id)");
5
5
  async function loadPlugins(doc, pluginsDir) {
@@ -786,8 +786,11 @@ async function collectImage(resource, pkg, inputs, encoder, options, doTrim, log
786
786
  }).png().toBuffer();
787
787
  sourceHasAlpha = true;
788
788
  }
789
- } catch {
790
- if (options.strictOutput) throw new Error(`atlas: Could not read image "${filePath}".`);
789
+ } catch (error) {
790
+ if (options.strictOutput) {
791
+ const detail = error instanceof Error && error.message.startsWith("publishBrowser:") ? ` ${error.message}` : "";
792
+ throw new Error(`atlas: Could not read image "${filePath}".${detail}`);
793
+ }
791
794
  if (origW === 0 || origH === 0) {
792
795
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
793
796
  return;
@@ -786,8 +786,11 @@ async function collectImage(resource, pkg, inputs, encoder, options, doTrim, log
786
786
  }).png().toBuffer();
787
787
  sourceHasAlpha = true;
788
788
  }
789
- } catch {
790
- if (options.strictOutput) throw new Error(`atlas: Could not read image "${filePath}".`);
789
+ } catch (error) {
790
+ if (options.strictOutput) {
791
+ const detail = error instanceof Error && error.message.startsWith("publishBrowser:") ? ` ${error.message}` : "";
792
+ throw new Error(`atlas: Could not read image "${filePath}".${detail}`);
793
+ }
791
794
  if (origW === 0 || origH === 0) {
792
795
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
793
796
  return;
@@ -1,4 +1,4 @@
1
- import { d as basename, f as normalizeComparablePath, p as trimTrailingSlashes } from "./publish--8vagaSA.js";
1
+ import { d as basename, f as normalizeComparablePath, p as trimTrailingSlashes } from "./publish-DXoaC1Nl.js";
2
2
  import { BinaryReader, ProjectType, ProjectWriter, generateId } from "@openfairygui/core";
3
3
  //#region src/restore-internals/output-transaction.ts
4
4
  function isPathWithin(root, candidate) {
@@ -1,4 +1,4 @@
1
- const require_publish = require("./publish-Bv7E2fZE.cjs");
1
+ const require_publish = require("./publish-CykUJfVa.cjs");
2
2
  let _openfairygui_core = require("@openfairygui/core");
3
3
  //#region src/restore-internals/output-transaction.ts
4
4
  function isPathWithin(root, candidate) {
package/dist/web.cjs CHANGED
@@ -1,7 +1,116 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_publish = require("./publish-Bv7E2fZE.cjs");
2
+ const require_publish = require("./publish-CykUJfVa.cjs");
3
3
  let _openfairygui_core = require("@openfairygui/core");
4
+ let fast_xml_parser = require("fast-xml-parser");
4
5
  //#region src/adapters/web/raster.ts
6
+ const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
7
+ const MAX_SVG_DIMENSION = 16384;
8
+ const MAX_SVG_PIXELS = 64 * 1024 * 1024;
9
+ const MAX_SVG_NODES = 5e4;
10
+ const UNSAFE_SVG_ELEMENTS = new Set([
11
+ "a",
12
+ "animate",
13
+ "animatecolor",
14
+ "animatemotion",
15
+ "animatetransform",
16
+ "audio",
17
+ "canvas",
18
+ "discard",
19
+ "embed",
20
+ "feimage",
21
+ "foreignobject",
22
+ "iframe",
23
+ "image",
24
+ "object",
25
+ "script",
26
+ "set",
27
+ "style",
28
+ "video"
29
+ ]);
30
+ function unsafeSvg(message) {
31
+ throw new Error(`publishBrowser: unsafe SVG input (${message}).`);
32
+ }
33
+ function svgLocalName(name) {
34
+ return name.split(":").at(-1).toLowerCase();
35
+ }
36
+ function parseSvgLength(value, name) {
37
+ if (value === void 0) return void 0;
38
+ const match = String(value).match(/^\s*(?:\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/iu);
39
+ if (!match) unsafeSvg(`${name} must use a finite pixel value`);
40
+ const parsed = Number.parseFloat(match[0]);
41
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_SVG_DIMENSION) unsafeSvg(`${name} exceeds the supported dimensions`);
42
+ return parsed;
43
+ }
44
+ function validateSvgAttribute(name, value) {
45
+ const normalizedName = name.toLowerCase();
46
+ if (normalizedName === "xmlns" || normalizedName.startsWith("xmlns:")) return;
47
+ const localName = svgLocalName(name);
48
+ const text = String(value);
49
+ if (localName.startsWith("on")) unsafeSvg(`event attribute "${name}" is not allowed`);
50
+ if (localName === "style" || localName === "src") unsafeSvg(`attribute "${name}" is not allowed`);
51
+ if (localName === "href" && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) unsafeSvg(`external reference in "${name}" is not allowed`);
52
+ if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) unsafeSvg(`external URL in "${name}" is not allowed`);
53
+ for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
54
+ const reference = (match[1] ?? "").trim().replace(/^(['"])(.*)\1$/u, "$2");
55
+ if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) unsafeSvg(`external url() in "${name}" is not allowed`);
56
+ }
57
+ }
58
+ function visitSvgEntry(entry) {
59
+ const pending = [entry];
60
+ let nodeCount = 0;
61
+ while (pending.length > 0) {
62
+ const current = pending.pop();
63
+ for (const [name, value] of Object.entries(current)) {
64
+ if (name === ":@" || name.startsWith("#") || name.startsWith("?")) continue;
65
+ if (++nodeCount > MAX_SVG_NODES) unsafeSvg("node count exceeds the supported limit");
66
+ const localName = svgLocalName(name);
67
+ if (UNSAFE_SVG_ELEMENTS.has(localName)) unsafeSvg(`element <${name}> is not allowed`);
68
+ for (const [attributeName, attributeValue] of Object.entries(current[":@"] ?? {})) validateSvgAttribute(attributeName, attributeValue);
69
+ if (Array.isArray(value)) {
70
+ for (const child of value) if (child && typeof child === "object" && !Array.isArray(child)) pending.push(child);
71
+ }
72
+ }
73
+ }
74
+ }
75
+ function validateSvg(bytes) {
76
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) unsafeSvg("source size is unsupported");
77
+ let source;
78
+ try {
79
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
80
+ } catch {
81
+ unsafeSvg("source is not valid UTF-8");
82
+ }
83
+ if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) unsafeSvg("DTD, entities, and stylesheets are not allowed");
84
+ if (fast_xml_parser.XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) unsafeSvg("source is not well-formed XML");
85
+ const roots = new fast_xml_parser.XMLParser({
86
+ preserveOrder: true,
87
+ ignoreAttributes: false,
88
+ attributeNamePrefix: "",
89
+ parseAttributeValue: false,
90
+ parseTagValue: false,
91
+ processEntities: false,
92
+ trimValues: false
93
+ }).parse(source).flatMap((entry) => Object.keys(entry).filter((name) => name !== ":@" && !name.startsWith("#") && !name.startsWith("?")).map((name) => ({
94
+ entry,
95
+ name
96
+ })));
97
+ if (roots.length !== 1 || svgLocalName(roots[0].name) !== "svg") unsafeSvg("a single <svg> root is required");
98
+ const root = roots[0].entry;
99
+ visitSvgEntry(root);
100
+ const attributes = root[":@"] ?? {};
101
+ const width = parseSvgLength(attributes.width, "width");
102
+ const height = parseSvgLength(attributes.height, "height");
103
+ let viewBoxWidth;
104
+ let viewBoxHeight;
105
+ if (attributes.viewBox !== void 0) {
106
+ const viewBox = String(attributes.viewBox).trim().split(/[\s,]+/u).map(Number);
107
+ if (viewBox.length !== 4 || viewBox.some((value) => !Number.isFinite(value)) || viewBox[2] <= 0 || viewBox[3] <= 0) unsafeSvg("viewBox must contain four finite values with positive dimensions");
108
+ viewBoxWidth = viewBox[2];
109
+ viewBoxHeight = viewBox[3];
110
+ if (viewBoxWidth > MAX_SVG_DIMENSION || viewBoxHeight > MAX_SVG_DIMENSION) unsafeSvg("viewBox exceeds the supported dimensions");
111
+ }
112
+ if ((width ?? viewBoxWidth ?? 300) * (height ?? viewBoxHeight ?? 150) > MAX_SVG_PIXELS) unsafeSvg("pixel count exceeds the supported limit");
113
+ }
5
114
  function getBrowserContext(canvas) {
6
115
  const context = canvas.getContext("2d");
7
116
  if (!context) throw new Error("publishBrowser: a 2D canvas context is unavailable.");
@@ -60,7 +169,15 @@ async function canvasToPng(canvas) {
60
169
  async function decodeRaster(bytes, mimeType) {
61
170
  if (typeof createImageBitmap !== "function") throw new Error("publishBrowser: createImageBitmap is required for atlas PNG generation.");
62
171
  const copy = bytes.slice();
63
- const bitmap = await createImageBitmap(new Blob([copy.buffer], { type: mimeType }));
172
+ if (mimeType === "image/svg+xml") validateSvg(copy);
173
+ const blob = new Blob([copy.buffer], { type: mimeType });
174
+ let bitmap;
175
+ try {
176
+ bitmap = await createImageBitmap(blob);
177
+ } catch (error) {
178
+ if (mimeType !== "image/svg+xml") throw error;
179
+ return decodeSvgWithDom(blob);
180
+ }
64
181
  try {
65
182
  const raster = createRaster(bitmap.width, bitmap.height);
66
183
  getBrowserContext(raster.canvas).drawImage(bitmap, 0, 0);
@@ -69,6 +186,26 @@ async function decodeRaster(bytes, mimeType) {
69
186
  bitmap.close();
70
187
  }
71
188
  }
189
+ async function decodeSvgWithDom(blob) {
190
+ if (typeof globalThis.Image !== "function" || typeof globalThis.URL?.createObjectURL !== "function" || typeof globalThis.URL?.revokeObjectURL !== "function") throw new Error("publishBrowser: createImageBitmap rejected SVG and DOM image decoding is unavailable.");
191
+ const url = globalThis.URL.createObjectURL(blob);
192
+ try {
193
+ const image = new globalThis.Image();
194
+ await new Promise((resolve, reject) => {
195
+ image.onload = () => resolve();
196
+ image.onerror = () => reject(/* @__PURE__ */ new Error("publishBrowser: DOM image decoding failed for SVG."));
197
+ image.src = url;
198
+ });
199
+ const width = image.naturalWidth || image.width;
200
+ const height = image.naturalHeight || image.height;
201
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS) unsafeSvg("decoded dimensions exceed the supported limit");
202
+ const raster = createRaster(width, height);
203
+ getBrowserContext(raster.canvas).drawImage(image, 0, 0);
204
+ return raster;
205
+ } finally {
206
+ globalThis.URL.revokeObjectURL(url);
207
+ }
208
+ }
72
209
  var BrowserImagePipeline = class {
73
210
  rawOutput = false;
74
211
  constructor(raster, decode, write) {
package/dist/web.js CHANGED
@@ -1,6 +1,115 @@
1
- import { c as resolveCodeGenerationSettings, n as resolvePublishOptions, t as publish } from "./publish--8vagaSA.js";
1
+ import { c as resolveCodeGenerationSettings, n as resolvePublishOptions, t as publish } from "./publish-DXoaC1Nl.js";
2
2
  import { ProjectType } from "@openfairygui/core";
3
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
3
4
  //#region src/adapters/web/raster.ts
5
+ const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
6
+ const MAX_SVG_DIMENSION = 16384;
7
+ const MAX_SVG_PIXELS = 64 * 1024 * 1024;
8
+ const MAX_SVG_NODES = 5e4;
9
+ const UNSAFE_SVG_ELEMENTS = new Set([
10
+ "a",
11
+ "animate",
12
+ "animatecolor",
13
+ "animatemotion",
14
+ "animatetransform",
15
+ "audio",
16
+ "canvas",
17
+ "discard",
18
+ "embed",
19
+ "feimage",
20
+ "foreignobject",
21
+ "iframe",
22
+ "image",
23
+ "object",
24
+ "script",
25
+ "set",
26
+ "style",
27
+ "video"
28
+ ]);
29
+ function unsafeSvg(message) {
30
+ throw new Error(`publishBrowser: unsafe SVG input (${message}).`);
31
+ }
32
+ function svgLocalName(name) {
33
+ return name.split(":").at(-1).toLowerCase();
34
+ }
35
+ function parseSvgLength(value, name) {
36
+ if (value === void 0) return void 0;
37
+ const match = String(value).match(/^\s*(?:\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/iu);
38
+ if (!match) unsafeSvg(`${name} must use a finite pixel value`);
39
+ const parsed = Number.parseFloat(match[0]);
40
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_SVG_DIMENSION) unsafeSvg(`${name} exceeds the supported dimensions`);
41
+ return parsed;
42
+ }
43
+ function validateSvgAttribute(name, value) {
44
+ const normalizedName = name.toLowerCase();
45
+ if (normalizedName === "xmlns" || normalizedName.startsWith("xmlns:")) return;
46
+ const localName = svgLocalName(name);
47
+ const text = String(value);
48
+ if (localName.startsWith("on")) unsafeSvg(`event attribute "${name}" is not allowed`);
49
+ if (localName === "style" || localName === "src") unsafeSvg(`attribute "${name}" is not allowed`);
50
+ if (localName === "href" && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) unsafeSvg(`external reference in "${name}" is not allowed`);
51
+ if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) unsafeSvg(`external URL in "${name}" is not allowed`);
52
+ for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
53
+ const reference = (match[1] ?? "").trim().replace(/^(['"])(.*)\1$/u, "$2");
54
+ if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) unsafeSvg(`external url() in "${name}" is not allowed`);
55
+ }
56
+ }
57
+ function visitSvgEntry(entry) {
58
+ const pending = [entry];
59
+ let nodeCount = 0;
60
+ while (pending.length > 0) {
61
+ const current = pending.pop();
62
+ for (const [name, value] of Object.entries(current)) {
63
+ if (name === ":@" || name.startsWith("#") || name.startsWith("?")) continue;
64
+ if (++nodeCount > MAX_SVG_NODES) unsafeSvg("node count exceeds the supported limit");
65
+ const localName = svgLocalName(name);
66
+ if (UNSAFE_SVG_ELEMENTS.has(localName)) unsafeSvg(`element <${name}> is not allowed`);
67
+ for (const [attributeName, attributeValue] of Object.entries(current[":@"] ?? {})) validateSvgAttribute(attributeName, attributeValue);
68
+ if (Array.isArray(value)) {
69
+ for (const child of value) if (child && typeof child === "object" && !Array.isArray(child)) pending.push(child);
70
+ }
71
+ }
72
+ }
73
+ }
74
+ function validateSvg(bytes) {
75
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) unsafeSvg("source size is unsupported");
76
+ let source;
77
+ try {
78
+ source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
79
+ } catch {
80
+ unsafeSvg("source is not valid UTF-8");
81
+ }
82
+ if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) unsafeSvg("DTD, entities, and stylesheets are not allowed");
83
+ if (XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) unsafeSvg("source is not well-formed XML");
84
+ const roots = new XMLParser({
85
+ preserveOrder: true,
86
+ ignoreAttributes: false,
87
+ attributeNamePrefix: "",
88
+ parseAttributeValue: false,
89
+ parseTagValue: false,
90
+ processEntities: false,
91
+ trimValues: false
92
+ }).parse(source).flatMap((entry) => Object.keys(entry).filter((name) => name !== ":@" && !name.startsWith("#") && !name.startsWith("?")).map((name) => ({
93
+ entry,
94
+ name
95
+ })));
96
+ if (roots.length !== 1 || svgLocalName(roots[0].name) !== "svg") unsafeSvg("a single <svg> root is required");
97
+ const root = roots[0].entry;
98
+ visitSvgEntry(root);
99
+ const attributes = root[":@"] ?? {};
100
+ const width = parseSvgLength(attributes.width, "width");
101
+ const height = parseSvgLength(attributes.height, "height");
102
+ let viewBoxWidth;
103
+ let viewBoxHeight;
104
+ if (attributes.viewBox !== void 0) {
105
+ const viewBox = String(attributes.viewBox).trim().split(/[\s,]+/u).map(Number);
106
+ if (viewBox.length !== 4 || viewBox.some((value) => !Number.isFinite(value)) || viewBox[2] <= 0 || viewBox[3] <= 0) unsafeSvg("viewBox must contain four finite values with positive dimensions");
107
+ viewBoxWidth = viewBox[2];
108
+ viewBoxHeight = viewBox[3];
109
+ if (viewBoxWidth > MAX_SVG_DIMENSION || viewBoxHeight > MAX_SVG_DIMENSION) unsafeSvg("viewBox exceeds the supported dimensions");
110
+ }
111
+ if ((width ?? viewBoxWidth ?? 300) * (height ?? viewBoxHeight ?? 150) > MAX_SVG_PIXELS) unsafeSvg("pixel count exceeds the supported limit");
112
+ }
4
113
  function getBrowserContext(canvas) {
5
114
  const context = canvas.getContext("2d");
6
115
  if (!context) throw new Error("publishBrowser: a 2D canvas context is unavailable.");
@@ -59,7 +168,15 @@ async function canvasToPng(canvas) {
59
168
  async function decodeRaster(bytes, mimeType) {
60
169
  if (typeof createImageBitmap !== "function") throw new Error("publishBrowser: createImageBitmap is required for atlas PNG generation.");
61
170
  const copy = bytes.slice();
62
- const bitmap = await createImageBitmap(new Blob([copy.buffer], { type: mimeType }));
171
+ if (mimeType === "image/svg+xml") validateSvg(copy);
172
+ const blob = new Blob([copy.buffer], { type: mimeType });
173
+ let bitmap;
174
+ try {
175
+ bitmap = await createImageBitmap(blob);
176
+ } catch (error) {
177
+ if (mimeType !== "image/svg+xml") throw error;
178
+ return decodeSvgWithDom(blob);
179
+ }
63
180
  try {
64
181
  const raster = createRaster(bitmap.width, bitmap.height);
65
182
  getBrowserContext(raster.canvas).drawImage(bitmap, 0, 0);
@@ -68,6 +185,26 @@ async function decodeRaster(bytes, mimeType) {
68
185
  bitmap.close();
69
186
  }
70
187
  }
188
+ async function decodeSvgWithDom(blob) {
189
+ if (typeof globalThis.Image !== "function" || typeof globalThis.URL?.createObjectURL !== "function" || typeof globalThis.URL?.revokeObjectURL !== "function") throw new Error("publishBrowser: createImageBitmap rejected SVG and DOM image decoding is unavailable.");
190
+ const url = globalThis.URL.createObjectURL(blob);
191
+ try {
192
+ const image = new globalThis.Image();
193
+ await new Promise((resolve, reject) => {
194
+ image.onload = () => resolve();
195
+ image.onerror = () => reject(/* @__PURE__ */ new Error("publishBrowser: DOM image decoding failed for SVG."));
196
+ image.src = url;
197
+ });
198
+ const width = image.naturalWidth || image.width;
199
+ const height = image.naturalHeight || image.height;
200
+ if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0 || width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION || width * height > MAX_SVG_PIXELS) unsafeSvg("decoded dimensions exceed the supported limit");
201
+ const raster = createRaster(width, height);
202
+ getBrowserContext(raster.canvas).drawImage(image, 0, 0);
203
+ return raster;
204
+ } finally {
205
+ globalThis.URL.revokeObjectURL(url);
206
+ }
207
+ }
71
208
  var BrowserImagePipeline = class {
72
209
  rawOutput = false;
73
210
  constructor(raster, decode, write) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openfairygui/functions",
3
- "version": "0.2.0-alpha.35",
3
+ "version": "0.2.0-alpha.36",
4
4
  "description": "FairyGUI Headless Authoring SDK — composable transform functions.",
5
5
  "author": "OpenFairyGUI Contributors",
6
6
  "license": "MIT",
@@ -73,8 +73,9 @@
73
73
  "publish"
74
74
  ],
75
75
  "dependencies": {
76
+ "fast-xml-parser": "^5.0.0",
76
77
  "jiti": "^2.6.1",
77
- "@openfairygui/core": "0.2.0-alpha.35"
78
+ "@openfairygui/core": "0.2.0-alpha.36"
78
79
  },
79
80
  "optionalDependencies": {
80
81
  "sharp": ">=0.33.0"
@@ -6,6 +6,7 @@ import type {
6
6
  PublishFileSystem,
7
7
  PublishSourceFileSystem,
8
8
  } from '../../publish/contracts.js';
9
+ import { XMLParser, XMLValidator } from 'fast-xml-parser';
9
10
 
10
11
  type BrowserCanvas = OffscreenCanvas | HTMLCanvasElement;
11
12
 
@@ -39,6 +40,137 @@ interface BrowserRaster {
39
40
  height: number;
40
41
  }
41
42
 
43
+ const MAX_SVG_SOURCE_BYTES = 8 * 1024 * 1024;
44
+ const MAX_SVG_DIMENSION = 16_384;
45
+ const MAX_SVG_PIXELS = 64 * 1024 * 1024;
46
+ const MAX_SVG_NODES = 50_000;
47
+ const UNSAFE_SVG_ELEMENTS = new Set([
48
+ 'a',
49
+ 'animate',
50
+ 'animatecolor',
51
+ 'animatemotion',
52
+ 'animatetransform',
53
+ 'audio',
54
+ 'canvas',
55
+ 'discard',
56
+ 'embed',
57
+ 'feimage',
58
+ 'foreignobject',
59
+ 'iframe',
60
+ 'image',
61
+ 'object',
62
+ 'script',
63
+ 'set',
64
+ 'style',
65
+ 'video',
66
+ ]);
67
+
68
+ type ParsedSvgEntry = Record<string, unknown> & { ':@'?: Record<string, unknown> };
69
+
70
+ function unsafeSvg(message: string): never {
71
+ throw new Error(`publishBrowser: unsafe SVG input (${message}).`);
72
+ }
73
+
74
+ function svgLocalName(name: string): string {
75
+ return name.split(':').at(-1)!.toLowerCase();
76
+ }
77
+
78
+ function parseSvgLength(value: unknown, name: string): number | undefined {
79
+ if (value === undefined) return undefined;
80
+ const match = String(value).match(/^\s*(?:\d+(?:\.\d+)?|\.\d+)(?:px)?\s*$/iu);
81
+ if (!match) unsafeSvg(`${name} must use a finite pixel value`);
82
+ const parsed = Number.parseFloat(match[0]);
83
+ if (!Number.isFinite(parsed) || parsed <= 0 || parsed > MAX_SVG_DIMENSION) {
84
+ unsafeSvg(`${name} exceeds the supported dimensions`);
85
+ }
86
+ return parsed;
87
+ }
88
+
89
+ function validateSvgAttribute(name: string, value: unknown): void {
90
+ const normalizedName = name.toLowerCase();
91
+ if (normalizedName === 'xmlns' || normalizedName.startsWith('xmlns:')) return;
92
+ const localName = svgLocalName(name);
93
+ const text = String(value);
94
+ if (localName.startsWith('on')) unsafeSvg(`event attribute "${name}" is not allowed`);
95
+ if (localName === 'style' || localName === 'src') unsafeSvg(`attribute "${name}" is not allowed`);
96
+ if (localName === 'href' && !/^#[A-Za-z_][\w:.-]*$/u.test(text)) {
97
+ unsafeSvg(`external reference in "${name}" is not allowed`);
98
+ }
99
+ if (/(?:^|[\s("'=])(?:https?:|file:|javascript:|data:|\/\/)/iu.test(text)) {
100
+ unsafeSvg(`external URL in "${name}" is not allowed`);
101
+ }
102
+ for (const match of text.matchAll(/url\s*\(([^)]*)\)/giu)) {
103
+ const reference = (match[1] ?? '').trim().replace(/^(['"])(.*)\1$/u, '$2');
104
+ if (!/^#[A-Za-z_][\w:.-]*$/u.test(reference)) unsafeSvg(`external url() in "${name}" is not allowed`);
105
+ }
106
+ }
107
+
108
+ function visitSvgEntry(entry: ParsedSvgEntry): void {
109
+ const pending = [entry];
110
+ let nodeCount = 0;
111
+ while (pending.length > 0) {
112
+ const current = pending.pop()!;
113
+ for (const [name, value] of Object.entries(current)) {
114
+ if (name === ':@' || name.startsWith('#') || name.startsWith('?')) continue;
115
+ if (++nodeCount > MAX_SVG_NODES) unsafeSvg('node count exceeds the supported limit');
116
+ const localName = svgLocalName(name);
117
+ if (UNSAFE_SVG_ELEMENTS.has(localName)) unsafeSvg(`element <${name}> is not allowed`);
118
+ for (const [attributeName, attributeValue] of Object.entries(current[':@'] ?? {})) {
119
+ validateSvgAttribute(attributeName, attributeValue);
120
+ }
121
+ if (Array.isArray(value)) {
122
+ for (const child of value) {
123
+ if (child && typeof child === 'object' && !Array.isArray(child)) pending.push(child as ParsedSvgEntry);
124
+ }
125
+ }
126
+ }
127
+ }
128
+ }
129
+
130
+ function validateSvg(bytes: Uint8Array): void {
131
+ if (bytes.byteLength === 0 || bytes.byteLength > MAX_SVG_SOURCE_BYTES) unsafeSvg('source size is unsupported');
132
+ let source: string;
133
+ try {
134
+ source = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
135
+ } catch {
136
+ unsafeSvg('source is not valid UTF-8');
137
+ }
138
+ if (/<!\s*(?:doctype|entity)\b|<\?xml-stylesheet\b/iu.test(source)) unsafeSvg('DTD, entities, and stylesheets are not allowed');
139
+ if (XMLValidator.validate(source, { allowBooleanAttributes: false }) !== true) unsafeSvg('source is not well-formed XML');
140
+ const parsed = new XMLParser({
141
+ preserveOrder: true,
142
+ ignoreAttributes: false,
143
+ attributeNamePrefix: '',
144
+ parseAttributeValue: false,
145
+ parseTagValue: false,
146
+ processEntities: false,
147
+ trimValues: false,
148
+ }).parse(source) as ParsedSvgEntry[];
149
+ const roots = parsed.flatMap((entry) => Object.keys(entry)
150
+ .filter((name) => name !== ':@' && !name.startsWith('#') && !name.startsWith('?'))
151
+ .map((name) => ({ entry, name })));
152
+ if (roots.length !== 1 || svgLocalName(roots[0]!.name) !== 'svg') unsafeSvg('a single <svg> root is required');
153
+ const root = roots[0]!.entry;
154
+ visitSvgEntry(root);
155
+ const attributes = root[':@'] ?? {};
156
+ const width = parseSvgLength(attributes.width, 'width');
157
+ const height = parseSvgLength(attributes.height, 'height');
158
+ let viewBoxWidth: number | undefined;
159
+ let viewBoxHeight: number | undefined;
160
+ if (attributes.viewBox !== undefined) {
161
+ const viewBox = String(attributes.viewBox).trim().split(/[\s,]+/u).map(Number);
162
+ if (viewBox.length !== 4 || viewBox.some((value) => !Number.isFinite(value)) || viewBox[2]! <= 0 || viewBox[3]! <= 0) {
163
+ unsafeSvg('viewBox must contain four finite values with positive dimensions');
164
+ }
165
+ viewBoxWidth = viewBox[2];
166
+ viewBoxHeight = viewBox[3];
167
+ if (viewBoxWidth > MAX_SVG_DIMENSION || viewBoxHeight > MAX_SVG_DIMENSION) unsafeSvg('viewBox exceeds the supported dimensions');
168
+ }
169
+ const rasterWidth = width ?? viewBoxWidth ?? 300;
170
+ const rasterHeight = height ?? viewBoxHeight ?? 150;
171
+ if (rasterWidth * rasterHeight > MAX_SVG_PIXELS) unsafeSvg('pixel count exceeds the supported limit');
172
+ }
173
+
42
174
  function getBrowserContext(canvas: BrowserCanvas): BrowserContext {
43
175
  const context = canvas.getContext('2d');
44
176
  if (!context) throw new Error('publishBrowser: a 2D canvas context is unavailable.');
@@ -115,7 +247,15 @@ async function decodeRaster(bytes: Uint8Array, mimeType: string): Promise<Browse
115
247
  throw new Error('publishBrowser: createImageBitmap is required for atlas PNG generation.');
116
248
  }
117
249
  const copy = bytes.slice();
118
- const bitmap = await createImageBitmap(new Blob([copy.buffer as ArrayBuffer], { type: mimeType }));
250
+ if (mimeType === 'image/svg+xml') validateSvg(copy);
251
+ const blob = new Blob([copy.buffer as ArrayBuffer], { type: mimeType });
252
+ let bitmap: ImageBitmap;
253
+ try {
254
+ bitmap = await createImageBitmap(blob);
255
+ } catch (error) {
256
+ if (mimeType !== 'image/svg+xml') throw error;
257
+ return decodeSvgWithDom(blob);
258
+ }
119
259
  try {
120
260
  const raster = createRaster(bitmap.width, bitmap.height);
121
261
  getBrowserContext(raster.canvas).drawImage(bitmap, 0, 0);
@@ -125,6 +265,36 @@ async function decodeRaster(bytes: Uint8Array, mimeType: string): Promise<Browse
125
265
  }
126
266
  }
127
267
 
268
+ async function decodeSvgWithDom(blob: Blob): Promise<BrowserRaster> {
269
+ if (typeof globalThis.Image !== 'function'
270
+ || typeof globalThis.URL?.createObjectURL !== 'function'
271
+ || typeof globalThis.URL?.revokeObjectURL !== 'function'
272
+ ) {
273
+ throw new Error('publishBrowser: createImageBitmap rejected SVG and DOM image decoding is unavailable.');
274
+ }
275
+ const url = globalThis.URL.createObjectURL(blob);
276
+ try {
277
+ const image = new globalThis.Image();
278
+ await new Promise<void>((resolve, reject) => {
279
+ image.onload = () => resolve();
280
+ image.onerror = () => reject(new Error('publishBrowser: DOM image decoding failed for SVG.'));
281
+ image.src = url;
282
+ });
283
+ const width = image.naturalWidth || image.width;
284
+ const height = image.naturalHeight || image.height;
285
+ if (!Number.isFinite(width) || !Number.isFinite(height)
286
+ || width <= 0 || height <= 0
287
+ || width > MAX_SVG_DIMENSION || height > MAX_SVG_DIMENSION
288
+ || width * height > MAX_SVG_PIXELS
289
+ ) unsafeSvg('decoded dimensions exceed the supported limit');
290
+ const raster = createRaster(width, height);
291
+ getBrowserContext(raster.canvas).drawImage(image, 0, 0);
292
+ return raster;
293
+ } finally {
294
+ globalThis.URL.revokeObjectURL(url);
295
+ }
296
+ }
297
+
128
298
  class BrowserImagePipeline implements AtlasRasterPipeline {
129
299
  private rawOutput = false;
130
300
 
@@ -246,9 +246,12 @@ export async function collectImage(
246
246
  .toBuffer();
247
247
  sourceHasAlpha = true;
248
248
  }
249
- } catch {
249
+ } catch (error) {
250
250
  if (options.strictOutput) {
251
- throw new Error(`atlas: Could not read image "${filePath}".`);
251
+ const detail = error instanceof Error && error.message.startsWith('publishBrowser:')
252
+ ? ` ${error.message}`
253
+ : '';
254
+ throw new Error(`atlas: Could not read image "${filePath}".${detail}`);
252
255
  }
253
256
  if (origW === 0 || origH === 0) {
254
257
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);