@openfairygui/functions 0.2.0-alpha.1 → 0.2.0-alpha.12

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 CHANGED
@@ -1,12 +1,32 @@
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';
1
+ import {
2
+ GearType,
3
+ TransitionActionType,
4
+ type Component,
5
+ type Document,
6
+ type DragonBonesResource,
7
+ type FontResource,
8
+ type ILogger,
9
+ type ImageResource,
10
+ type MovieClipResource,
11
+ type Package,
12
+ type SpineResource,
13
+ type Transform,
14
+ } from '@openfairygui/core';
2
15
  import { COMPAT_NODE_RECT_FLAGS, type CompatNodeRect } from './max-rects-compat.js';
3
16
  import { MaxRectsPackerCompat } from './max-rects-packer-compat.js';
17
+ import type { AtlasRasterBackend, AtlasRasterInput, AtlasRasterResolvedBuffer } from './publish/contracts.js';
4
18
  import type { ExtrasMap, HasOptionalSrc, HasOptionalUrl } from './shared-types.js';
5
- import { createTransform } from './utils.js';
19
+ import { createTransform, parseTextureSetMode, type TextureSetMode } from './utils.js';
6
20
 
7
21
  export interface AtlasOptions {
8
22
  /**
9
- * Sharp module instance, injected by the caller.
23
+ * Limit atlas generation to specific package names.
24
+ * When omitted, all packages are processed.
25
+ */
26
+ packages?: string[];
27
+
28
+ /**
29
+ * Raster backend, injected by the host adapter.
10
30
  * Required for actual image compositing and trimImage.
11
31
  *
12
32
  * ```ts
@@ -14,7 +34,7 @@ export interface AtlasOptions {
14
34
  * await doc.transform(atlas({ encoder: sharp }));
15
35
  * ```
16
36
  */
17
- encoder?: unknown;
37
+ encoder?: AtlasRasterBackend;
18
38
 
19
39
  /** Maximum atlas texture size (width and height). Default: 2048. */
20
40
  maxSize?: number;
@@ -39,7 +59,7 @@ export interface AtlasOptions {
39
59
 
40
60
  /**
41
61
  * Trim transparent pixels from image edges before packing.
42
- * Requires encoder (sharp). Stores offset/originalSize in Sprite nodes.
62
+ * Requires a raster backend. Stores offset/originalSize in Sprite nodes.
43
63
  * Default: false.
44
64
  */
45
65
  trimImage?: boolean;
@@ -92,10 +112,11 @@ export interface AtlasOptions {
92
112
  * separate atlas pages/files per branch instead of mixing them with main.
93
113
  */
94
114
  separatedAtlasForBranch?: boolean;
95
-
96
115
  }
97
116
 
98
- const ATLAS_DEFAULTS: Required<Omit<AtlasOptions, 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw'>> = {
117
+ const ATLAS_DEFAULTS: Required<
118
+ Omit<AtlasOptions, 'packages' | 'encoder' | 'basePath' | 'outputPath' | 'mkdir' | 'readFileRaw'>
119
+ > = {
99
120
  maxSize: 2048,
100
121
  fast: true,
101
122
  allowRotation: true,
@@ -200,54 +221,20 @@ interface BranchAtlasGroup {
200
221
  inputs: InputItem[];
201
222
  }
202
223
 
203
- interface AtlasEncoderMetadata {
204
- width?: number;
205
- height?: number;
206
- channels?: number;
207
- hasAlpha?: boolean;
208
- trimOffsetLeft?: number;
209
- trimOffsetTop?: number;
210
- }
211
-
212
- interface AtlasEncoderResolvedBuffer {
213
- data: Uint8Array;
214
- info: Required<Pick<AtlasEncoderMetadata, 'width' | 'height' | 'channels'>> & AtlasEncoderMetadata;
215
- }
216
-
217
- interface AtlasCompositeInput {
218
- input: Uint8Array;
219
- left: number;
220
- top: number;
221
- }
222
-
223
- interface AtlasEncoderPipeline {
224
- ensureAlpha(): AtlasEncoderPipeline;
225
- raw(): AtlasEncoderPipeline;
226
- extract(options: { left: number; top: number; width: number; height: number }): AtlasEncoderPipeline;
227
- toBuffer(options: { resolveWithObject: true }): Promise<AtlasEncoderResolvedBuffer>;
228
- toBuffer(options?: { resolveWithObject?: false }): Promise<Uint8Array>;
229
- toBuffer(options?: { resolveWithObject?: boolean }): Promise<Uint8Array | AtlasEncoderResolvedBuffer>;
230
- png(): AtlasEncoderPipeline;
231
- metadata(): Promise<AtlasEncoderMetadata>;
232
- rotate(angle: number): AtlasEncoderPipeline;
233
- composite(inputs: AtlasCompositeInput[]): AtlasEncoderPipeline;
234
- toFile(path: string): Promise<unknown>;
224
+ function getSelectedSkeletonDependencyImageIds(resources: PackageResource[]): Set<string> {
225
+ const imageIds = new Set<string>();
226
+ const resourcesById = new Map(resources.map((resource) => [resource.getId(), resource] as const));
227
+ for (const resource of resources) {
228
+ if (!isSkeletonResource(resource)) continue;
229
+ for (const requiredId of resource.getRequireIds()) {
230
+ if (!requiredId) continue;
231
+ const required = resourcesById.get(requiredId);
232
+ if (required && isImageResource(required)) imageIds.add(requiredId);
233
+ }
234
+ }
235
+ return imageIds;
235
236
  }
236
237
 
237
- type AtlasEncoderInput =
238
- | string
239
- | Uint8Array
240
- | {
241
- create: {
242
- width: number;
243
- height: number;
244
- channels: 4;
245
- background: { r: number; g: number; b: number; alpha: number };
246
- };
247
- };
248
-
249
- type AtlasEncoder = (input: AtlasEncoderInput) => AtlasEncoderPipeline;
250
-
251
238
  function resolveFontFileName(fontName: string): string {
252
239
  return /\.fnt$/i.test(fontName) ? fontName : `${fontName}.fnt`;
253
240
  }
@@ -282,7 +269,9 @@ async function resolveEditorCompatibleResourceOrder(
282
269
  const imgMatch = line.match(/\bimg=(\w+)/);
283
270
  if (imgMatch) await addResource(resourceMap.get(imgMatch[1] ?? ''));
284
271
  }
285
- } catch { /* ignore */ }
272
+ } catch {
273
+ /* ignore */
274
+ }
286
275
  }
287
276
  }
288
277
  if (isComponentResource(resource)) {
@@ -371,7 +360,9 @@ async function resolveEditorCompatibleResourceOrder(
371
360
  ]) {
372
361
  await addResourceByLocalUiUrl(ref);
373
362
  }
