@openfairygui/functions 0.1.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/src/atlas.ts ADDED
@@ -0,0 +1,1651 @@
1
+ import { GearType, TransitionActionType, type Component, type Document, type DragonBonesResource, type FontResource, type ILogger, type ImageResource, type MovieClipResource, type Package, type SpineResource, type Transform } from '@openfairygui/core';
2
+ import { COMPAT_NODE_RECT_FLAGS, type CompatNodeRect } from './max-rects-compat.js';
3
+ import { MaxRectsPackerCompat } from './max-rects-packer-compat.js';
4
+ import type { ExtrasMap, HasOptionalSrc, HasOptionalUrl } from './shared-types.js';
5
+ import { createTransform } from './utils.js';
6
+
7
+ export interface AtlasOptions {
8
+ /**
9
+ * Sharp module instance, injected by the caller.
10
+ * Required for actual image compositing and trimImage.
11
+ *
12
+ * ```ts
13
+ * import sharp from 'sharp';
14
+ * await doc.transform(atlas({ encoder: sharp }));
15
+ * ```
16
+ */
17
+ encoder?: unknown;
18
+
19
+ /** Maximum atlas texture size (width and height). Default: 2048. */
20
+ maxSize?: number;
21
+
22
+ /** Whether to use the fast editor-compatible packing heuristics. Default: true. */
23
+ fast?: boolean;
24
+
25
+ /** Allow rotating sprites 90° for better packing. Default: true. */
26
+ allowRotation?: boolean;
27
+
28
+ /** Pixel padding between sprites. Default: 1. */
29
+ padding?: number;
30
+
31
+ /** Constrain atlas dimensions to powers of two. Default: false. */
32
+ powerOfTwo?: boolean;
33
+
34
+ /** Force square atlas (width === height). Default: false. */
35
+ square?: boolean;
36
+
37
+ /** Allow spilling into multiple atlas pages. Default: true. */
38
+ multiPage?: boolean;
39
+
40
+ /**
41
+ * Trim transparent pixels from image edges before packing.
42
+ * Requires encoder (sharp). Stores offset/originalSize in Sprite nodes.
43
+ * Default: false.
44
+ */
45
+ trimImage?: boolean;
46
+
47
+ /**
48
+ * Base path for reading source images. If not set, images must have
49
+ * their pixel data stored in extras._imageData as Uint8Array.
50
+ */
51
+ basePath?: string;
52
+
53
+ /**
54
+ * Output directory for generated atlas PNGs.
55
+ * Required when encoder is provided.
56
+ */
57
+ outputPath?: string;
58
+
59
+ /**
60
+ * Optional mkdir function to ensure output directory exists.
61
+ * If not provided, the outputPath directory must already exist.
62
+ */
63
+ mkdir?: (path: string) => Promise<void>;
64
+
65
+ /**
66
+ * Optional raw file reader for reading .jta MovieClip files.
67
+ * Required for MovieClip frame atlas packing.
68
+ */
69
+ readFileRaw?: (path: string) => Promise<Uint8Array>;
70
+
71
+ /**
72
+ * Keep original input order when MaxRects tie-break scores are equal.
73
+ * This is an internal publish detail used to mirror editor/CLI behavior.
74
+ */
75
+ preserveInputOrderOnTie?: boolean;
76
+
77
+ /**
78
+ * Internal publish detail used by Unity binary output:
79
+ * allow single untrimmed PNG image packages to bypass the packer and
80
+ * write atlas0 directly, matching the reference CLI behavior.
81
+ */
82
+ directSingleImageOutput?: boolean;
83
+
84
+ /**
85
+ * Internal publish detail used by the direct-image-output path.
86
+ * When extractAlpha is enabled, the direct output shortcut must be disabled.
87
+ */
88
+ extractAlpha?: boolean;
89
+
90
+ /**
91
+ * When branchProcessing keeps branch resources, publish branch images into
92
+ * separate atlas pages/files per branch instead of mixing them with main.
93
+ */
94
+ separatedAtlasForBranch?: boolean;
95
+
96
+ }
97
+
98
+ const ATLAS_DEFAULTS: Required<Omit<AtlasOptions, 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw'>> = {
99
+ maxSize: 2048,
100
+ fast: true,
101
+ allowRotation: true,
102
+ padding: 1,
103
+ powerOfTwo: false,
104
+ square: false,
105
+ multiPage: true,
106
+ trimImage: false,
107
+ preserveInputOrderOnTie: false,
108
+ directSingleImageOutput: false,
109
+ extractAlpha: false,
110
+ separatedAtlasForBranch: false,
111
+ };
112
+
113
+ /** Trim info for a single image. */
114
+ interface TrimInfo {
115
+ /** Trimmed pixel data (PNG). */
116
+ buffer: Uint8Array;
117
+ /** Trimmed width. */
118
+ width: number;
119
+ /** Trimmed height. */
120
+ height: number;
121
+ /** Offset from original left edge. */
122
+ offsetX: number;
123
+ /** Offset from original top edge. */
124
+ offsetY: number;
125
+ /** Original width before trim. */
126
+ originalWidth: number;
127
+ /** Original height before trim. */
128
+ originalHeight: number;
129
+ }
130
+
131
+ type PackageResource = ReturnType<Package['listResources']>[number];
132
+ type PackableResource = ImageResource | MovieClipResource | FontResource;
133
+ type PackInputResource = ImageResource | MovieClipResource;
134
+ type ParsedFnt = ReturnType<typeof _parseFnt>;
135
+
136
+ function getPublishedItemId(resource: { getId(): string; getExtras(): ExtrasMap | undefined }): string {
137
+ return ((resource.getExtras() as ImageResourceExtras | undefined) ?? {})._publishedId ?? resource.getId();
138
+ }
139
+
140
+ interface AtlasReferenceItem {
141
+ icon?: string | null;
142
+ url?: string | null;
143
+ }
144
+
145
+ interface GearWithAtlasRefs {
146
+ getGearType?(): number;
147
+ getValues?(): string;
148
+ getDefaultValue?(): unknown;
149
+ }
150
+
151
+ interface TransitionItemWithAtlasRefs {
152
+ getActionType?(): number;
153
+ getStartValue?(): unknown;
154
+ getEndValue?(): unknown;
155
+ }
156
+
157
+ interface TransitionWithAtlasRefs {
158
+ listItems?(): TransitionItemWithAtlasRefs[];
159
+ }
160
+
161
+ interface ChildWithReferenceUrls extends HasOptionalSrc, HasOptionalUrl {
162
+ getDefaultItem?(): string;
163
+ getIcon?(): string;
164
+ getSelectedIcon?(): string;
165
+ getDropdown?(): string;
166
+ getSound?(): string;
167
+ getText?(): string;
168
+ getFont?(): string;
169
+ getInstanceIcon?(): string;
170
+ getInstanceSelectedIcon?(): string;
171
+ getVtScrollBarRes?(): string;
172
+ getHzScrollBarRes?(): string;
173
+ getHeaderRes?(): string;
174
+ getFooterRes?(): string;
175
+ getInstanceComboItems?(): Array<{ icon: string | null }>;
176
+ getListItems?(): AtlasReferenceItem[];
177
+ listGears?(): GearWithAtlasRefs[];
178
+ }
179
+
180
+ interface ImageResourceExtras extends ExtrasMap {
181
+ _fileName?: string;
182
+ _publishedId?: string;
183
+ }
184
+
185
+ interface FontSpriteAlias {
186
+ fontId: string;
187
+ textureId: string;
188
+ }
189
+
190
+ interface FontResourceExtras extends ExtrasMap {
191
+ _fontSpriteAlias?: FontSpriteAlias;
192
+ }
193
+
194
+ interface PackageAtlasExtras extends ExtrasMap {
195
+ publishedResourceIds?: string[];
196
+ }
197
+
198
+ interface BranchAtlasGroup {
199
+ branchName: string;
200
+ branchOrdinal: number;
201
+ inputs: InputItem[];
202
+ }
203
+
204
+ interface AtlasEncoderMetadata {
205
+ width?: number;
206
+ height?: number;
207
+ channels?: number;
208
+ hasAlpha?: boolean;
209
+ trimOffsetLeft?: number;
210
+ trimOffsetTop?: number;
211
+ }
212
+
213
+ interface AtlasEncoderResolvedBuffer {
214
+ data: Uint8Array;
215
+ info: Required<Pick<AtlasEncoderMetadata, 'width' | 'height' | 'channels'>> & AtlasEncoderMetadata;
216
+ }
217
+
218
+ interface AtlasCompositeInput {
219
+ input: Uint8Array;
220
+ left: number;
221
+ top: number;
222
+ }
223
+
224
+ interface AtlasEncoderPipeline {
225
+ ensureAlpha(): AtlasEncoderPipeline;
226
+ raw(): AtlasEncoderPipeline;
227
+ extract(options: { left: number; top: number; width: number; height: number }): AtlasEncoderPipeline;
228
+ toBuffer(options: { resolveWithObject: true }): Promise<AtlasEncoderResolvedBuffer>;
229
+ toBuffer(options?: { resolveWithObject?: false }): Promise<Uint8Array>;
230
+ toBuffer(options?: { resolveWithObject?: boolean }): Promise<Uint8Array | AtlasEncoderResolvedBuffer>;
231
+ png(): AtlasEncoderPipeline;
232
+ metadata(): Promise<AtlasEncoderMetadata>;
233
+ rotate(angle: number): AtlasEncoderPipeline;
234
+ composite(inputs: AtlasCompositeInput[]): AtlasEncoderPipeline;
235
+ toFile(path: string): Promise<unknown>;
236
+ }
237
+
238
+ type AtlasEncoderInput =
239
+ | string
240
+ | Uint8Array
241
+ | {
242
+ create: {
243
+ width: number;
244
+ height: number;
245
+ channels: 4;
246
+ background: { r: number; g: number; b: number; alpha: number };
247
+ };
248
+ };
249
+
250
+ type AtlasEncoder = (input: AtlasEncoderInput) => AtlasEncoderPipeline;
251
+
252
+ function resolveFontFileName(fontName: string): string {
253
+ return /\.fnt$/i.test(fontName) ? fontName : `${fontName}.fnt`;
254
+ }
255
+
256
+ function addLocalResourceByUiUrl(
257
+ target: PackageResource[],
258
+ added: Set<string>,
259
+ resourceMap: Map<string, PackageResource>,
260
+ pkgId: string,
261
+ value: string | null | undefined,
262
+ ): void {
263
+ if (!value || typeof value !== 'string' || !value.startsWith('ui://')) return;
264
+ const normalized = value.slice(5).split(',')[0] ?? '';
265
+ if (!normalized) return;
266
+ let resourceId = '';
267
+ const slashIndex = normalized.indexOf('/');
268
+ if (slashIndex >= 0) {
269
+ const packageToken = normalized.slice(0, slashIndex);
270
+ if (packageToken !== pkgId) return;
271
+ resourceId = normalized.slice(slashIndex + 1);
272
+ } else if (normalized.length > 8) {
273
+ const packageToken = normalized.slice(0, 8);
274
+ if (packageToken !== pkgId) return;
275
+ resourceId = normalized.slice(8);
276
+ }
277
+ if (!resourceId) return;
278
+ const resource = resourceMap.get(resourceId);
279
+ if (!resource || added.has(resourceId)) return;
280
+ added.add(resourceId);
281
+ target.push(resource);
282
+ }
283
+
284
+ function addLocalResourcesByText(
285
+ target: PackageResource[],
286
+ added: Set<string>,
287
+ resourceMap: Map<string, PackageResource>,
288
+ pkgId: string,
289
+ value: string | null | undefined,
290
+ ): void {
291
+ if (!value || typeof value !== 'string') return;
292
+ for (const match of value.matchAll(/ui:\/\/[0-9a-z]{8}[0-9a-z]+/gi)) {
293
+ addLocalResourceByUiUrl(target, added, resourceMap, pkgId, match[0]);
294
+ }
295
+ }
296
+
297
+ function addResourceById(
298
+ target: PackageResource[],
299
+ added: Set<string>,
300
+ resourceMap: Map<string, PackageResource>,
301
+ resourceId: string | null | undefined,
302
+ ): void {
303
+ if (!resourceId) return;
304
+ const resource = resourceMap.get(resourceId);
305
+ if (!resource || added.has(resourceId)) return;
306
+ added.add(resourceId);
307
+ target.push(resource);
308
+ }
309
+
310
+ async function resolveEditorCompatibleResourceOrder(
311
+ pkg: Package,
312
+ allResources: PackageResource[],
313
+ options: AtlasOptions,
314
+ ): Promise<PackageResource[]> {
315
+ const pkgId = pkg.getId();
316
+ const resourceMap = new Map(allResources.map((resource) => [resource.getId(), resource]));
317
+ const ordered: PackageResource[] = [];
318
+ const added = new Set<string>();
319
+ const componentStack: Component[] = [];
320
+
321
+ async function addResource(resource: PackageResource | undefined): Promise<void> {
322
+ if (!resource) return;
323
+ const resourceId = resource.getId();
324
+ if (!resourceId || added.has(resourceId)) return;
325
+ added.add(resourceId);
326
+ ordered.push(resource);
327
+ if (isFontResource(resource)) {
328
+ await addResource(resourceMap.get(resource.getTextureId?.() ?? ''));
329
+ if (options.readFileRaw && options.basePath) {
330
+ const fontName = resolveFontFileName(resource.getName());
331
+ const fontPath = resource.getPath() ?? '/';
332
+ const fntFile = `${options.basePath}/${pkg.getName()}${fontPath}${fontName}`;
333
+ try {
334
+ const fntData = await options.readFileRaw(fntFile);
335
+ const fntText = new TextDecoder().decode(fntData);
336
+ for (const line of fntText.split(/\r?\n/)) {
337
+ const imgMatch = line.match(/\bimg=(\w+)/);
338
+ if (imgMatch) await addResource(resourceMap.get(imgMatch[1] ?? ''));
339
+ }
340
+ } catch { /* ignore */ }
341
+ }
342
+ }
343
+ if (isComponentResource(resource)) {
344
+ componentStack.push(resource);
345
+ }
346
+ }
347
+
348
+ async function addResourceByLocalUiUrl(value: string | null | undefined): Promise<void> {
349
+ if (!value || typeof value !== 'string' || !value.startsWith('ui://')) return;
350
+ const normalized = value.slice(5).split(',')[0] ?? '';
351
+ if (!normalized) return;
352
+ let resourceId = '';
353
+ const slashIndex = normalized.indexOf('/');
354
+ if (slashIndex >= 0) {
355
+ const packageToken = normalized.slice(0, slashIndex);
356
+ if (packageToken !== pkgId) return;
357
+ resourceId = normalized.slice(slashIndex + 1);
358
+ } else if (normalized.length > 8) {
359
+ const packageToken = normalized.slice(0, 8);
360
+ if (packageToken !== pkgId) return;
361
+ resourceId = normalized.slice(8);
362
+ }
363
+ if (!resourceId) return;
364
+ await addResource(resourceMap.get(resourceId));
365
+ }
366
+
367
+ async function addGearIconResources(gear: GearWithAtlasRefs): Promise<void> {
368
+ if (gear.getGearType?.() !== GearType.Icon) return;
369
+ const values = gear.getValues?.();
370
+ if (typeof values === 'string' && values) {
371
+ for (const value of values.split('|')) {
372
+ await addResourceByLocalUiUrl(value.trim());
373
+ }
374
+ }
375
+ const defaultValue = gear.getDefaultValue?.();
376
+ if (typeof defaultValue === 'string') {
377
+ await addResourceByLocalUiUrl(defaultValue);
378
+ }
379
+ }
380
+
381
+ for (const resource of allResources) {
382
+ if (resource.getExported()) await addResource(resource);
383
+ }
384
+
385
+ while (componentStack.length > 0) {
386
+ const component = componentStack.pop();
387
+ if (!component) continue;
388
+ for (const child of component.listChildren()) {
389
+ const refChild = child as ChildWithReferenceUrls;
390
+ await addResource(resourceMap.get(refChild.getSrc?.() ?? ''));
391
+ for (const ref of [
392
+ refChild.getUrl?.(),
393
+ refChild.getDefaultItem?.(),
394
+ refChild.getIcon?.(),
395
+ refChild.getSelectedIcon?.(),
396
+ refChild.getFont?.(),
397
+ refChild.getDropdown?.(),
398
+ refChild.getVtScrollBarRes?.(),
399
+ refChild.getHzScrollBarRes?.(),
400
+ refChild.getHeaderRes?.(),
401
+ refChild.getFooterRes?.(),
402
+ refChild.getSound?.(),
403
+ refChild.getInstanceIcon?.(),
404
+ refChild.getInstanceSelectedIcon?.(),
405
+ ]) {
406
+ await addResourceByLocalUiUrl(ref);
407
+ }
408
+ for (const item of refChild.getInstanceComboItems?.() ?? []) {
409
+ await addResourceByLocalUiUrl(item.icon ?? undefined);
410
+ }
411
+ for (const item of refChild.getListItems?.() ?? []) {
412
+ await addResourceByLocalUiUrl(item.icon ?? undefined);
413
+ await addResourceByLocalUiUrl(item.url ?? undefined);
414
+ }
415
+ for (const gear of refChild.listGears?.() ?? []) {
416
+ await addGearIconResources(gear);
417
+ }
418
+ }
419
+ for (const ref of [
420
+ (component as Component & ChildWithReferenceUrls).getDropdown?.(),
421
+ (component as Component & ChildWithReferenceUrls).getVtScrollBarRes?.(),
422
+ (component as Component & ChildWithReferenceUrls).getHzScrollBarRes?.(),
423
+ (component as Component & ChildWithReferenceUrls).getHeaderRes?.(),
424
+ (component as Component & ChildWithReferenceUrls).getFooterRes?.(),
425
+ (component as Component & ChildWithReferenceUrls).getSound?.(),
426
+ ]) {
427
+ await addResourceByLocalUiUrl(ref);
428
+ }
429
+ for (const transition of (component as Component & { listTransitions?(): TransitionWithAtlasRefs[] }).listTransitions?.() ?? []) {
430
+ for (const item of transition.listItems?.() ?? []) {
431
+ const actionType = item.getActionType?.();
432
+ if (actionType !== TransitionActionType.Sound && actionType !== TransitionActionType.Icon) continue;
433
+ for (const value of [item.getStartValue?.(), item.getEndValue?.()]) {
434
+ if (Array.isArray(value)) {
435
+ for (const entry of value) {
436
+ if (typeof entry === 'string') await addResourceByLocalUiUrl(entry);
437
+ }
438
+ } else if (typeof value === 'string') {
439
+ await addResourceByLocalUiUrl(value);
440
+ }
441
+ }
442
+ }
443
+ }
444
+ }
445
+
446
+ for (const resource of allResources) {
447
+ await addResource(resource);
448
+ }
449
+
450
+ return ordered;
451
+ }
452
+
453
+ /**
454
+ * Packs image resources into texture atlases.
455
+ *
456
+ * This transform performs MaxRects bin-packing on all ImageResource items
457
+ * within each package, creating Atlas and Sprite property nodes. When an
458
+ * `encoder` (sharp) is provided, it also composites the actual PNG files.
459
+ *
460
+ * When `trimImage` is enabled and encoder is available, transparent pixels
461
+ * at image edges are trimmed before packing. The trimmed offset and original
462
+ * dimensions are stored in the Sprite nodes for runtime reconstruction.
463
+ *
464
+ * ```ts
465
+ * import sharp from 'sharp';
466
+ * await doc.transform(atlas({
467
+ * encoder: sharp,
468
+ * maxSize: 2048,
469
+ * trimImage: true,
470
+ * basePath: './assets/',
471
+ * outputPath: './dist/',
472
+ * }));
473
+ * ```
474
+ */
475
+ export function atlas(_options: AtlasOptions = {}): Transform {
476
+ const options = { ...ATLAS_DEFAULTS, ..._options };
477
+
478
+ return createTransform('atlas', async (doc: Document): Promise<void> => {
479
+ const root = doc.getRoot();
480
+ const logger = doc.getLogger();
481
+ const encoder = options.encoder as AtlasEncoder | undefined;
482
+ const doTrim = options.trimImage && !!encoder && !!options.basePath;
483
+
484
+ for (const pkg of root.listPackages()) {
485
+ // Respect publish-selected resources when publish() precomputes a merged branch view.
486
+ const selectedPublishIds = new Set(((pkg.getExtras() as PackageAtlasExtras | undefined) ?? {}).publishedResourceIds ?? []);
487
+ const allResources = selectedPublishIds.size > 0
488
+ ? pkg.listResources().filter((resource) => selectedPublishIds.has(resource.getId()))
489
+ : pkg.listResources();
490
+ // Process resources in declaration order (matching editor behavior)
491
+ const orderedResources = await resolveEditorCompatibleResourceOrder(pkg, allResources, options);
492
+ const resourceOrder = new Map(orderedResources.map((resource, index) => [resource.getId(), index]));
493
+ const inputOrder = new Map(allResources.map((resource, index) => [resource.getId(), index]));
494
+ const orderedAllResources = sortResourcesByOrder(allResources, resourceOrder, inputOrder);
495
+ const hasPackable = allResources.some((resource) => isPackableResource(resource));
496
+ if (!hasPackable) continue;
497
+
498
+ // Collect packable items in declaration order
499
+ const inputs: InputItem[] = [];
500
+
501
+ // Build set of referenced resource IDs (editor only packs referenced images)
502
+ // Walk component tree recursively to find all image references
503
+ const referencedIds = new Set<string>();
504
+ const resourceMap = new Map<string, PackageResource>();
505
+ for (const res of allResources) {
506
+ const id = res.getId();
507
+ if (id) resourceMap.set(id, res);
508
+ }
509
+ function collectRefs(component: Component, visited: Set<string>): void {
510
+ for (const child of component.listChildren()) {
511
+ const refChild = child as ChildWithReferenceUrls;
512
+ const src = refChild.getSrc?.();
513
+ if (src && !visited.has(src)) {
514
+ referencedIds.add(src);
515
+ visited.add(src);
516
+ const srcRes = resourceMap.get(src);
517
+ if (srcRes && isComponentResource(srcRes)) {
518
+ collectRefs(srcRes, visited);
519
+ }
520
+ }
521
+ for (const ref of [
522
+ refChild.getIcon?.(),
523
+ refChild.getSelectedIcon?.(),
524
+ refChild.getFont?.(),
525
+ refChild.getDropdown?.(),
526
+ refChild.getInstanceIcon?.(),
527
+ refChild.getInstanceSelectedIcon?.(),
528
+ refChild.getVtScrollBarRes?.(),
529
+ refChild.getHzScrollBarRes?.(),
530
+ refChild.getHeaderRes?.(),
531
+ refChild.getFooterRes?.(),
532
+ refChild.getUrl?.(),
533
+ ]) {
534
+ addUiResourceRef(referencedIds, ref);
535
+ }
536
+ addUiResourceRefsFromText(referencedIds, refChild.getText?.());
537
+ for (const item of refChild.getInstanceComboItems?.() ?? []) {
538
+ addUiResourceRef(referencedIds, item.icon ?? undefined);
539
+ }
540
+ for (const item of refChild.getListItems?.() ?? []) {
541
+ addUiResourceRef(referencedIds, item.icon ?? undefined);
542
+ }
543
+ for (const gear of refChild.listGears?.() ?? []) {
544
+ addUiResourceRefsFromUnknown(referencedIds, gear.getValues?.());
545
+ addUiResourceRefsFromUnknown(referencedIds, gear.getDefaultValue?.());
546
+ }
547
+ }
548
+ for (const transition of (component as Component & { listTransitions?(): TransitionWithAtlasRefs[] }).listTransitions?.() ?? []) {
549
+ for (const item of transition.listItems?.() ?? []) {
550
+ addUiResourceRefsFromUnknown(referencedIds, item.getStartValue?.());
551
+ addUiResourceRefsFromUnknown(referencedIds, item.getEndValue?.());
552
+ }
553
+ }
554
+ }
555
+ for (const res of orderedAllResources) {
556
+ if (isComponentResource(res)) {
557
+ collectRefs(res, new Set());
558
+ }
559
+ if (isSkeletonResource(res) && referencedIds.has(res.getId())) {
560
+ for (const requiredId of res.getRequireIds()) {
561
+ if (requiredId) referencedIds.add(requiredId);
562
+ }
563
+ }
564
+ // Font texture references and glyph image references
565
+ if (isFontResource(res)) {
566
+ const textureId = res.getTextureId?.() ?? '';
567
+ if (textureId) referencedIds.add(textureId);
568
+ // Parse .fnt file for glyph image references
569
+ if (options.readFileRaw && options.basePath) {
570
+ const fontName = resolveFontFileName(res.getName());
571
+ const fontPath = res.getPath() ?? '/';
572
+ const fntFile = `${options.basePath}/${pkg.getName()}${fontPath}${fontName}`;
573
+ try {
574
+ const fntData = await options.readFileRaw(fntFile);
575
+ const fntText = new TextDecoder().decode(fntData);
576
+ for (const line of fntText.split(/\r?\n/)) {
577
+ const match = line.match(/img=(\w+)/);
578
+ if (match) referencedIds.add(match[1]);
579
+ }
580
+ } catch { /* .fnt file not found — OK */ }
581
+ }
582
+ }
583
+ }
584
+
585
+ for (const res of orderedAllResources) {
586
+ if (isImageResource(res)) {
587
+ // Pack referenced images, plus explicitly exported standalone images.
588
+ const resId = res.getId();
589
+ if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
590
+ await _collectImage(res, pkg, inputs, encoder, options, doTrim, logger);
591
+ } else if (isMovieClipResource(res)) {
592
+ const resId = res.getId();
593
+ if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
594
+ await _collectMovieClipFrames(doc, res, pkg, inputs, encoder, options, logger);
595
+ } else if (isFontResource(res)) {
596
+ const resId = res.getId();
597
+ if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
598
+ await _collectFontTexture(doc, res, pkg, options);
599
+ }
600
+ }
601
+
602
+ if (inputs.length === 0) continue;
603
+ const branchGroups = buildBranchAtlasGroups(doc, inputs, options);
604
+ let totalPageCount = 0;
605
+ let usedDirectOutput = false;
606
+
607
+ for (const group of branchGroups) {
608
+ const directOutput = resolveDirectImageOutput(group.inputs, options);
609
+ if (directOutput) {
610
+ await emitDirectImageOutput(doc, pkg, directOutput, encoder, options, logger, group.branchName, group.branchOrdinal);
611
+ usedDirectOutput = true;
612
+ totalPageCount += 1;
613
+ continue;
614
+ }
615
+
616
+ const hasDuplicatePadding = group.inputs.some((i) => {
617
+ return isImageResource(i.resource) && i.resource.getDuplicatePadding?.() === true;
618
+ });
619
+
620
+ const packer = new MaxRectsPackerCompat({
621
+ pot: options.powerOfTwo,
622
+ mof: !options.powerOfTwo,
623
+ padding: options.padding,
624
+ rotation: options.allowRotation,
625
+ minWidth: 16,
626
+ minHeight: 16,
627
+ maxWidth: options.maxSize,
628
+ maxHeight: options.maxSize,
629
+ square: options.square,
630
+ fast: options.fast,
631
+ edgePadding: false,
632
+ duplicatePadding: hasDuplicatePadding,
633
+ multiPage: options.multiPage,
634
+ preserveInputOrderOnTie: options.preserveInputOrderOnTie,
635
+ });
636
+ const pages = packer.pack(group.inputs.map((input, index) => inputToCompatRect(input, index)));
637
+ if (!pages || pages.length === 0) continue;
638
+ totalPageCount += pages.length;
639
+
640
+ for (let p = 0; p < pages.length; p++) {
641
+ const page = pages[p];
642
+ const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(group.branchOrdinal, p)}`);
643
+ atlasNode.setIndex(resolveAtlasIndex(group.branchOrdinal, p));
644
+ atlasNode.setFile(resolveAtlasOutputFileName(pkg, p, group.branchName));
645
+ atlasNode.setWidth(page.width);
646
+ atlasNode.setHeight(page.height);
647
+ pkg.addAtlas(atlasNode);
648
+
649
+ for (const pr of page.outputRects) {
650
+ const input = group.inputs[pr.index];
651
+ if (!input) continue;
652
+ const packedSize = resolvePackedRectSize(input, pr.width, pr.height, pr.rotated);
653
+ const rotated = pr.rotated;
654
+ const sprite = doc.createSprite();
655
+ sprite.setItemId(input.id);
656
+ sprite.setRectX(pr.x);
657
+ sprite.setRectY(pr.y);
658
+ sprite.setRectWidth(packedSize.width);
659
+ sprite.setRectHeight(packedSize.height);
660
+ sprite.setRotated(rotated);
661
+ sprite.setOffsetX(input.offsetX);
662
+ sprite.setOffsetY(input.offsetY);
663
+ sprite.setOriginalWidth(input.originalWidth);
664
+ sprite.setOriginalHeight(input.originalHeight);
665
+ sprite.setAtlas(atlasNode);
666
+ atlasNode.addSprite(sprite);
667
+ }
668
+
669
+ for (const res of allResources) {
670
+ if (!isFontResource(res)) continue;
671
+ const fextras = res.getExtras() as FontResourceExtras;
672
+ const alias = fextras?._fontSpriteAlias;
673
+ if (!alias) continue;
674
+ const imgSprite = page.outputRects.find((result) => group.inputs[result.index]?.id === alias.textureId);
675
+ if (!imgSprite) continue;
676
+ const imgInput = group.inputs[imgSprite.index];
677
+ const fontSprite = doc.createSprite();
678
+ fontSprite.setItemId(alias.fontId);
679
+ fontSprite.setRectX(imgSprite.x);
680
+ fontSprite.setRectY(imgSprite.y);
681
+ fontSprite.setRectWidth(imgSprite.width);
682
+ fontSprite.setRectHeight(imgSprite.height);
683
+ fontSprite.setRotated(imgSprite.rotated);
684
+ if (imgInput) {
685
+ fontSprite.setOffsetX(imgInput.offsetX);
686
+ fontSprite.setOffsetY(imgInput.offsetY);
687
+ fontSprite.setOriginalWidth(imgInput.originalWidth);
688
+ fontSprite.setOriginalHeight(imgInput.originalHeight);
689
+ }
690
+ fontSprite.setAtlas(atlasNode);
691
+ atlasNode.addSprite(fontSprite);
692
+ }
693
+ }
694
+
695
+ if (encoder && options.outputPath) {
696
+ if (options.mkdir) {
697
+ await options.mkdir(options.outputPath);
698
+ }
699
+ for (let p = 0; p < pages.length; p++) {
700
+ const page = pages[p];
701
+ const compositeInputs: Array<{ input: Uint8Array; left: number; top: number }> = [];
702
+
703
+ for (const pr of page.outputRects) {
704
+ const input = group.inputs[pr.index];
705
+ if (!input) continue;
706
+ if (pr.width <= 0 || pr.height <= 0 || input.width <= 0 || input.height <= 0) continue;
707
+ try {
708
+ let imgBuffer: Uint8Array;
709
+
710
+ if (input.trimBuffer) {
711
+ imgBuffer = input.trimBuffer;
712
+ if (imgBuffer.length === 0) continue;
713
+ } else {
714
+ if (!isImageResource(input.resource)) {
715
+ logger.warn(`atlas: Non-image input "${input.id}" is missing inline buffer, skipping compositing.`);
716
+ continue;
717
+ }
718
+ const filePath = _resolveImagePath(input.resource, pkg, options.basePath!);
719
+ imgBuffer = await encoder(filePath).toBuffer();
720
+ }
721
+
722
+ if (pr.rotated) imgBuffer = await encoder(imgBuffer).rotate(270).toBuffer();
723
+
724
+ compositeInputs.push({
725
+ input: imgBuffer,
726
+ left: pr.x,
727
+ top: pr.y,
728
+ });
729
+ } catch {
730
+ logger.warn(`atlas: Could not read image "${input.id}" for compositing.`);
731
+ }
732
+ }
733
+
734
+ const atlasFileName = resolveAtlasOutputFileName(pkg, p, group.branchName);
735
+ const outputFile = `${options.outputPath}/${atlasFileName}`;
736
+
737
+ await encoder({
738
+ create: {
739
+ width: page.width,
740
+ height: page.height,
741
+ channels: 4 as const,
742
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
743
+ },
744
+ })
745
+ .composite(compositeInputs)
746
+ .png()
747
+ .toFile(outputFile);
748
+
749
+ logger.info(`atlas: Generated ${atlasFileName} (${page.width}x${page.height}, ${page.outputRects.length} sprites)`);
750
+ }
751
+ }
752
+ }
753
+
754
+ if (usedDirectOutput) {
755
+ logger.info(`atlas: Direct output for single image package "${pkg.getName()}".`);
756
+ }
757
+ logger.info(`atlas: Packed ${inputs.length} images into ${totalPageCount} atlas(es) for package "${pkg.getName()}".`);
758
+ }
759
+ });
760
+ }
761
+
762
+ function buildBranchAtlasGroups(doc: Document, inputs: InputItem[], options: AtlasOptions): BranchAtlasGroup[] {
763
+ if (!options.separatedAtlasForBranch) {
764
+ return [{ branchName: '', branchOrdinal: 0, inputs }];
765
+ }
766
+
767
+ const discoveredBranchNames = [...new Set(inputs
768
+ .map((input) => getInputBranchName(input))
769
+ .filter((branchName) => !!branchName))];
770
+ if (discoveredBranchNames.length === 0) {
771
+ return [{ branchName: '', branchOrdinal: 0, inputs }];
772
+ }
773
+
774
+ const orderedBranchNames = doc.getRoot().listBranches().filter((branchName) => discoveredBranchNames.includes(branchName));
775
+ for (const branchName of discoveredBranchNames) {
776
+ if (!orderedBranchNames.includes(branchName)) orderedBranchNames.push(branchName);
777
+ }
778
+
779
+ const groups = new Map<string, InputItem[]>();
780
+ groups.set('', []);
781
+ for (const branchName of orderedBranchNames) {
782
+ groups.set(branchName, []);
783
+ }
784
+
785
+ for (const input of inputs) {
786
+ const branchName = getInputBranchName(input);
787
+ const key = groups.has(branchName) ? branchName : '';
788
+ groups.get(key)!.push(input);
789
+ }
790
+
791
+ const orderedKeys = [''];
792
+ for (const branchName of orderedBranchNames) {
793
+ if ((groups.get(branchName)?.length ?? 0) > 0) orderedKeys.push(branchName);
794
+ }
795
+
796
+ return orderedKeys
797
+ .filter((branchName) => (groups.get(branchName)?.length ?? 0) > 0)
798
+ .map((branchName, index) => ({
799
+ branchName,
800
+ branchOrdinal: index,
801
+ inputs: groups.get(branchName) ?? [],
802
+ }));
803
+ }
804
+
805
+ function inputToCompatRect(input: InputItem, index: number): CompatNodeRect {
806
+ const duplicatePadding = isImageResource(input.resource) && input.resource.getDuplicatePadding?.() === true;
807
+ return {
808
+ x: 0,
809
+ y: 0,
810
+ width: input.width,
811
+ height: input.height,
812
+ rotated: false,
813
+ index,
814
+ subIndex: -1,
815
+ flags: duplicatePadding ? COMPAT_NODE_RECT_FLAGS.DUPLICATE_PADDING : 0,
816
+ score1: 0,
817
+ score2: 0,
818
+ sourceKind: input.sourceKind,
819
+ };
820
+ }
821
+
822
+ function resolvePackedRectSize(input: InputItem, width: number, height: number, rectRotated: boolean): { width: number; height: number } {
823
+ if (!rectRotated) return { width, height };
824
+ return {
825
+ width: input.height,
826
+ height: input.width,
827
+ };
828
+ }
829
+
830
+ function resolveDirectImageOutput(inputs: InputItem[], options: AtlasOptions): InputItem | null {
831
+ if (!options.directSingleImageOutput || options.extractAlpha) return null;
832
+ if (inputs.length !== 1) return null;
833
+ const [input] = inputs;
834
+ if (!input || input.sourceKind !== 'image' || !isImageResource(input.resource)) return null;
835
+ if (input.resource.getDuplicatePadding?.() === true) return null;
836
+ if (input.width !== input.originalWidth || input.height !== input.originalHeight) return null;
837
+ const fileName = resolveImageFileName(input.resource).toLowerCase();
838
+ if (!fileName.endsWith('.png')) return null;
839
+ return input;
840
+ }
841
+
842
+ function resolveDirectOutputAtlasSize(width: number, height: number, options: AtlasOptions): { width: number; height: number } {
843
+ let resolvedWidth = width;
844
+ let resolvedHeight = height;
845
+ if (options.square) {
846
+ const side = Math.max(resolvedWidth, resolvedHeight);
847
+ resolvedWidth = side;
848
+ resolvedHeight = side;
849
+ }
850
+ if (options.powerOfTwo) {
851
+ resolvedWidth = nextPow2(resolvedWidth);
852
+ resolvedHeight = nextPow2(resolvedHeight);
853
+ }
854
+ return { width: resolvedWidth, height: resolvedHeight };
855
+ }
856
+
857
+ async function emitDirectImageOutput(
858
+ doc: Document,
859
+ pkg: Package,
860
+ input: InputItem,
861
+ encoder: AtlasEncoder | undefined,
862
+ options: AtlasOptions,
863
+ logger: ILogger,
864
+ branchName: string = '',
865
+ branchOrdinal: number = 0,
866
+ ): Promise<void> {
867
+ const atlasFileName = resolveAtlasOutputFileName(pkg, 0, branchName);
868
+ const atlasSize = resolveDirectOutputAtlasSize(input.originalWidth, input.originalHeight, options);
869
+ const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(branchOrdinal, 0)}`);
870
+ atlasNode.setIndex(resolveAtlasIndex(branchOrdinal, 0));
871
+ atlasNode.setFile(atlasFileName);
872
+ atlasNode.setWidth(atlasSize.width);
873
+ atlasNode.setHeight(atlasSize.height);
874
+ pkg.addAtlas(atlasNode);
875
+
876
+ const sprite = doc.createSprite();
877
+ sprite.setItemId(input.id);
878
+ sprite.setRectX(0);
879
+ sprite.setRectY(0);
880
+ sprite.setRectWidth(input.originalWidth);
881
+ sprite.setRectHeight(input.originalHeight);
882
+ sprite.setRotated(false);
883
+ sprite.setOffsetX(0);
884
+ sprite.setOffsetY(0);
885
+ sprite.setOriginalWidth(input.originalWidth);
886
+ sprite.setOriginalHeight(input.originalHeight);
887
+ sprite.setAtlas(atlasNode);
888
+ atlasNode.addSprite(sprite);
889
+
890
+ if (!encoder || !options.outputPath || !isImageResource(input.resource) || !options.basePath) return;
891
+ if (options.mkdir) {
892
+ await options.mkdir(options.outputPath);
893
+ }
894
+
895
+ const outputFile = `${options.outputPath}/${atlasFileName}`;
896
+ const filePath = _resolveImagePath(input.resource, pkg, options.basePath);
897
+
898
+ try {
899
+ if (atlasSize.width === input.originalWidth && atlasSize.height === input.originalHeight) {
900
+ await encoder(filePath).png().toFile(outputFile);
901
+ } else {
902
+ const imageBuffer = await encoder(filePath).png().toBuffer();
903
+ await encoder({
904
+ create: {
905
+ width: atlasSize.width,
906
+ height: atlasSize.height,
907
+ channels: 4 as const,
908
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
909
+ },
910
+ })
911
+ .composite([{ input: imageBuffer, left: 0, top: 0 }])
912
+ .png()
913
+ .toFile(outputFile);
914
+ }
915
+ } catch {
916
+ logger.warn(`atlas: Could not write direct-output atlas "${atlasFileName}".`);
917
+ }
918
+ }
919
+
920
+ function getInputBranchName(input: InputItem): string {
921
+ return (input.resource as { getBranch?(): string }).getBranch?.() ?? '';
922
+ }
923
+
924
+ function resolveAtlasIndex(branchOrdinal: number, pageIndex: number): number {
925
+ if (branchOrdinal <= 0) return pageIndex;
926
+ return branchOrdinal * 100 + pageIndex;
927
+ }
928
+
929
+ function resolveAtlasOutputFileName(pkg: Package, pageIndex: number, branchName: string): string {
930
+ const suffix = branchName ? `_${branchName}` : '';
931
+ return `${pkg.getPublishName() || pkg.getName()}_atlas${pageIndex}${suffix}.png`;
932
+ }
933
+
934
+ function resolveImageFileName(resource: ImageResource): string {
935
+ const extras = resource.getExtras() as ImageResourceExtras;
936
+ return resource.getFileName() || extras._fileName || resource.getName();
937
+ }
938
+
939
+ function nextPow2(value: number): number {
940
+ if (value <= 1) return 1;
941
+ return 2 ** Math.ceil(Math.log2(value));
942
+ }
943
+
944
+ function sortResourcesByOrder(
945
+ resources: PackageResource[],
946
+ orderMap: Map<string, number>,
947
+ inputOrderMap: Map<string, number>,
948
+ ): PackageResource[] {
949
+ const ordered = [...resources];
950
+ ordered.sort((left, right) => {
951
+ const leftId = left.getId();
952
+ const rightId = right.getId();
953
+ const leftOrder = leftId && orderMap.has(leftId) ? (orderMap.get(leftId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
954
+ const rightOrder = rightId && orderMap.has(rightId) ? (orderMap.get(rightId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
955
+ if (leftOrder !== rightOrder) return leftOrder - rightOrder;
956
+ const leftInputOrder = leftId && inputOrderMap.has(leftId) ? (inputOrderMap.get(leftId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
957
+ const rightInputOrder = rightId && inputOrderMap.has(rightId) ? (inputOrderMap.get(rightId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
958
+ if (leftInputOrder !== rightInputOrder) return leftInputOrder - rightInputOrder;
959
+ return (leftId ?? '').localeCompare(rightId ?? '');
960
+ });
961
+ return ordered;
962
+ }
963
+
964
+ interface ExtractedJtaFrameMeta {
965
+ addDelay: number;
966
+ offsetX: number;
967
+ offsetY: number;
968
+ width: number;
969
+ height: number;
970
+ textureIndex: number;
971
+ }
972
+
973
+ interface ExtractedJtaMeta {
974
+ interval: number;
975
+ repeatDelay: number;
976
+ swing: boolean;
977
+ width: number;
978
+ height: number;
979
+ frames: ExtractedJtaFrameMeta[];
980
+ }
981
+
982
+ interface ExtractedJtaData {
983
+ frames: Uint8Array[];
984
+ meta?: ExtractedJtaMeta;
985
+ }
986
+
987
+ /**
988
+ * Trim transparent edges from an image using sharp.
989
+ * Returns the trimmed buffer, dimensions, and offsets.
990
+ * Falls back to the original image if trim fails (e.g. no alpha channel, no transparent edges).
991
+ */
992
+ async function _trimImage(
993
+ encoder: AtlasEncoder,
994
+ filePath: string,
995
+ originalWidth: number,
996
+ originalHeight: number,
997
+ ): Promise<TrimInfo> {
998
+ try {
999
+ const trimResult = await encoder(filePath)
1000
+ .ensureAlpha()
1001
+ .raw()
1002
+ .toBuffer({ resolveWithObject: true });
1003
+ if (!isResolvedBuffer(trimResult)) {
1004
+ throw new Error('atlas: encoder raw alpha trim did not return resolved metadata.');
1005
+ }
1006
+ const { data, info } = trimResult;
1007
+ const width = info.width;
1008
+ const height = info.height;
1009
+ const channels = info.channels || 4;
1010
+ let minX = width;
1011
+ let minY = height;
1012
+ let maxX = -1;
1013
+ let maxY = -1;
1014
+
1015
+ for (let y = 0; y < height; y += 1) {
1016
+ for (let x = 0; x < width; x += 1) {
1017
+ const alphaIndex = (y * width + x) * channels + 3;
1018
+ if ((data[alphaIndex] ?? 0) === 0) continue;
1019
+ if (x < minX) minX = x;
1020
+ if (y < minY) minY = y;
1021
+ if (x > maxX) maxX = x;
1022
+ if (y > maxY) maxY = y;
1023
+ }
1024
+ }
1025
+
1026
+ if (maxX < minX || maxY < minY) {
1027
+ return {
1028
+ buffer: new Uint8Array(0),
1029
+ width: 0,
1030
+ height: 0,
1031
+ offsetX: 0,
1032
+ offsetY: 0,
1033
+ originalWidth,
1034
+ originalHeight,
1035
+ };
1036
+ }
1037
+
1038
+ const trimmedWidth = maxX - minX + 1;
1039
+ const trimmedHeight = maxY - minY + 1;
1040
+ const buffer = await encoder(filePath)
1041
+ .extract({
1042
+ left: minX,
1043
+ top: minY,
1044
+ width: trimmedWidth,
1045
+ height: trimmedHeight,
1046
+ })
1047
+ .toBuffer();
1048
+
1049
+ return {
1050
+ buffer,
1051
+ width: trimmedWidth,
1052
+ height: trimmedHeight,
1053
+ offsetX: minX,
1054
+ offsetY: minY,
1055
+ originalWidth,
1056
+ originalHeight,
1057
+ };
1058
+ } catch {
1059
+ // Trim failed (e.g. JPEG without alpha, nothing to trim) — return original
1060
+ const buf = await encoder(filePath).png().toBuffer();
1061
+ return {
1062
+ buffer: buf,
1063
+ width: originalWidth,
1064
+ height: originalHeight,
1065
+ offsetX: 0,
1066
+ offsetY: 0,
1067
+ originalWidth,
1068
+ originalHeight,
1069
+ };
1070
+ }
1071
+ }
1072
+
1073
+ /**
1074
+ * Resolve an ImageResource to its actual file path on disk.
1075
+ */
1076
+ function _resolveImagePath(resource: ImageResource, pkg: Package, basePath: string): string {
1077
+ const imgPath = resource.getPath() ?? '/';
1078
+ const fileName = resolveImageFileName(resource);
1079
+ const branchName = resource.getBranch?.() ?? '';
1080
+ const normalizedBasePath = basePath.replace(/[/\\]+$/, '');
1081
+ const packageBasePath = !branchName
1082
+ ? normalizedBasePath
1083
+ : /[\\/]assets$/i.test(normalizedBasePath)
1084
+ ? normalizedBasePath.replace(/([\\/])assets$/i, `$1assets_${branchName}`)
1085
+ : `${normalizedBasePath}_${branchName}`;
1086
+ return `${packageBasePath}/${pkg.getName()}${imgPath}${fileName}`;
1087
+ }
1088
+
1089
+ type InputItem = {
1090
+ id: string; width: number; height: number;
1091
+ originalWidth: number; originalHeight: number;
1092
+ offsetX: number; offsetY: number;
1093
+ resource: PackInputResource; trimBuffer?: Uint8Array;
1094
+ sourceKind: 'image' | 'movieclip-frame';
1095
+ };
1096
+
1097
+ /** Collect a single ImageResource into the inputs array. */
1098
+ async function _collectImage(
1099
+ resource: ImageResource,
1100
+ pkg: Package,
1101
+ inputs: InputItem[],
1102
+ encoder: AtlasEncoder | undefined,
1103
+ options: AtlasOptions,
1104
+ doTrim: boolean,
1105
+ logger: ILogger,
1106
+ ): Promise<void> {
1107
+ let origW = resource.getWidth() ?? 0;
1108
+ let origH = resource.getHeight() ?? 0;
1109
+ let sourceHasAlpha = false;
1110
+
1111
+ if (encoder && options.basePath) {
1112
+ const filePath = _resolveImagePath(resource, pkg, options.basePath);
1113
+ try {
1114
+ const metadata = await encoder(filePath).metadata();
1115
+ if (origW === 0 || origH === 0) {
1116
+ origW = metadata.width ?? 0;
1117
+ origH = metadata.height ?? 0;
1118
+ resource.setWidth(origW);
1119
+ resource.setHeight(origH);
1120
+ }
1121
+ sourceHasAlpha = metadata.hasAlpha === true || metadata.channels === 4;
1122
+ } catch {
1123
+ if (origW === 0 || origH === 0) {
1124
+ logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
1125
+ return;
1126
+ }
1127
+ }
1128
+ }
1129
+
1130
+ if (origW <= 0 || origH <= 0) return;
1131
+
1132
+ let packW = origW, packH = origH, offX = 0, offY = 0;
1133
+ let trimBuf: Uint8Array | undefined;
1134
+
1135
+ if (doTrim && sourceHasAlpha && options.basePath && encoder) {
1136
+ const filePath = _resolveImagePath(resource, pkg, options.basePath);
1137
+ try {
1138
+ const trimResult = await _trimImage(encoder, filePath, origW, origH);
1139
+ packW = trimResult.width;
1140
+ packH = trimResult.height;
1141
+ offX = trimResult.offsetX;
1142
+ offY = trimResult.offsetY;
1143
+ trimBuf = trimResult.buffer;
1144
+ } catch {
1145
+ logger.warn(`atlas: Could not trim "${filePath}", using original.`);
1146
+ }
1147
+ }
1148
+
1149
+ inputs.push({
1150
+ id: getPublishedItemId(resource), width: packW, height: packH,
1151
+ originalWidth: origW, originalHeight: origH,
1152
+ offsetX: offX, offsetY: offY,
1153
+ resource,
1154
+ trimBuffer: trimBuf,
1155
+ sourceKind: 'image',
1156
+ });
1157
+ }
1158
+
1159
+ /** Collect MovieClip frame textures from a .jta file into the inputs array. */
1160
+ async function _collectMovieClipFrames(
1161
+ doc: Document,
1162
+ resource: MovieClipResource,
1163
+ pkg: Package,
1164
+ inputs: InputItem[],
1165
+ encoder: AtlasEncoder | undefined,
1166
+ options: AtlasOptions,
1167
+ logger: ILogger,
1168
+ ): Promise<void> {
1169
+ if (!options.basePath || !options.readFileRaw) return;
1170
+
1171
+ const mcId = resource.getId();
1172
+ const mcName = resource.getName() + '.jta';
1173
+ const mcPath = resource.getPath() ?? '/';
1174
+ const filePath = `${options.basePath}/${pkg.getName()}${mcPath}${mcName}`;
1175
+
1176
+ try {
1177
+ const raw = await options.readFileRaw(filePath);
1178
+ const jta = _extractJtaFrames(raw);
1179
+ if (jta.frames.length === 0) return;
1180
+
1181
+ const frameMetas = jta.meta?.frames ?? [];
1182
+ for (const frame of resource.listFrames()) {
1183
+ resource.removeFrame(frame);
1184
+ }
1185
+ resource
1186
+ .setInterval(jta.meta?.interval ?? 100)
1187
+ .setSwing(jta.meta?.swing ?? false)
1188
+ .setRepeatDelay(jta.meta?.repeatDelay ?? 0);
1189
+
1190
+ if (frameMetas.length > 0) {
1191
+ const firstFrameIndexByTextureIndex = new Map<number, number>();
1192
+ for (let frameIndex = 0; frameIndex < frameMetas.length; frameIndex += 1) {
1193
+ const meta = frameMetas[frameIndex];
1194
+ const textureIndex = Number.isFinite(meta.textureIndex) ? meta.textureIndex : frameIndex;
1195
+ if (!firstFrameIndexByTextureIndex.has(textureIndex)) {
1196
+ firstFrameIndexByTextureIndex.set(textureIndex, frameIndex);
1197
+ }
1198
+ }
1199
+
1200
+ const spriteIdByTextureIndex = new Map<number, string>();
1201
+ for (let textureIndex = 0; textureIndex < jta.frames.length; textureIndex += 1) {
1202
+ const exportFrameIndex = firstFrameIndexByTextureIndex.get(textureIndex);
1203
+ if (exportFrameIndex === undefined) continue;
1204
+ const itemId = `${mcId}_${exportFrameIndex}`;
1205
+ const input = await _createMovieClipFrameInput(jta.frames[textureIndex], itemId, resource, encoder);
1206
+ if (!input) continue;
1207
+ inputs.push(input);
1208
+ spriteIdByTextureIndex.set(textureIndex, itemId);
1209
+ }
1210
+
1211
+ for (let frameIndex = 0; frameIndex < frameMetas.length; frameIndex += 1) {
1212
+ const meta = frameMetas[frameIndex];
1213
+ const textureIndex = Number.isFinite(meta.textureIndex) ? meta.textureIndex : frameIndex;
1214
+ const frame = doc.createMovieFrame(`${mcId}_${frameIndex}`);
1215
+ frame
1216
+ .setRectX(meta.offsetX)
1217
+ .setRectY(meta.offsetY)
1218
+ .setRectWidth(meta.width)
1219
+ .setRectHeight(meta.height)
1220
+ .setAddDelay(meta.addDelay)
1221
+ .setSpriteId(spriteIdByTextureIndex.get(textureIndex) ?? '');
1222
+ resource.addFrame(frame);
1223
+ }
1224
+ } else {
1225
+ for (let frameIndex = 0; frameIndex < jta.frames.length; frameIndex += 1) {
1226
+ const itemId = `${mcId}_${frameIndex}`;
1227
+ const input = await _createMovieClipFrameInput(jta.frames[frameIndex], itemId, resource, encoder);
1228
+ if (!input) continue;
1229
+ inputs.push(input);
1230
+ const frame = doc.createMovieFrame(itemId);
1231
+ frame
1232
+ .setRectX(0)
1233
+ .setRectY(0)
1234
+ .setRectWidth(input.originalWidth)
1235
+ .setRectHeight(input.originalHeight)
1236
+ .setAddDelay(0)
1237
+ .setSpriteId(itemId);
1238
+ resource.addFrame(frame);
1239
+ }
1240
+ }
1241
+
1242
+ if ((jta.meta?.width ?? 0) > 0 && (jta.meta?.height ?? 0) > 0) {
1243
+ resource.setWidth(jta.meta?.width ?? 0);
1244
+ resource.setHeight(jta.meta?.height ?? 0);
1245
+ }
1246
+ } catch {
1247
+ logger.warn(`atlas: Could not parse MovieClip "${filePath}", skipping frames.`);
1248
+ }
1249
+ }
1250
+
1251
+ async function _createMovieClipFrameInput(
1252
+ buffer: Uint8Array,
1253
+ itemId: string,
1254
+ resource: MovieClipResource,
1255
+ encoder: AtlasEncoder | undefined,
1256
+ ): Promise<InputItem | null> {
1257
+ if (!encoder || buffer.length === 0) return null;
1258
+ try {
1259
+ const meta = await encoder(buffer).metadata();
1260
+ const width = meta.width ?? 0;
1261
+ const height = meta.height ?? 0;
1262
+ if (width <= 0 || height <= 0) return null;
1263
+ return {
1264
+ id: itemId,
1265
+ width,
1266
+ height,
1267
+ originalWidth: width,
1268
+ originalHeight: height,
1269
+ offsetX: 0,
1270
+ offsetY: 0,
1271
+ resource,
1272
+ trimBuffer: buffer,
1273
+ sourceKind: 'movieclip-frame',
1274
+ };
1275
+ } catch {
1276
+ return null;
1277
+ }
1278
+ }
1279
+
1280
+ const PNG_SIGNATURE = new Uint8Array([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
1281
+
1282
+ function _extractJtaFrames(data: Uint8Array): ExtractedJtaData {
1283
+ const frames: Uint8Array[] = [];
1284
+ let offset = 0;
1285
+ let firstPngOffset = -1;
1286
+
1287
+ while (offset < data.length) {
1288
+ const sigIndex = _findPngSignature(data, offset);
1289
+ if (sigIndex === -1) break;
1290
+ if (firstPngOffset === -1) firstPngOffset = sigIndex;
1291
+ const end = _findPngEnd(data, sigIndex);
1292
+ if (end === -1) break;
1293
+ frames.push(data.subarray(sigIndex, end));
1294
+ offset = end;
1295
+ }
1296
+
1297
+ if (firstPngOffset === -1 || frames.length === 0) {
1298
+ return { frames: [] };
1299
+ }
1300
+
1301
+ return {
1302
+ frames,
1303
+ meta: _parseJtaHeader(data, firstPngOffset, frames.length),
1304
+ };
1305
+ }
1306
+
1307
+ function _findPngSignature(data: Uint8Array, fromIndex: number): number {
1308
+ for (let index = fromIndex; index <= data.length - PNG_SIGNATURE.length; index += 1) {
1309
+ let matched = true;
1310
+ for (let sigIndex = 0; sigIndex < PNG_SIGNATURE.length; sigIndex += 1) {
1311
+ if (data[index + sigIndex] !== PNG_SIGNATURE[sigIndex]) {
1312
+ matched = false;
1313
+ break;
1314
+ }
1315
+ }
1316
+ if (matched) return index;
1317
+ }
1318
+ return -1;
1319
+ }
1320
+
1321
+ function _findPngEnd(data: Uint8Array, start: number): number {
1322
+ let pos = start + PNG_SIGNATURE.length;
1323
+ while (pos + 8 <= data.length) {
1324
+ const length = _readUint32BE(data, pos);
1325
+ pos += 8;
1326
+ if (pos + length + 4 > data.length) return -1;
1327
+ const isIEND =
1328
+ data[pos - 4] === 0x49 &&
1329
+ data[pos - 3] === 0x45 &&
1330
+ data[pos - 2] === 0x4e &&
1331
+ data[pos - 1] === 0x44;
1332
+ pos += length + 4;
1333
+ if (isIEND) return pos;
1334
+ }
1335
+ return -1;
1336
+ }
1337
+
1338
+ function _parseJtaHeader(data: Uint8Array, firstPngOffset: number, frameCount: number): ExtractedJtaMeta | undefined {
1339
+ if (data.length < 10) return undefined;
1340
+
1341
+ const state = { offset: 0 };
1342
+ const end = Math.min(firstPngOffset, data.length);
1343
+ const mark = _readUtfBE(data, state, end);
1344
+ if (!mark) return undefined;
1345
+
1346
+ const version = _readInt32BEAt(data, state, end);
1347
+ if (version == null) return undefined;
1348
+
1349
+ const fpsRaw = _readInt8At(data, state, end);
1350
+ if (fpsRaw == null) return undefined;
1351
+ const fps = fpsRaw > 0 ? fpsRaw : 24;
1352
+
1353
+ if (state.offset + 3 > end) return undefined;
1354
+ state.offset += 3;
1355
+
1356
+ if (version < 102) return undefined;
1357
+
1358
+ _readUint16BEAt(data, state, end);
1359
+ _readUint16BEAt(data, state, end);
1360
+ const width = _readUint16BEAt(data, state, end);
1361
+ const height = _readUint16BEAt(data, state, end);
1362
+ if (width == null || height == null) return undefined;
1363
+
1364
+ const speedRaw = _readUint8At(data, state, end);
1365
+ const repeatDelayRaw = _readUint8At(data, state, end);
1366
+ const swingRaw = _readInt8At(data, state, end);
1367
+ const frameTableCount = _readInt16BEAt(data, state, end);
1368
+ if (speedRaw == null || repeatDelayRaw == null || swingRaw == null || frameTableCount == null) return undefined;
1369
+
1370
+ const frames: ExtractedJtaFrameMeta[] = [];
1371
+ for (let index = 0; index < frameTableCount; index += 1) {
1372
+ const delayRaw = _readInt16BEAt(data, state, end);
1373
+ const offsetX = _readInt16BEAt(data, state, end);
1374
+ const offsetY = _readInt16BEAt(data, state, end);
1375
+ const frameWidth = _readInt16BEAt(data, state, end);
1376
+ const frameHeight = _readInt16BEAt(data, state, end);
1377
+ const textureIndex = _readInt16BEAt(data, state, end);
1378
+ if (
1379
+ delayRaw == null ||
1380
+ offsetX == null ||
1381
+ offsetY == null ||
1382
+ frameWidth == null ||
1383
+ frameHeight == null ||
1384
+ textureIndex == null
1385
+ ) {
1386
+ break;
1387
+ }
1388
+ frames.push({
1389
+ addDelay: Math.trunc((1000 / fps) * delayRaw),
1390
+ offsetX,
1391
+ offsetY,
1392
+ width: frameWidth,
1393
+ height: frameHeight,
1394
+ textureIndex,
1395
+ });
1396
+ }
1397
+
1398
+ return {
1399
+ interval: Math.trunc((1000 / fps) * (speedRaw || 1)),
1400
+ repeatDelay: Math.trunc((1000 / fps) * repeatDelayRaw),
1401
+ swing: swingRaw === 1,
1402
+ width,
1403
+ height,
1404
+ frames: frames.length === 0 && frameCount > 0 ? [] : frames,
1405
+ };
1406
+ }
1407
+
1408
+ function _readUtfBE(data: Uint8Array, state: { offset: number }, end: number): string | null {
1409
+ const length = _readUint16BEAt(data, state, end);
1410
+ if (length == null || state.offset + length > end) return null;
1411
+ const value = new TextDecoder().decode(data.subarray(state.offset, state.offset + length));
1412
+ state.offset += length;
1413
+ return value;
1414
+ }
1415
+
1416
+ function _readUint8At(data: Uint8Array, state: { offset: number }, end: number): number | null {
1417
+ if (state.offset + 1 > end) return null;
1418
+ const value = data[state.offset];
1419
+ state.offset += 1;
1420
+ return value ?? 0;
1421
+ }
1422
+
1423
+ function _readInt8At(data: Uint8Array, state: { offset: number }, end: number): number | null {
1424
+ if (state.offset + 1 > end) return null;
1425
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1426
+ const value = view.getInt8(state.offset);
1427
+ state.offset += 1;
1428
+ return value;
1429
+ }
1430
+
1431
+ function _readUint16BEAt(data: Uint8Array, state: { offset: number }, end: number): number | null {
1432
+ if (state.offset + 2 > end) return null;
1433
+ const value = _readUint16BE(data, state.offset);
1434
+ state.offset += 2;
1435
+ return value;
1436
+ }
1437
+
1438
+ function _readInt16BEAt(data: Uint8Array, state: { offset: number }, end: number): number | null {
1439
+ if (state.offset + 2 > end) return null;
1440
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1441
+ const value = view.getInt16(state.offset, false);
1442
+ state.offset += 2;
1443
+ return value;
1444
+ }
1445
+
1446
+ function _readInt32BEAt(data: Uint8Array, state: { offset: number }, end: number): number | null {
1447
+ if (state.offset + 4 > end) return null;
1448
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
1449
+ const value = view.getInt32(state.offset, false);
1450
+ state.offset += 4;
1451
+ return value;
1452
+ }
1453
+
1454
+ function _readUint16BE(data: Uint8Array, offset: number): number {
1455
+ if (offset + 1 >= data.length) return 0;
1456
+ return (data[offset] << 8) | data[offset + 1];
1457
+ }
1458
+
1459
+ function _readUint32BE(data: Uint8Array, offset: number): number {
1460
+ if (offset + 3 >= data.length) return 0;
1461
+ return (
1462
+ (data[offset] * 0x1000000) +
1463
+ ((data[offset + 1] ?? 0) << 16) +
1464
+ ((data[offset + 2] ?? 0) << 8) +
1465
+ (data[offset + 3] ?? 0)
1466
+ );
1467
+ }
1468
+
1469
+ /** Collect a Bitmap Font's texture image, packed under the font's ID. */
1470
+ async function _collectFontTexture(
1471
+ doc: Document,
1472
+ fontRes: FontResource,
1473
+ pkg: Package,
1474
+ options: AtlasOptions,
1475
+ ): Promise<void> {
1476
+ const extras = fontRes.getExtras() as FontResourceExtras;
1477
+ const textureId = fontRes.getTextureId?.() ?? '';
1478
+
1479
+ if (textureId) {
1480
+ // Record font→texture mapping so we can add a duplicate sprite entry
1481
+ // after atlas packing. The editor stores both the image ID (jb800) and
1482
+ // the font ID (wa8u2r) as separate sprites at the same atlas position.
1483
+ const fontId = fontRes.getId();
1484
+ fontRes.setExtras({ ...fontRes.getExtras(), _fontSpriteAlias: { fontId, textureId } });
1485
+ }
1486
+
1487
+ // Parse .fnt file for glyph data (needed for binary encoding)
1488
+ // This applies to ALL fonts, not just those with a textureId
1489
+ if (options.readFileRaw && options.basePath) {
1490
+ const fontName = resolveFontFileName(fontRes.getName());
1491
+ const fontPath = fontRes.getPath() ?? '/';
1492
+ const pkgName = pkg.getName();
1493
+ const fntFile = `${options.basePath}/${pkgName}${fontPath}${fontName}`;
1494
+ try {
1495
+ const fntData = await options.readFileRaw(fntFile);
1496
+ const fntText = new TextDecoder().decode(fntData);
1497
+ const fntParsed = _parseFnt(fntText);
1498
+ for (const glyph of fontRes.listGlyphs()) {
1499
+ fontRes.removeGlyph(glyph);
1500
+ }
1501
+ fontRes
1502
+ .setTtf(fntParsed.hasFace)
1503
+ .setTint(fntParsed.colored)
1504
+ .setAutoScale(fntParsed.resizable)
1505
+ .setHasChannel(fntParsed.hasChannel)
1506
+ .setFontSize(fntParsed.fontSize)
1507
+ .setXAdvance(fntParsed.xadvance)
1508
+ .setLineHeight(fntParsed.lineHeight);
1509
+ for (const item of fntParsed.glyphs) {
1510
+ const glyph = doc.createFontGlyph(`${fontRes.getId()}_${item.charId}`);
1511
+ glyph
1512
+ .setCharId(item.charId)
1513
+ .setChar(item.charId > 0 ? String.fromCodePoint(item.charId) : '')
1514
+ .setImg(item.img ?? '')
1515
+ .setX(item.x)
1516
+ .setY(item.y)
1517
+ .setXOffset(item.xoffset)
1518
+ .setYOffset(item.yoffset)
1519
+ .setWidth(item.width)
1520
+ .setHeight(item.height)
1521
+ .setAdvance(item.xadvance)
1522
+ .setLineHeight(fntParsed.lineHeight)
1523
+ .setChannel(item.channel);
1524
+ fontRes.addGlyph(glyph);
1525
+ }
1526
+ } catch { /* .fnt not found */ }
1527
+ }
1528
+ }
1529
+
1530
+ /** Parse a BMFont .fnt text file into structured data for binary encoding. */
1531
+ function _parseFnt(text: string): {
1532
+ hasFace: boolean; colored: boolean; resizable: boolean; hasChannel: boolean;
1533
+ fontSize: number; xadvance: number; lineHeight: number;
1534
+ glyphs: Array<{
1535
+ charId: number; img: string | null;
1536
+ x: number; y: number; xoffset: number; yoffset: number;
1537
+ width: number; height: number; xadvance: number; channel: number;
1538
+ }>;
1539
+ } {
1540
+ const lines = text.split(/\r?\n/);
1541
+ let hasFace = false, colored = false, resizable = false, hasChannel = false;
1542
+ let fontSize = 0, globalXadvance = 0, lineHeight = 0;
1543
+ const glyphs: Array<{
1544
+ charId: number; img: string | null;
1545
+ x: number; y: number; xoffset: number; yoffset: number;
1546
+ width: number; height: number; xadvance: number; channel: number;
1547
+ }> = [];
1548
+
1549
+ for (const line of lines) {
1550
+ const trimmed = line.trim();
1551
+ if (!trimmed) continue;
1552
+ const parts = trimmed.split(/\s+/);
1553
+ const attrs: Record<string, string> = {};
1554
+ for (let i = 1; i < parts.length; i++) {
1555
+ const eq = parts[i].split('=');
1556
+ if (eq.length === 2) attrs[eq[0]] = eq[1];
1557
+ }
1558
+
1559
+ switch (parts[0]) {
1560
+ case 'info':
1561
+ hasFace = attrs.face != null;
1562
+ colored = hasFace;
1563
+ if (attrs.colored !== undefined) colored = attrs.colored === 'true';
1564
+ fontSize = parseInt(attrs.size, 10) || 0;
1565
+ resizable = attrs.resizable === 'true';
1566
+ break;
1567
+ case 'common':
1568
+ lineHeight = parseInt(attrs.lineHeight, 10) || 0;
1569
+ globalXadvance = parseInt(attrs.xadvance, 10) || 0;
1570
+ if (fontSize === 0) fontSize = lineHeight;
1571
+ else if (lineHeight === 0) lineHeight = fontSize;
1572
+ break;
1573
+ case 'char': {
1574
+ const charId = parseInt(attrs.id, 10) || 0;
1575
+ if (charId === 0) continue;
1576
+ const img = attrs.img || null;
1577
+ if (!hasFace && !img) continue;
1578
+ const chnl = parseInt(attrs.chnl, 10) || 0;
1579
+ if (chnl !== 0 && chnl !== 15) hasChannel = true;
1580
+ glyphs.push({
1581
+ charId, img,
1582
+ x: parseInt(attrs.x, 10) || 0,
1583
+ y: parseInt(attrs.y, 10) || 0,
1584
+ xoffset: parseInt(attrs.xoffset, 10) || 0,
1585
+ yoffset: parseInt(attrs.yoffset, 10) || 0,
1586
+ width: parseInt(attrs.width, 10) || 0,
1587
+ height: parseInt(attrs.height, 10) || 0,
1588
+ xadvance: parseInt(attrs.xadvance, 10) || 0,
1589
+ channel: chnl,
1590
+ });
1591
+ break;
1592
+ }
1593
+ }
1594
+ }
1595
+
1596
+ return { hasFace, colored, resizable: fontSize > 0 ? resizable : false, hasChannel, fontSize, xadvance: globalXadvance, lineHeight, glyphs };
1597
+ }
1598
+
1599
+ function isComponentResource(resource: PackageResource): resource is Component {
1600
+ return resource.propertyType === 'Component';
1601
+ }
1602
+
1603
+ function isImageResource(resource: PackageResource): resource is ImageResource {
1604
+ return resource.propertyType === 'ImageResource';
1605
+ }
1606
+
1607
+ function isMovieClipResource(resource: PackageResource): resource is MovieClipResource {
1608
+ return resource.propertyType === 'MovieClipResource';
1609
+ }
1610
+
1611
+ function isSkeletonResource(resource: PackageResource): resource is SpineResource | DragonBonesResource {
1612
+ return resource.propertyType === 'SpineResource' || resource.propertyType === 'DragonBonesResource';
1613
+ }
1614
+
1615
+ function isFontResource(resource: PackageResource): resource is FontResource {
1616
+ return resource.propertyType === 'FontResource';
1617
+ }
1618
+
1619
+ function isPackableResource(resource: PackageResource): resource is PackableResource {
1620
+ return isImageResource(resource) || isMovieClipResource(resource) || isFontResource(resource);
1621
+ }
1622
+
1623
+ function addUiResourceRef(target: Set<string>, value: string | undefined | null): void {
1624
+ if (!value?.startsWith('ui://')) return;
1625
+ const refId = value.slice(5).slice(8);
1626
+ if (refId) target.add(refId);
1627
+ }
1628
+
1629
+ function addUiResourceRefsFromText(target: Set<string>, value: string | undefined | null): void {
1630
+ if (!value || typeof value !== 'string') return;
1631
+ const matches = value.matchAll(/ui:\/\/[0-9a-z]{8}([0-9a-z]+)/gi);
1632
+ for (const match of matches) {
1633
+ const refId = match[1] ?? '';
1634
+ if (refId) target.add(refId);
1635
+ }
1636
+ }
1637
+
1638
+ function addUiResourceRefsFromUnknown(target: Set<string>, value: unknown): void {
1639
+ if (Array.isArray(value)) {
1640
+ for (const entry of value) addUiResourceRefsFromUnknown(target, entry);
1641
+ return;
1642
+ }
1643
+ if (typeof value === 'string') {
1644
+ addUiResourceRef(target, value);
1645
+ addUiResourceRefsFromText(target, value);
1646
+ }
1647
+ }
1648
+
1649
+ function isResolvedBuffer(value: Uint8Array | AtlasEncoderResolvedBuffer): value is AtlasEncoderResolvedBuffer {
1650
+ return typeof value === 'object' && value !== null && 'data' in value && 'info' in value;
1651
+ }