@openfairygui/functions 0.2.0-alpha.34 → 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.
@@ -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
 
@@ -21,7 +21,7 @@ import {
21
21
  } from '../publish/package-context.js';
22
22
  import type { ExtrasMap } from '../shared-types.js';
23
23
  import { parseFnt } from './font.js';
24
- import { extractJtaFrames } from './jta.js';
24
+ import { prepareJtaForPublish, type PreparedJtaData } from './jta.js';
25
25
 
26
26
  /** Trim info for a single image. */
27
27
  interface TrimInfo {
@@ -181,6 +181,36 @@ export interface PagedAtlasGroup {
181
181
  inputs: InputItem[];
182
182
  }
183
183
 
184
+ export function resolveMovieClipSourcePath(resource: MovieClipResource, pkg: Package, basePath: string): string {
185
+ const fileName = `${resource.getName()}.jta`;
186
+ const resourcePath = resource.getPath() ?? '/';
187
+ return `${basePath}/${pkg.getName()}${resourcePath}${fileName}`;
188
+ }
189
+
190
+ export async function prepareMovieClipResource(
191
+ resource: MovieClipResource,
192
+ pkg: Package,
193
+ encoder: AtlasRasterBackend | undefined,
194
+ basePath: string,
195
+ readFileRaw: (path: string) => Promise<Uint8Array>,
196
+ ): Promise<PreparedJtaData> {
197
+ const filePath = resolveMovieClipSourcePath(resource, pkg, basePath);
198
+ let raw: Uint8Array;
199
+ try {
200
+ raw = await readFileRaw(filePath);
201
+ } catch {
202
+ throw new Error(`atlas: Could not read MovieClip "${filePath}".`);
203
+ }
204
+
205
+ try {
206
+ return await prepareJtaForPublish(raw, encoder, filePath);
207
+ } catch (error) {
208
+ if (error instanceof Error && error.message.startsWith('atlas:')) throw error;
209
+ const detail = error instanceof Error ? ` ${error.message}` : '';
210
+ throw new Error(`atlas: Could not parse MovieClip "${filePath}".${detail}`);
211
+ }
212
+ }
213
+
184
214
  /** Collect a single ImageResource into the inputs array. */
185
215
  export async function collectImage(
186
216
  resource: ImageResource,
@@ -216,9 +246,12 @@ export async function collectImage(
216
246
  .toBuffer();
217
247
  sourceHasAlpha = true;
218
248
  }
219
- } catch {
249
+ } catch (error) {
220
250
  if (options.strictOutput) {
221
- 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}`);
222
255
  }
223
256
  if (origW === 0 || origH === 0) {
224
257
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
@@ -285,130 +318,62 @@ export async function collectMovieClipFrames(
285
318
  }
286
319
 
287
320
  const mcId = resource.getId();
288
- const mcName = resource.getName() + '.jta';
289
- const mcPath = resource.getPath() ?? '/';
290
- const filePath = `${options.basePath}/${pkg.getName()}${mcPath}${mcName}`;
321
+ const filePath = resolveMovieClipSourcePath(resource, pkg, options.basePath);
291
322
 
292
323
  try {
293
- const raw = await options.readFileRaw(filePath);
294
- const jta = extractJtaFrames(raw);
295
- if (jta.frames.length === 0) return;
296
-
297
- const frameMetas = jta.meta?.frames ?? [];
324
+ const jta =
325
+ options.preparedMovieClips?.get(resource) ??
326
+ (await prepareMovieClipResource(resource, pkg, encoder, options.basePath, options.readFileRaw));
298
327
  for (const frame of resource.listFrames()) {
299
328
  resource.removeFrame(frame);
300
329
  }
301
330
  resource
302
- .setInterval(jta.meta?.interval ?? 100)
303
- .setSwing(jta.meta?.swing ?? false)
304
- .setRepeatDelay(jta.meta?.repeatDelay ?? 0);
305
-
306
- if (frameMetas.length > 0) {
307
- const firstFrameIndexByTextureIndex = new Map<number, number>();
308
- for (let frameIndex = 0; frameIndex < frameMetas.length; frameIndex += 1) {
309
- const meta = frameMetas[frameIndex];
310
- const textureIndex = Number.isFinite(meta.textureIndex) ? meta.textureIndex : frameIndex;
311
- if (!firstFrameIndexByTextureIndex.has(textureIndex)) {
312
- firstFrameIndexByTextureIndex.set(textureIndex, frameIndex);
313
- }
314
- }
315
-
316
- const spriteIdByTextureIndex = new Map<number, string>();
317
- for (let textureIndex = 0; textureIndex < jta.frames.length; textureIndex += 1) {
318
- const exportFrameIndex = firstFrameIndexByTextureIndex.get(textureIndex);
319
- if (exportFrameIndex === undefined) continue;
320
- const itemId = `${mcId}_${exportFrameIndex}`;
321
- const input = await createMovieClipFrameInput(
322
- jta.frames[textureIndex],
323
- itemId,
331
+ .setInterval(jta.meta.interval)
332
+ .setSwing(jta.meta.swing)
333
+ .setRepeatDelay(jta.meta.repeatDelay);
334
+ const spriteIdByTextureIndex = new Map<number, string>();
335
+ for (const texture of jta.referencedTextures) {
336
+ if (texture.width <= 0 || texture.height <= 0) continue;
337
+ const itemId = `${mcId}_${texture.firstFrameIndex}`;
338
+ inputs.push({
339
+ id: itemId,
340
+ width: texture.width,
341
+ height: texture.height,
342
+ originalWidth: texture.width,
343
+ originalHeight: texture.height,
344
+ offsetX: 0,
345
+ offsetY: 0,
324
346
  resource,
325
- encoder,
326
- options.strictOutput,
327
- );
328
- if (!input) continue;
329
- inputs.push(input);
330
- spriteIdByTextureIndex.set(textureIndex, itemId);
331
- }
347
+ trimBuffer: texture.buffer,
348
+ sourceKind: 'movieclip-frame',
349
+ });
350
+ spriteIdByTextureIndex.set(texture.textureIndex, itemId);
351
+ }
332
352
 
333
- for (let frameIndex = 0; frameIndex < frameMetas.length; frameIndex += 1) {
334
- const meta = frameMetas[frameIndex];
335
- const textureIndex = Number.isFinite(meta.textureIndex) ? meta.textureIndex : frameIndex;
336
- const frame = doc.createMovieFrame(`${mcId}_${frameIndex}`);
337
- frame
338
- .setRectX(meta.offsetX)
339
- .setRectY(meta.offsetY)
340
- .setRectWidth(meta.width)
341
- .setRectHeight(meta.height)
342
- .setAddDelay(meta.addDelay)
343
- .setSpriteId(spriteIdByTextureIndex.get(textureIndex) ?? '');
344
- resource.addFrame(frame);
345
- }
346
- } else {
347
- for (let frameIndex = 0; frameIndex < jta.frames.length; frameIndex += 1) {
348
- const itemId = `${mcId}_${frameIndex}`;
349
- const input = await createMovieClipFrameInput(
350
- jta.frames[frameIndex],
351
- itemId,
352
- resource,
353
- encoder,
354
- options.strictOutput,
355
- );
356
- if (!input) continue;
357
- inputs.push(input);
358
- const frame = doc.createMovieFrame(itemId);
359
- frame
360
- .setRectX(0)
361
- .setRectY(0)
362
- .setRectWidth(input.originalWidth)
363
- .setRectHeight(input.originalHeight)
364
- .setAddDelay(0)
365
- .setSpriteId(itemId);
366
- resource.addFrame(frame);
367
- }
353
+ for (let frameIndex = 0; frameIndex < jta.meta.frames.length; frameIndex += 1) {
354
+ const meta = jta.meta.frames[frameIndex]!;
355
+ const frame = doc.createMovieFrame(`${mcId}_${frameIndex}`);
356
+ frame
357
+ .setRectX(meta.offsetX)
358
+ .setRectY(meta.offsetY)
359
+ .setRectWidth(meta.width)
360
+ .setRectHeight(meta.height)
361
+ .setAddDelay(meta.addDelay)
362
+ .setSpriteId(meta.textureIndex === -1 ? '' : (spriteIdByTextureIndex.get(meta.textureIndex) ?? ''));
363
+ resource.addFrame(frame);
368
364
  }
369
365
 
370
- if ((jta.meta?.width ?? 0) > 0 && (jta.meta?.height ?? 0) > 0) {
371
- resource.setWidth(jta.meta?.width ?? 0);
372
- resource.setHeight(jta.meta?.height ?? 0);
366
+ if (jta.meta.width > 0 && jta.meta.height > 0) {
367
+ resource.setWidth(jta.meta.width);
368
+ resource.setHeight(jta.meta.height);
373
369
  }
374
- } catch {
375
- const message = `atlas: Could not parse MovieClip "${filePath}".`;
376
- if (options.strictOutput) throw new Error(message);
370
+ } catch (error) {
371
+ const message = error instanceof Error ? error.message : `atlas: Could not parse MovieClip "${filePath}".`;
372
+ if (options.strictOutput) throw error;
377
373
  logger.warn(`${message} Skipping frames.`);
378
374
  }
379
375
  }
380
376
 
381
- async function createMovieClipFrameInput(
382
- buffer: Uint8Array,
383
- itemId: string,
384
- resource: MovieClipResource,
385
- encoder: AtlasRasterBackend | undefined,
386
- strictOutput: boolean,
387
- ): Promise<InputItem | null> {
388
- if (!encoder || buffer.length === 0) return null;
389
- try {
390
- const meta = await encoder(buffer).metadata();
391
- const width = meta.width ?? 0;
392
- const height = meta.height ?? 0;
393
- if (width <= 0 || height <= 0) return null;
394
- return {
395
- id: itemId,
396
- width,
397
- height,
398
- originalWidth: width,
399
- originalHeight: height,
400
- offsetX: 0,
401
- offsetY: 0,
402
- resource,
403
- trimBuffer: buffer,
404
- sourceKind: 'movieclip-frame',
405
- };
406
- } catch {
407
- if (strictOutput) throw new Error(`atlas: Could not decode MovieClip frame "${itemId}".`);
408
- return null;
409
- }
410
- }
411
-
412
377
  /** Collect a Bitmap Font's texture image, packed under the font's ID. */
413
378
  export async function collectFontTexture(
414
379
  doc: Document,