374
- for (const transition of (component as Component & { listTransitions?(): TransitionWithAtlasRefs[] }).listTransitions?.() ?? []) {
363
+ for (const transition of (
364
+ component as Component & { listTransitions?(): TransitionWithAtlasRefs[] }
365
+ ).listTransitions?.() ?? []) {
375
366
  for (const item of transition.listItems?.() ?? []) {
376
367
  const actionType = item.getActionType?.();
377
368
  if (actionType !== TransitionActionType.Sound && actionType !== TransitionActionType.Icon) continue;
@@ -400,7 +391,7 @@ async function resolveEditorCompatibleResourceOrder(
400
391
  *
401
392
  * This transform performs MaxRects bin-packing on all ImageResource items
402
393
  * within each package, creating Atlas and Sprite property nodes. When an
403
- * `encoder` (sharp) is provided, it also composites the actual PNG files.
394
+ * a raster backend is provided, it also composites the actual PNG files.
404
395
  *
405
396
  * When `trimImage` is enabled and encoder is available, transparent pixels
406
397
  * at image edges are trimmed before packing. The trimmed offset and original
@@ -423,21 +414,30 @@ export function atlas(_options: AtlasOptions = {}): Transform {
423
414
  return createTransform('atlas', async (doc: Document): Promise<void> => {
424
415
  const root = doc.getRoot();
425
416
  const logger = doc.getLogger();
426
- const encoder = options.encoder as AtlasEncoder | undefined;
417
+ const encoder = options.encoder;
427
418
  const doTrim = options.trimImage && !!encoder && !!options.basePath;
419
+ const packageFilter = options.packages ? new Set(options.packages) : null;
428
420
 
429
421
  for (const pkg of root.listPackages()) {
422
+ if (packageFilter && !packageFilter.has(pkg.getName())) continue;
430
423
  // Respect publish-selected resources when publish() precomputes a merged branch view.
431
- const selectedPublishIds = new Set(((pkg.getExtras() as PackageAtlasExtras | undefined) ?? {}).publishedResourceIds ?? []);
432
- const allResources = selectedPublishIds.size > 0
433
- ? pkg.listResources().filter((resource) => selectedPublishIds.has(resource.getId()))
434
- : pkg.listResources();
424
+ const selectedPublishIds = new Set(
425
+ ((pkg.getExtras() as PackageAtlasExtras | undefined) ?? {}).publishedResourceIds ?? [],
426
+ );
427
+ const allResources =
428
+ selectedPublishIds.size > 0
429
+ ? pkg.listResources().filter((resource) => selectedPublishIds.has(resource.getId()))
430
+ : pkg.listResources();
431
+ const skeletonDependencyImageIds = getSelectedSkeletonDependencyImageIds(allResources);
435
432
  // Process resources in declaration order (matching editor behavior)
436
433
  const orderedResources = await resolveEditorCompatibleResourceOrder(pkg, allResources, options);
437
434
  const resourceOrder = new Map(orderedResources.map((resource, index) => [resource.getId(), index]));
438
435
  const inputOrder = new Map(allResources.map((resource, index) => [resource.getId(), index]));
439
436
  const orderedAllResources = sortResourcesByOrder(allResources, resourceOrder, inputOrder);
440
- const hasPackable = allResources.some((resource) => isPackableResource(resource));
437
+ const hasPackable = allResources.some((resource) => {
438
+ if (isImageResource(resource) && skeletonDependencyImageIds.has(resource.getId())) return false;
439
+ return isPackableResource(resource);
440
+ });
441
441
  if (!hasPackable) continue;
442
442
 
443
443
  // Collect packable items in declaration order
@@ -490,7 +490,9 @@ export function atlas(_options: AtlasOptions = {}): Transform {
490
490
  addUiResourceRefsFromUnknown(referencedIds, gear.getDefaultValue?.());
491
491
  }
492
492
  }
493
- for (const transition of (component as Component & { listTransitions?(): TransitionWithAtlasRefs[] }).listTransitions?.() ?? []) {
493
+ for (const transition of (
494
+ component as Component & { listTransitions?(): TransitionWithAtlasRefs[] }
495
+ ).listTransitions?.() ?? []) {
494
496
  for (const item of transition.listItems?.() ?? []) {
495
497
  addUiResourceRefsFromUnknown(referencedIds, item.getStartValue?.());
496
498
  addUiResourceRefsFromUnknown(referencedIds, item.getEndValue?.());
@@ -522,7 +524,9 @@ export function atlas(_options: AtlasOptions = {}): Transform {
522
524
  const match = line.match(/img=(\w+)/);
523
525
  if (match) referencedIds.add(match[1]);
524
526
  }
525
- } catch { /* .fnt file not found — OK */ }
527
+ } catch {
528
+ /* .fnt file not found — OK */
529
+ }
526
530
  }
527
531
  }
528
532
  }
@@ -531,175 +535,124 @@ export function atlas(_options: AtlasOptions = {}): Transform {
531
535
  if (isImageResource(res)) {
532
536
  // Pack referenced images, plus explicitly exported standalone images.
533
537
  const resId = res.getId();
534
- if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
538
+ if (skeletonDependencyImageIds.has(resId)) continue;
539
+ if (
540
+ selectedPublishIds.size === 0 &&
541
+ !res.getExported() &&
542
+ referencedIds.size > 0 &&
543
+ !referencedIds.has(resId)
544
+ )
545
+ continue;
535
546
  await _collectImage(res, pkg, inputs, encoder, options, doTrim, logger);
536
547
  } else if (isMovieClipResource(res)) {
537
548
  const resId = res.getId();
538
- if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
549
+ if (
550
+ selectedPublishIds.size === 0 &&
551
+ !res.getExported() &&
552
+ referencedIds.size > 0 &&
553
+ !referencedIds.has(resId)
554
+ )
555
+ continue;
539
556
  await _collectMovieClipFrames(doc, res, pkg, inputs, encoder, options, logger);
540
557
  } else if (isFontResource(res)) {
541
558
  const resId = res.getId();
542
- if (!res.getExported() && referencedIds.size > 0 && !referencedIds.has(resId)) continue;
559
+ if (
560
+ selectedPublishIds.size === 0 &&
561
+ !res.getExported() &&
562
+ referencedIds.size > 0 &&
563
+ !referencedIds.has(resId)
564
+ )
565
+ continue;
543
566
  await _collectFontTexture(doc, res, pkg, options);
544
567
  }
545
568
  }
546
569
 
547
570
  if (inputs.length === 0) continue;
548
- const branchGroups = buildBranchAtlasGroups(doc, inputs, options);
549
571
  let totalPageCount = 0;
550
572
  let usedDirectOutput = false;
573
+ const { autoInputs, fixedPageGroups, standaloneGroups, reservedPageIndexes } = groupStandaloneInputs(
574
+ doc,
575
+ inputs,
576
+ options,
577
+ );
578
+ const branchGroups = buildBranchAtlasGroups(doc, autoInputs, options);
579
+ const branchPageOffsets = new Map<number, number>();
551
580
 
552
581
  for (const group of branchGroups) {
553
- const directOutput = resolveDirectImageOutput(group.inputs, options);
582
+ const directOutput =
583
+ fixedPageGroups.length === 0 && standaloneGroups.length === 0
584
+ ? resolveDirectImageOutput(group.inputs, options)
585
+ : null;
554
586
  if (directOutput) {
555
- await emitDirectImageOutput(doc, pkg, directOutput, encoder, options, logger, group.branchName, group.branchOrdinal);
587
+ await emitDirectImageOutput(
588
+ doc,
589
+ pkg,
590
+ directOutput,
591
+ encoder,
592
+ options,
593
+ logger,
594
+ group.branchName,
595
+ group.branchOrdinal,
596
+ );
556
597
  usedDirectOutput = true;
557
598
  totalPageCount += 1;
558
599
  continue;
559
600
  }
560
-
561
- const hasDuplicatePadding = group.inputs.some((i) => {
562
- return isImageResource(i.resource) && i.resource.getDuplicatePadding?.() === true;
601
+ const pageStart = reserveAutoPageStart(branchPageOffsets, group.branchOrdinal, reservedPageIndexes);
602
+ const emittedPageCount = await emitPagedAtlasGroup(doc, pkg, allResources, group.inputs, {
603
+ branchName: group.branchName,
604
+ branchOrdinal: group.branchOrdinal,
605
+ pageStart,
606
+ fileNameAt: (pageIndex) => resolveAtlasOutputFileName(pkg, pageIndex, group.branchName),
607
+ options,
608
+ encoder,
609
+ logger,
563
610
  });
611
+ totalPageCount += emittedPageCount;
612
+ branchPageOffsets.set(group.branchOrdinal, pageStart + emittedPageCount);
613
+ }
564
614
 
565
- const packer = new MaxRectsPackerCompat({
566
- pot: options.powerOfTwo,
567
- mof: !options.powerOfTwo,
568
- padding: options.padding,
569
- rotation: options.allowRotation,
570
- minWidth: 16,
571
- minHeight: 16,
572
- maxWidth: options.maxSize,
573
- maxHeight: options.maxSize,
574
- square: options.square,
575
- fast: options.fast,
576
- edgePadding: false,
577
- duplicatePadding: hasDuplicatePadding,
578
- multiPage: options.multiPage,
579
- preserveInputOrderOnTie: options.preserveInputOrderOnTie,
615
+ for (const group of fixedPageGroups) {
616
+ const emittedPageCount = await emitPagedAtlasGroup(doc, pkg, allResources, group.inputs, {
617
+ branchName: group.branchName,
618
+ branchOrdinal: group.branchOrdinal,
619
+ pageStart: group.pageIndex,
620
+ forceSinglePage: true,
621
+ fileNameAt: () => resolveAtlasOutputFileName(pkg, group.pageIndex, group.branchName),
622
+ options,
623
+ encoder,
624
+ logger,
580
625
  });
581
- const pages = packer.pack(group.inputs.map((input, index) => inputToCompatRect(input, index)));
582
- if (!pages || pages.length === 0) continue;
583
- totalPageCount += pages.length;
584
-
585
- for (let p = 0; p < pages.length; p++) {
586
- const page = pages[p];
587
- const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(group.branchOrdinal, p)}`);
588
- atlasNode.setIndex(resolveAtlasIndex(group.branchOrdinal, p));
589
- atlasNode.setFile(resolveAtlasOutputFileName(pkg, p, group.branchName));
590
- atlasNode.setWidth(page.width);
591
- atlasNode.setHeight(page.height);
592
- pkg.addAtlas(atlasNode);
593
-
594
- for (const pr of page.outputRects) {
595
- const input = group.inputs[pr.index];
596
- if (!input) continue;
597
- const packedSize = resolvePackedRectSize(input, pr.width, pr.height, pr.rotated);
598
- const rotated = pr.rotated;
599
- const sprite = doc.createSprite();
600
- sprite.setItemId(input.id);
601
- sprite.setRectX(pr.x);
602
- sprite.setRectY(pr.y);
603
- sprite.setRectWidth(packedSize.width);
604
- sprite.setRectHeight(packedSize.height);
605
- sprite.setRotated(rotated);
606
- sprite.setOffsetX(input.offsetX);
607
- sprite.setOffsetY(input.offsetY);
608
- sprite.setOriginalWidth(input.originalWidth);
609
- sprite.setOriginalHeight(input.originalHeight);
610
- sprite.setAtlas(atlasNode);
611
- atlasNode.addSprite(sprite);
612
- }
613
-
614
- for (const res of allResources) {
615
- if (!isFontResource(res)) continue;
616
- const fextras = res.getExtras() as FontResourceExtras;
617
- const alias = fextras?._fontSpriteAlias;
618
- if (!alias) continue;
619
- const imgSprite = page.outputRects.find((result) => group.inputs[result.index]?.id === alias.textureId);
620
- if (!imgSprite) continue;
621
- const imgInput = group.inputs[imgSprite.index];
622
- const fontSprite = doc.createSprite();
623
- fontSprite.setItemId(alias.fontId);
624
- fontSprite.setRectX(imgSprite.x);
625
- fontSprite.setRectY(imgSprite.y);
626
- fontSprite.setRectWidth(imgSprite.width);
627
- fontSprite.setRectHeight(imgSprite.height);
628
- fontSprite.setRotated(imgSprite.rotated);
629
- if (imgInput) {
630
- fontSprite.setOffsetX(imgInput.offsetX);
631
- fontSprite.setOffsetY(imgInput.offsetY);
632
- fontSprite.setOriginalWidth(imgInput.originalWidth);
633
- fontSprite.setOriginalHeight(imgInput.originalHeight);
634
- }
635
- fontSprite.setAtlas(atlasNode);
636
- atlasNode.addSprite(fontSprite);
637
- }
638
- }
626
+ totalPageCount += emittedPageCount;
627
+ }
639
628
 
640
- if (encoder && options.outputPath) {
641
- if (options.mkdir) {
642
- await options.mkdir(options.outputPath);
643
- }
644
- for (let p = 0; p < pages.length; p++) {
645
- const page = pages[p];
646
- const compositeInputs: Array<{ input: Uint8Array; left: number; top: number }> = [];
647
-
648
- for (const pr of page.outputRects) {
649
- const input = group.inputs[pr.index];
650
- if (!input) continue;
651
- if (pr.width <= 0 || pr.height <= 0 || input.width <= 0 || input.height <= 0) continue;
652
- try {
653
- let imgBuffer: Uint8Array;
654
-
655
- if (input.trimBuffer) {
656
- imgBuffer = input.trimBuffer;
657
- if (imgBuffer.length === 0) continue;
658
- } else {
659
- if (!isImageResource(input.resource)) {
660
- logger.warn(`atlas: Non-image input "${input.id}" is missing inline buffer, skipping compositing.`);
661
- continue;
662
- }
663
- const filePath = _resolveImagePath(input.resource, pkg, options.basePath!);
664
- imgBuffer = await encoder(filePath).toBuffer();
665
- }
666
-
667
- if (pr.rotated) imgBuffer = await encoder(imgBuffer).rotate(270).toBuffer();
668
-
669
- compositeInputs.push({
670
- input: imgBuffer,
671
- left: pr.x,
672
- top: pr.y,
673
- });
674
- } catch {
675
- logger.warn(`atlas: Could not read image "${input.id}" for compositing.`);
676
- }
677
- }
629
+ const standalonePageOffsets = new Map(branchPageOffsets);
630
+ for (const group of fixedPageGroups) {
631
+ const nextPageIndex = group.pageIndex + 1;
632
+ const current = standalonePageOffsets.get(group.branchOrdinal) ?? 0;
633
+ if (nextPageIndex > current) standalonePageOffsets.set(group.branchOrdinal, nextPageIndex);
634
+ }
678
635
 
679
- const atlasFileName = resolveAtlasOutputFileName(pkg, p, group.branchName);
680
- const outputFile = `${options.outputPath}/${atlasFileName}`;
681
-
682
- await encoder({
683
- create: {
684
- width: page.width,
685
- height: page.height,
686
- channels: 4 as const,
687
- background: { r: 0, g: 0, b: 0, alpha: 0 },
688
- },
689
- })
690
- .composite(compositeInputs)
691
- .png()
692
- .toFile(outputFile);
693
-
694
- logger.info(`atlas: Generated ${atlasFileName} (${page.width}x${page.height}, ${page.outputRects.length} sprites)`);
695
- }
696
- }
636
+ for (const group of standaloneGroups) {
637
+ const emittedPageCount = await emitStandaloneAtlasGroup(doc, pkg, group, {
638
+ atlasIndexStart: standalonePageOffsets.get(group.branchOrdinal) ?? 0,
639
+ options,
640
+ encoder,
641
+ logger,
642
+ });
643
+ totalPageCount += emittedPageCount;
644
+ standalonePageOffsets.set(
645
+ group.branchOrdinal,
646
+ (standalonePageOffsets.get(group.branchOrdinal) ?? 0) + emittedPageCount,
647
+ );
697
648
  }
698
649
 
699
650
  if (usedDirectOutput) {
700
651
  logger.info(`atlas: Direct output for single image package "${pkg.getName()}".`);
701
652
  }
702
- logger.info(`atlas: Packed ${inputs.length} images into ${totalPageCount} atlas(es) for package "${pkg.getName()}".`);
653
+ logger.info(
654
+ `atlas: Packed ${inputs.length} images into ${totalPageCount} atlas(es) for package "${pkg.getName()}".`,
655
+ );
703
656
  }
704
657
  });
705
658
  }
@@ -709,14 +662,17 @@ function buildBranchAtlasGroups(doc: Document, inputs: InputItem[], options: Atl
709
662
  return [{ branchName: '', branchOrdinal: 0, inputs }];
710
663
  }
711
664
 
712
- const discoveredBranchNames = [...new Set(inputs
713
- .map((input) => getInputBranchName(input))
714
- .filter((branchName) => !!branchName))];
665
+ const discoveredBranchNames = [
666
+ ...new Set(inputs.map((input) => getInputBranchName(input)).filter((branchName) => !!branchName)),
667
+ ];
715
668
  if (discoveredBranchNames.length === 0) {
716
669
  return [{ branchName: '', branchOrdinal: 0, inputs }];
717
670
  }
718
671
 
719
- const orderedBranchNames = doc.getRoot().listBranches().filter((branchName) => discoveredBranchNames.includes(branchName));
672
+ const orderedBranchNames = doc
673
+ .getRoot()
674
+ .listBranches()
675
+ .filter((branchName) => discoveredBranchNames.includes(branchName));
720
676
  for (const branchName of discoveredBranchNames) {
721
677
  if (!orderedBranchNames.includes(branchName)) orderedBranchNames.push(branchName);
722
678
  }
@@ -747,6 +703,263 @@ function buildBranchAtlasGroups(doc: Document, inputs: InputItem[], options: Atl
747
703
  }));
748
704
  }
749
705
 
706
+ function reserveAutoPageStart(
707
+ branchPageOffsets: Map<number, number>,
708
+ branchOrdinal: number,
709
+ reservedPageIndexes: Set<number>,
710
+ ): number {
711
+ let pageIndex = branchPageOffsets.get(branchOrdinal) ?? 0;
712
+ while (branchOrdinal === 0 && reservedPageIndexes.has(pageIndex)) {
713
+ pageIndex += 1;
714
+ }
715
+ return pageIndex;
716
+ }
717
+
718
+ async function emitPagedAtlasGroup(
719
+ doc: Document,
720
+ pkg: Package,
721
+ allResources: PackageResource[],
722
+ inputs: InputItem[],
723
+ context: {
724
+ branchName: string;
725
+ branchOrdinal: number;
726
+ pageStart: number;
727
+ fileNameAt: (pageIndex: number) => string;
728
+ options: AtlasOptions;
729
+ encoder: AtlasRasterBackend | undefined;
730
+ logger: ILogger;
731
+ forceSinglePage?: boolean;
732
+ },
733
+ ): Promise<number> {
734
+ if (inputs.length === 0) return 0;
735
+ const pages = packAtlasPages(inputs, context.options, context.forceSinglePage === true);
736
+ if (pages.length === 0) return 0;
737
+
738
+ for (let pageOffset = 0; pageOffset < pages.length; pageOffset += 1) {
739
+ const page = pages[pageOffset];
740
+ const pageIndex = context.pageStart + pageOffset;
741
+ const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(context.branchOrdinal, pageIndex)}`);
742
+ atlasNode.setIndex(resolveAtlasIndex(context.branchOrdinal, pageIndex));
743
+ atlasNode.setFile(context.fileNameAt(pageIndex));
744
+ atlasNode.setWidth(page.width);
745
+ atlasNode.setHeight(page.height);
746
+ pkg.addAtlas(atlasNode);
747
+
748
+ attachSpritesToAtlas(doc, allResources, inputs, page.outputRects, atlasNode);
749
+ await writeAtlasPageImage(
750
+ pkg,
751
+ inputs,
752
+ page,
753
+ atlasNode.getFile(),
754
+ context.encoder,
755
+ context.options,
756
+ context.logger,
757
+ );
758
+ }
759
+
760
+ return pages.length;
761
+ }
762
+
763
+ async function emitStandaloneAtlasGroup(
764
+ doc: Document,
765
+ pkg: Package,
766
+ group: StandaloneAtlasGroup,
767
+ context: {
768
+ atlasIndexStart: number;
769
+ options: AtlasOptions;
770
+ encoder: AtlasRasterBackend | undefined;
771
+ logger: ILogger;
772
+ },
773
+ ): Promise<number> {
774
+ if (group.inputs.length === 0) return 0;
775
+ const pages = packAtlasPages(
776
+ group.inputs,
777
+ context.options,
778
+ true,
779
+ group.sizeMode === 'npot'
780
+ ? { powerOfTwo: false, multipleOfFour: false, square: false }
781
+ : group.sizeMode === 'multipleOf4'
782
+ ? { powerOfTwo: false, multipleOfFour: true, square: false }
783
+ : undefined,
784
+ );
785
+ if (pages.length === 0) return 0;
786
+
787
+ for (let pageOffset = 0; pageOffset < pages.length; pageOffset += 1) {
788
+ const page = pages[pageOffset];
789
+ const baseFileName = resolveStandaloneAtlasOutputFileName(pkg, group.resource, group.branchName);
790
+ const atlasFileName = pages.length <= 1 ? baseFileName : insertFileNameSuffix(baseFileName, `_${pageOffset}`);
791
+ const atlasIndex = context.atlasIndexStart + pageOffset;
792
+ const atlasNode = doc.createAtlas(`atlas${resolveAtlasIndex(group.branchOrdinal, atlasIndex)}`);
793
+ atlasNode.setIndex(resolveAtlasIndex(group.branchOrdinal, atlasIndex));
794
+ atlasNode.setFile(atlasFileName);
795
+ const standaloneSize = resolveStandaloneAtlasSize(page.width, page.height, group.sizeMode, context.options);
796
+ atlasNode.setWidth(standaloneSize.width);
797
+ atlasNode.setHeight(standaloneSize.height);
798
+ pkg.addAtlas(atlasNode);
799
+
800
+ attachSpritesToAtlas(doc, [], group.inputs, page.outputRects, atlasNode);
801
+ await writeAtlasPageImage(
802
+ pkg,
803
+ group.inputs,
804
+ { ...page, width: standaloneSize.width, height: standaloneSize.height },
805
+ atlasFileName,
806
+ context.encoder,
807
+ context.options,
808
+ context.logger,
809
+ );
810
+ }
811
+
812
+ return pages.length;
813
+ }
814
+
815
+ function packAtlasPages(
816
+ inputs: InputItem[],
817
+ options: AtlasOptions,
818
+ forceSinglePage: boolean,
819
+ sizeOverrides?: {
820
+ powerOfTwo: boolean;
821
+ multipleOfFour: boolean;
822
+ square: boolean;
823
+ },
824
+ ): ReturnType<MaxRectsPackerCompat['pack']> {
825
+ const hasDuplicatePadding = inputs.some((input) => {
826
+ return isImageResource(input.resource) && input.resource.getDuplicatePadding?.() === true;
827
+ });
828
+ const packer = new MaxRectsPackerCompat({
829
+ pot: sizeOverrides?.powerOfTwo ?? options.powerOfTwo,
830
+ mof: sizeOverrides?.multipleOfFour ?? !options.powerOfTwo,
831
+ padding: options.padding,
832
+ rotation: options.allowRotation,
833
+ minWidth: 16,
834
+ minHeight: 16,
835
+ maxWidth: options.maxSize,
836
+ maxHeight: options.maxSize,
837
+ square: sizeOverrides?.square ?? options.square,
838
+ fast: options.fast,
839
+ edgePadding: false,
840
+ duplicatePadding: hasDuplicatePadding,
841
+ multiPage: forceSinglePage ? false : options.multiPage,
842
+ preserveInputOrderOnTie: options.preserveInputOrderOnTie,
843
+ });
844
+ return packer.pack(inputs.map((input, index) => inputToCompatRect(input, index))) ?? [];
845
+ }
846
+
847
+ function attachSpritesToAtlas(
848
+ doc: Document,
849
+ allResources: PackageResource[],
850
+ inputs: InputItem[],
851
+ outputRects: Array<{ index: number; x: number; y: number; width: number; height: number; rotated: boolean }>,
852
+ atlasNode: ReturnType<Document['createAtlas']>,
853
+ ): void {
854
+ for (const packedRect of outputRects) {
855
+ const input = inputs[packedRect.index];
856
+ if (!input) continue;
857
+ const packedSize = resolvePackedRectSize(input, packedRect.width, packedRect.height, packedRect.rotated);
858
+ const sprite = doc.createSprite();
859
+ sprite.setItemId(input.id);
860
+ sprite.setRectX(packedRect.x);
861
+ sprite.setRectY(packedRect.y);
862
+ sprite.setRectWidth(packedSize.width);
863
+ sprite.setRectHeight(packedSize.height);
864
+ sprite.setRotated(packedRect.rotated);
865
+ sprite.setOffsetX(input.offsetX);
866
+ sprite.setOffsetY(input.offsetY);
867
+ sprite.setOriginalWidth(input.originalWidth);
868
+ sprite.setOriginalHeight(input.originalHeight);
869
+ sprite.setAtlas(atlasNode);
870
+ atlasNode.addSprite(sprite);
871
+ }
872
+
873
+ for (const resource of allResources) {
874
+ if (!isFontResource(resource)) continue;
875
+ const extras = resource.getExtras() as FontResourceExtras;
876
+ const alias = extras?._fontSpriteAlias;
877
+ if (!alias) continue;
878
+ const imageSprite = outputRects.find((result) => inputs[result.index]?.id === alias.textureId);
879
+ if (!imageSprite) continue;
880
+ const imageInput = inputs[imageSprite.index];
881
+ const fontSprite = doc.createSprite();
882
+ fontSprite.setItemId(alias.fontId);
883
+ fontSprite.setRectX(imageSprite.x);
884
+ fontSprite.setRectY(imageSprite.y);
885
+ fontSprite.setRectWidth(imageSprite.width);
886
+ fontSprite.setRectHeight(imageSprite.height);
887
+ fontSprite.setRotated(imageSprite.rotated);
888
+ if (imageInput) {
889
+ fontSprite.setOffsetX(imageInput.offsetX);
890
+ fontSprite.setOffsetY(imageInput.offsetY);
891
+ fontSprite.setOriginalWidth(imageInput.originalWidth);
892
+ fontSprite.setOriginalHeight(imageInput.originalHeight);
893
+ }
894
+ fontSprite.setAtlas(atlasNode);
895
+ atlasNode.addSprite(fontSprite);
896
+ }
897
+ }
898
+
899
+ async function writeAtlasPageImage(
900
+ pkg: Package,
901
+ inputs: InputItem[],
902
+ page: {
903
+ width: number;
904
+ height: number;
905
+ outputRects: Array<{ index: number; x: number; y: number; width: number; height: number; rotated: boolean }>;
906
+ },
907
+ atlasFileName: string,
908
+ encoder: AtlasRasterBackend | undefined,
909
+ options: AtlasOptions,
910
+ logger: ILogger,
911
+ ): Promise<void> {
912
+ if (!encoder || !options.outputPath) return;
913
+ if (options.mkdir) {
914
+ await options.mkdir(options.outputPath);
915
+ }
916
+
917
+ const compositeInputs: Array<{ input: Uint8Array; left: number; top: number }> = [];
918
+ for (const packedRect of page.outputRects) {
919
+ const input = inputs[packedRect.index];
920
+ if (!input) continue;
921
+ if (packedRect.width <= 0 || packedRect.height <= 0 || input.width <= 0 || input.height <= 0) continue;
922
+ try {
923
+ let imageBuffer: Uint8Array;
924
+ if (input.trimBuffer) {
925
+ imageBuffer = input.trimBuffer;
926
+ if (imageBuffer.length === 0) continue;
927
+ } else if (input.rasterizedBuffer) {
928
+ imageBuffer = input.rasterizedBuffer;
929
+ } else {
930
+ if (!isImageResource(input.resource)) {
931
+ logger.warn(`atlas: Non-image input "${input.id}" is missing inline buffer, skipping compositing.`);
932
+ continue;
933
+ }
934
+ const filePath = _resolveImagePath(input.resource, pkg, options.basePath!);
935
+ imageBuffer = await encoder(filePath).toBuffer();
936
+ }
937
+ if (packedRect.rotated) imageBuffer = await encoder(imageBuffer).rotate(270).toBuffer();
938
+ compositeInputs.push({
939
+ input: imageBuffer,
940
+ left: packedRect.x,
941
+ top: packedRect.y,
942
+ });
943
+ } catch {
944
+ logger.warn(`atlas: Could not read image "${input.id}" for compositing.`);
945
+ }
946
+ }
947
+
948
+ const outputFile = `${options.outputPath}/${atlasFileName}`;
949
+ await encoder({
950
+ create: {
951
+ width: page.width,
952
+ height: page.height,
953
+ channels: 4 as const,
954
+ background: { r: 0, g: 0, b: 0, alpha: 0 },
955
+ },
956
+ })
957
+ .composite(compositeInputs)
958
+ .toFile(outputFile);
959
+
960
+ logger.info(`atlas: Generated ${atlasFileName} (${page.width}x${page.height}, ${page.outputRects.length} sprites)`);
961
+ }
962
+
750
963
  function inputToCompatRect(input: InputItem, index: number): CompatNodeRect {
751
964
  const duplicatePadding = isImageResource(input.resource) && input.resource.getDuplicatePadding?.() === true;
752
965
  return {
@@ -764,7 +977,12 @@ function inputToCompatRect(input: InputItem, index: number): CompatNodeRect {
764
977
  };
765
978
  }
766
979
 
767
- function resolvePackedRectSize(input: InputItem, width: number, height: number, rectRotated: boolean): { width: number; height: number } {
980
+ function resolvePackedRectSize(
981
+ input: InputItem,
982
+ width: number,
983
+ height: number,
984
+ rectRotated: boolean,
985
+ ): { width: number; height: number } {
768
986
  if (!rectRotated) return { width, height };
769
987
  return {
770
988
  width: input.height,
@@ -784,7 +1002,11 @@ function resolveDirectImageOutput(inputs: InputItem[], options: AtlasOptions): I
784
1002
  return input;
785
1003
  }
786
1004
 
787
- function resolveDirectOutputAtlasSize(width: number, height: number, options: AtlasOptions): { width: number; height: number } {
1005
+ function resolveDirectOutputAtlasSize(
1006
+ width: number,
1007
+ height: number,
1008
+ options: AtlasOptions,
1009
+ ): { width: number; height: number } {
788
1010
  let resolvedWidth = width;
789
1011
  let resolvedHeight = height;
790
1012
  if (options.square) {
@@ -803,7 +1025,7 @@ async function emitDirectImageOutput(
803
1025
  doc: Document,
804
1026
  pkg: Package,
805
1027
  input: InputItem,
806
- encoder: AtlasEncoder | undefined,
1028
+ encoder: AtlasRasterBackend | undefined,
807
1029
  options: AtlasOptions,
808
1030
  logger: ILogger,
809
1031
  branchName: string = '',
@@ -876,16 +1098,63 @@ function resolveAtlasOutputFileName(pkg: Package, pageIndex: number, branchName:
876
1098
  return `${pkg.getPublishName() || pkg.getName()}_atlas${pageIndex}${suffix}.png`;
877
1099
  }
878
1100
 
1101
+ function resolveStandaloneAtlasOutputFileName(pkg: Package, resource: PackInputResource, branchName: string): string {
1102
+ const baseName = `${pkg.getPublishName() || pkg.getName()}_atlas_${getPublishedItemId(resource)}`;
1103
+ const suffix = branchName ? `_${branchName}` : '';
1104
+ if (isImageResource(resource)) {
1105
+ const ext = extname(resolveImageFileName(resource)) || '.png';
1106
+ return `${baseName}${suffix}${ext}`;
1107
+ }
1108
+ return `${baseName}${suffix}.png`;
1109
+ }
1110
+
1111
+ function resolveStandaloneAtlasSize(
1112
+ width: number,
1113
+ height: number,
1114
+ sizeMode: StandaloneAtlasGroup['sizeMode'],
1115
+ options: AtlasOptions,
1116
+ ): { width: number; height: number } {
1117
+ if (sizeMode === 'npot') {
1118
+ return { width, height };
1119
+ }
1120
+ if (sizeMode === 'multipleOf4') {
1121
+ return {
1122
+ width: roundUpToMultiple(width, 4),
1123
+ height: roundUpToMultiple(height, 4),
1124
+ };
1125
+ }
1126
+ return resolveDirectOutputAtlasSize(width, height, options);
1127
+ }
1128
+
879
1129
  function resolveImageFileName(resource: ImageResource): string {
880
1130
  const extras = resource.getExtras() as ImageResourceExtras;
881
1131
  return resource.getFileName() || extras._fileName || resource.getName();
882
1132
  }
883
1133
 
1134
+ function extname(fileName: string): string {
1135
+ const normalized = fileName.replace(/\\/g, '/');
1136
+ const lastSlash = normalized.lastIndexOf('/');
1137
+ const lastDot = normalized.lastIndexOf('.');
1138
+ if (lastDot <= lastSlash) return '';
1139
+ return normalized.slice(lastDot);
1140
+ }
1141
+
1142
+ function insertFileNameSuffix(fileName: string, suffix: string): string {
1143
+ const extension = extname(fileName);
1144
+ if (!extension) return `${fileName}${suffix}`;
1145
+ return `${fileName.slice(0, -extension.length)}${suffix}${extension}`;
1146
+ }
1147
+
884
1148
  function nextPow2(value: number): number {
885
1149
  if (value <= 1) return 1;
886
1150
  return 2 ** Math.ceil(Math.log2(value));
887
1151
  }
888
1152
 
1153
+ function roundUpToMultiple(value: number, base: number): number {
1154
+ if (value <= 0) return 0;
1155
+ return Math.ceil(value / base) * base;
1156
+ }
1157
+
889
1158
  function sortResourcesByOrder(
890
1159
  resources: PackageResource[],
891
1160
  orderMap: Map<string, number>,
@@ -895,11 +1164,23 @@ function sortResourcesByOrder(
895
1164
  ordered.sort((left, right) => {
896
1165
  const leftId = left.getId();
897
1166
  const rightId = right.getId();
898
- const leftOrder = leftId && orderMap.has(leftId) ? (orderMap.get(leftId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
899
- const rightOrder = rightId && orderMap.has(rightId) ? (orderMap.get(rightId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
1167
+ const leftOrder =
1168
+ leftId && orderMap.has(leftId)
1169
+ ? (orderMap.get(leftId) ?? Number.MAX_SAFE_INTEGER)
1170
+ : Number.MAX_SAFE_INTEGER;
1171
+ const rightOrder =
1172
+ rightId && orderMap.has(rightId)
1173
+ ? (orderMap.get(rightId) ?? Number.MAX_SAFE_INTEGER)
1174
+ : Number.MAX_SAFE_INTEGER;
900
1175
  if (leftOrder !== rightOrder) return leftOrder - rightOrder;
901
- const leftInputOrder = leftId && inputOrderMap.has(leftId) ? (inputOrderMap.get(leftId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
902
- const rightInputOrder = rightId && inputOrderMap.has(rightId) ? (inputOrderMap.get(rightId) ?? Number.MAX_SAFE_INTEGER) : Number.MAX_SAFE_INTEGER;
1176
+ const leftInputOrder =
1177
+ leftId && inputOrderMap.has(leftId)
1178
+ ? (inputOrderMap.get(leftId) ?? Number.MAX_SAFE_INTEGER)
1179
+ : Number.MAX_SAFE_INTEGER;
1180
+ const rightInputOrder =
1181
+ rightId && inputOrderMap.has(rightId)
1182
+ ? (inputOrderMap.get(rightId) ?? Number.MAX_SAFE_INTEGER)
1183
+ : Number.MAX_SAFE_INTEGER;
903
1184
  if (leftInputOrder !== rightInputOrder) return leftInputOrder - rightInputOrder;
904
1185
  return (leftId ?? '').localeCompare(rightId ?? '');
905
1186
  });
@@ -929,22 +1210,117 @@ interface ExtractedJtaData {
929
1210
  meta?: ExtractedJtaMeta;
930
1211
  }
931
1212
 
1213
+ function getResourceTextureSetMode(resource: PackInputResource): TextureSetMode {
1214
+ if (isImageResource(resource)) {
1215
+ return parseTextureSetMode(resource.getTextureSetMode?.());
1216
+ }
1217
+ return parseTextureSetMode(resource.getTextureSetMode?.());
1218
+ }
1219
+
1220
+ function groupStandaloneInputs(
1221
+ doc: Document,
1222
+ inputs: InputItem[],
1223
+ options: AtlasOptions,
1224
+ ): {
1225
+ autoInputs: InputItem[];
1226
+ fixedPageGroups: PagedAtlasGroup[];
1227
+ standaloneGroups: StandaloneAtlasGroup[];
1228
+ reservedPageIndexes: Set<number>;
1229
+ } {
1230
+ const autoInputs: InputItem[] = [];
1231
+ const fixedInputsByPage = new Map<string, PagedAtlasGroup>();
1232
+ const standaloneGroups = new Map<string, StandaloneAtlasGroup>();
1233
+ const reservedPageIndexes = new Set<number>();
1234
+ const discoveredBranchNames = [
1235
+ ...new Set(inputs.map((input) => getInputBranchName(input)).filter((branchName) => !!branchName)),
1236
+ ];
1237
+ const orderedBranchNames = doc
1238
+ .getRoot()
1239
+ .listBranches()
1240
+ .filter((branchName) => discoveredBranchNames.includes(branchName));
1241
+ for (const branchName of discoveredBranchNames) {
1242
+ if (!orderedBranchNames.includes(branchName)) orderedBranchNames.push(branchName);
1243
+ }
1244
+ const branchOrdinalByName = new Map<string, number>();
1245
+ branchOrdinalByName.set('', 0);
1246
+ if (options.separatedAtlasForBranch) {
1247
+ let ordinal = 1;
1248
+ for (const branchName of orderedBranchNames) {
1249
+ branchOrdinalByName.set(branchName, ordinal++);
1250
+ }
1251
+ } else {
1252
+ for (const branchName of orderedBranchNames) {
1253
+ branchOrdinalByName.set(branchName, 0);
1254
+ }
1255
+ }
1256
+
1257
+ for (const input of inputs) {
1258
+ const branchName = getInputBranchName(input);
1259
+ const branchOrdinal = branchOrdinalByName.get(branchName) ?? 0;
1260
+ const mode = getResourceTextureSetMode(input.resource);
1261
+ if (mode.kind === 'standalone') {
1262
+ const resourceId = getPublishedItemId(input.resource);
1263
+ const key = `${branchName}\u0000${resourceId}`;
1264
+ const existing = standaloneGroups.get(key);
1265
+ if (existing) {
1266
+ existing.inputs.push(input);
1267
+ } else {
1268
+ standaloneGroups.set(key, {
1269
+ resource: input.resource,
1270
+ branchName,
1271
+ branchOrdinal,
1272
+ sizeMode: mode.sizeMode,
1273
+ inputs: [input],
1274
+ });
1275
+ }
1276
+ continue;
1277
+ }
1278
+ if (mode.kind === 'page') {
1279
+ reservedPageIndexes.add(mode.pageIndex);
1280
+ const key = `${branchName}\u0000${mode.pageIndex}`;
1281
+ const existing = fixedInputsByPage.get(key);
1282
+ if (existing) {
1283
+ existing.inputs.push(input);
1284
+ } else {
1285
+ fixedInputsByPage.set(key, {
1286
+ pageIndex: mode.pageIndex,
1287
+ branchName,
1288
+ branchOrdinal,
1289
+ inputs: [input],
1290
+ });
1291
+ }
1292
+ continue;
1293
+ }
1294
+ autoInputs.push(input);
1295
+ }
1296
+
1297
+ return {
1298
+ autoInputs,
1299
+ fixedPageGroups: [...fixedInputsByPage.values()].sort(
1300
+ (left, right) => left.branchOrdinal - right.branchOrdinal || left.pageIndex - right.pageIndex,
1301
+ ),
1302
+ standaloneGroups: [...standaloneGroups.values()].sort(
1303
+ (left, right) =>
1304
+ left.branchOrdinal - right.branchOrdinal ||
1305
+ getPublishedItemId(left.resource).localeCompare(getPublishedItemId(right.resource)),
1306
+ ),
1307
+ reservedPageIndexes,
1308
+ };
1309
+ }
1310
+
932
1311
  /**
933
- * Trim transparent edges from an image using sharp.
1312
+ * Trim transparent edges from an image using the host raster backend.
934
1313
  * Returns the trimmed buffer, dimensions, and offsets.
935
1314
  * Falls back to the original image if trim fails (e.g. no alpha channel, no transparent edges).
936
1315
  */
937
1316
  async function _trimImage(
938
- encoder: AtlasEncoder,
939
- filePath: string,
1317
+ encoder: AtlasRasterBackend,
1318
+ input: AtlasRasterInput,
940
1319
  originalWidth: number,
941
1320
  originalHeight: number,
942
1321
  ): Promise<TrimInfo> {
943
1322
  try {
944
- const trimResult = await encoder(filePath)
945
- .ensureAlpha()
946
- .raw()
947
- .toBuffer({ resolveWithObject: true });
1323
+ const trimResult = await encoder(input).ensureAlpha().raw().toBuffer({ resolveWithObject: true });
948
1324
  if (!isResolvedBuffer(trimResult)) {
949
1325
  throw new Error('atlas: encoder raw alpha trim did not return resolved metadata.');
950
1326
  }
@@ -982,7 +1358,7 @@ async function _trimImage(
982
1358
 
983
1359
  const trimmedWidth = maxX - minX + 1;
984
1360
  const trimmedHeight = maxY - minY + 1;
985
- const buffer = await encoder(filePath)
1361
+ const buffer = await encoder(input)
986
1362
  .extract({
987
1363
  left: minX,
988
1364
  top: minY,
@@ -1002,7 +1378,7 @@ async function _trimImage(
1002
1378
  };
1003
1379
  } catch {
1004
1380
  // Trim failed (e.g. JPEG without alpha, nothing to trim) — return original
1005
- const buf = await encoder(filePath).png().toBuffer();
1381
+ const buf = await encoder(input).png().toBuffer();
1006
1382
  return {
1007
1383
  buffer: buf,
1008
1384
  width: originalWidth,
@@ -1032,26 +1408,50 @@ function _resolveImagePath(resource: ImageResource, pkg: Package, basePath: stri
1032
1408
  }
1033
1409
 
1034
1410
  type InputItem = {
1035
- id: string; width: number; height: number;
1036
- originalWidth: number; originalHeight: number;
1037
- offsetX: number; offsetY: number;
1038
- resource: PackInputResource; trimBuffer?: Uint8Array;
1411
+ id: string;
1412
+ width: number;
1413
+ height: number;
1414
+ originalWidth: number;
1415
+ originalHeight: number;
1416
+ offsetX: number;
1417
+ offsetY: number;
1418
+ resource: PackInputResource;
1419
+ trimBuffer?: Uint8Array;
1420
+ rasterizedBuffer?: Uint8Array;
1039
1421
  sourceKind: 'image' | 'movieclip-frame';
1040
1422
  };
1041
1423
 
1424
+ interface StandaloneAtlasGroup {
1425
+ resource: PackInputResource;
1426
+ branchName: string;
1427
+ branchOrdinal: number;
1428
+ sizeMode: 'default' | 'npot' | 'multipleOf4';
1429
+ inputs: InputItem[];
1430
+ }
1431
+
1432
+ interface PagedAtlasGroup {
1433
+ pageIndex: number;
1434
+ branchName: string;
1435
+ branchOrdinal: number;
1436
+ inputs: InputItem[];
1437
+ }
1438
+
1042
1439
  /** Collect a single ImageResource into the inputs array. */
1043
1440
  async function _collectImage(
1044
1441
  resource: ImageResource,
1045
1442
  pkg: Package,
1046
1443
  inputs: InputItem[],
1047
- encoder: AtlasEncoder | undefined,
1444
+ encoder: AtlasRasterBackend | undefined,
1048
1445
  options: AtlasOptions,
1049
1446
  doTrim: boolean,
1050
1447
  logger: ILogger,
1051
1448
  ): Promise<void> {
1052
1449
  let origW = resource.getWidth() ?? 0;
1053
1450
  let origH = resource.getHeight() ?? 0;
1451
+ const declaredWidth = origW;
1452
+ const declaredHeight = origH;
1054
1453
  let sourceHasAlpha = false;
1454
+ let rasterizedBuffer: Uint8Array | undefined;
1055
1455
 
1056
1456
  if (encoder && options.basePath) {
1057
1457
  const filePath = _resolveImagePath(resource, pkg, options.basePath);
@@ -1064,6 +1464,13 @@ async function _collectImage(
1064
1464
  resource.setHeight(origH);
1065
1465
  }
1066
1466
  sourceHasAlpha = metadata.hasAlpha === true || metadata.channels === 4;
1467
+ if (/\.svg$/i.test(resolveImageFileName(resource)) && declaredWidth > 0 && declaredHeight > 0) {
1468
+ rasterizedBuffer = await encoder(filePath)
1469
+ .resize({ width: declaredWidth, height: declaredHeight, fit: 'fill' })
1470
+ .png()
1471
+ .toBuffer();
1472
+ sourceHasAlpha = true;
1473
+ }
1067
1474
  } catch {
1068
1475
  if (origW === 0 || origH === 0) {
1069
1476
  logger.warn(`atlas: Could not read image "${filePath}", skipping.`);
@@ -1074,13 +1481,16 @@ async function _collectImage(
1074
1481
 
1075
1482
  if (origW <= 0 || origH <= 0) return;
1076
1483
 
1077
- let packW = origW, packH = origH, offX = 0, offY = 0;
1484
+ let packW = origW,
1485
+ packH = origH,
1486
+ offX = 0,
1487
+ offY = 0;
1078
1488
  let trimBuf: Uint8Array | undefined;
1079
1489
 
1080
1490
  if (doTrim && sourceHasAlpha && options.basePath && encoder) {
1081
1491
  const filePath = _resolveImagePath(resource, pkg, options.basePath);
1082
1492
  try {
1083
- const trimResult = await _trimImage(encoder, filePath, origW, origH);
1493
+ const trimResult = await _trimImage(encoder, rasterizedBuffer ?? filePath, origW, origH);
1084
1494
  packW = trimResult.width;
1085
1495
  packH = trimResult.height;
1086
1496
  offX = trimResult.offsetX;
@@ -1092,11 +1502,16 @@ async function _collectImage(
1092
1502
  }
1093
1503
 
1094
1504
  inputs.push({
1095
- id: getPublishedItemId(resource), width: packW, height: packH,
1096
- originalWidth: origW, originalHeight: origH,
1097
- offsetX: offX, offsetY: offY,
1505
+ id: getPublishedItemId(resource),
1506
+ width: packW,
1507
+ height: packH,
1508
+ originalWidth: origW,
1509
+ originalHeight: origH,
1510
+ offsetX: offX,
1511
+ offsetY: offY,
1098
1512
  resource,
1099
1513
  trimBuffer: trimBuf,
1514
+ rasterizedBuffer,
1100
1515
  sourceKind: 'image',
1101
1516
  });
1102
1517
  }
@@ -1107,7 +1522,7 @@ async function _collectMovieClipFrames(
1107
1522
  resource: MovieClipResource,
1108
1523
  pkg: Package,
1109
1524
  inputs: InputItem[],
1110
- encoder: AtlasEncoder | undefined,
1525
+ encoder: AtlasRasterBackend | undefined,
1111
1526
  options: AtlasOptions,
1112
1527
  logger: ILogger,
1113
1528
  ): Promise<void> {
@@ -1197,7 +1612,7 @@ async function _createMovieClipFrameInput(
1197
1612
  buffer: Uint8Array,
1198
1613
  itemId: string,
1199
1614
  resource: MovieClipResource,
1200
- encoder: AtlasEncoder | undefined,
1615
+ encoder: AtlasRasterBackend | undefined,
1201
1616
  ): Promise<InputItem | null> {
1202
1617
  if (!encoder || buffer.length === 0) return null;
1203
1618
  try {
@@ -1270,10 +1685,7 @@ function _findPngEnd(data: Uint8Array, start: number): number {
1270
1685
  pos += 8;
1271
1686
  if (pos + length + 4 > data.length) return -1;
1272
1687
  const isIEND =
1273
- data[pos - 4] === 0x49 &&
1274
- data[pos - 3] === 0x45 &&
1275
- data[pos - 2] === 0x4e &&
1276
- data[pos - 1] === 0x44;
1688
+ data[pos - 4] === 0x49 && data[pos - 3] === 0x45 && data[pos - 2] === 0x4e && data[pos - 1] === 0x44;
1277
1689
  pos += length + 4;
1278
1690
  if (isIEND) return pos;
1279
1691
  }
@@ -1404,7 +1816,7 @@ function _readUint16BE(data: Uint8Array, offset: number): number {
1404
1816
  function _readUint32BE(data: Uint8Array, offset: number): number {
1405
1817
  if (offset + 3 >= data.length) return 0;
1406
1818
  return (
1407
- (data[offset] * 0x1000000) +
1819
+ data[offset] * 0x1000000 +
1408
1820
  ((data[offset + 1] ?? 0) << 16) +
1409
1821
  ((data[offset + 2] ?? 0) << 8) +
1410
1822
  (data[offset + 3] ?? 0)
@@ -1467,27 +1879,53 @@ async function _collectFontTexture(
1467
1879
  .setChannel(item.channel);
1468
1880
  fontRes.addGlyph(glyph);
1469
1881
  }
1470
- } catch { /* .fnt not found */ }
1882
+ } catch {
1883
+ /* .fnt not found */
1884
+ }
1471
1885
  }
1472
1886
  }
1473
1887
 
1474
1888
  /** Parse a BMFont .fnt text file into structured data for binary encoding. */
1475
1889
  function _parseFnt(text: string): {
1476
- hasFace: boolean; colored: boolean; resizable: boolean; hasChannel: boolean;
1477
- fontSize: number; xadvance: number; lineHeight: number;
1890
+ hasFace: boolean;
1891
+ colored: boolean;
1892
+ resizable: boolean;
1893
+ hasChannel: boolean;
1894
+ fontSize: number;
1895
+ xadvance: number;
1896
+ lineHeight: number;
1478
1897
  glyphs: Array<{
1479
- charId: number; img: string | null;
1480
- x: number; y: number; xoffset: number; yoffset: number;
1481
- width: number; height: number; xadvance: number; channel: number;
1898
+ charId: number;
1899
+ img: string | null;
1900
+ x: number;
1901
+ y: number;
1902
+ xoffset: number;
1903
+ yoffset: number;
1904
+ width: number;
1905
+ height: number;
1906
+ xadvance: number;
1907
+ channel: number;
1482
1908
  }>;
1483
1909
  } {
1484
1910
  const lines = text.split(/\r?\n/);
1485
- let hasFace = false, colored = false, resizable = false, hasChannel = false;
1486
- let fontSize = 0, globalXadvance = 0, lineHeight = 0;
1911
+ let hasFace = false,
1912
+ colored = false,
1913
+ resizable = false,
1914
+ hasChannel = false;
1915
+ let fontSize = 0,
1916
+ globalXadvance = 0,
1917
+ lineHeight = 0;
1487
1918
  const glyphs: Array<{
1488
- charId: number; img: string | null;
1489
- x: number; y: number; xoffset: number; yoffset: number;
1490
- width: number; height: number; xadvance: number; channel: number;
1919
+ charId: number;
1920
+ img: string | null;
1921
+ x: number;
1922
+ y: number;
1923
+ xoffset: number;
1924
+ yoffset: number;
1925
+ width: number;
1926
+ height: number;
1927
+ xadvance: number;
1928
+ channel: number;
1491
1929
  }> = [];
1492
1930
 
1493
1931
  for (const line of lines) {
@@ -1522,7 +1960,8 @@ function _parseFnt(text: string): {
1522
1960
  const chnl = parseInt(attrs.chnl, 10) || 0;
1523
1961
  if (chnl !== 0 && chnl !== 15) hasChannel = true;
1524
1962
  glyphs.push({
1525
- charId, img,
1963
+ charId,
1964
+ img,
1526
1965
  x: parseInt(attrs.x, 10) || 0,
1527
1966
  y: parseInt(attrs.y, 10) || 0,
1528
1967
  xoffset: parseInt(attrs.xoffset, 10) || 0,
@@ -1537,7 +1976,16 @@ function _parseFnt(text: string): {
1537
1976
  }
1538
1977
  }
1539
1978
 
1540
- return { hasFace, colored, resizable: fontSize > 0 ? resizable : false, hasChannel, fontSize, xadvance: globalXadvance, lineHeight, glyphs };
1979
+ return {
1980
+ hasFace,
1981
+ colored,
1982
+ resizable: fontSize > 0 ? resizable : false,
1983
+ hasChannel,
1984
+ fontSize,
1985
+ xadvance: globalXadvance,
1986
+ lineHeight,
1987
+ glyphs,
1988
+ };
1541
1989
  }
1542
1990
 
1543
1991
  function isComponentResource(resource: PackageResource): resource is Component {
@@ -1590,6 +2038,6 @@ function addUiResourceRefsFromUnknown(target: Set<string>, value: unknown): void
1590
2038
  }
1591
2039
  }
1592
2040
 
1593
- function isResolvedBuffer(value: Uint8Array | AtlasEncoderResolvedBuffer): value is AtlasEncoderResolvedBuffer {
2041
+ function isResolvedBuffer(value: Uint8Array | AtlasRasterResolvedBuffer): value is AtlasRasterResolvedBuffer {
1594
2042
  return typeof value === 'object' && value !== null && 'data' in value && 'info' in value;
1595
2043
  }