@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.
@@ -0,0 +1,412 @@
1
+ import { COMPAT_NODE_RECT_FLAGS, MAX_RECTS_METHOD, MaxRectsCompat, type CompatNodeRect, type CompatPage } from './max-rects-compat.js';
2
+
3
+ interface MaxRectsPackerCompatSettings {
4
+ pot?: boolean;
5
+ mof?: boolean;
6
+ padding?: number;
7
+ rotation?: boolean;
8
+ minWidth?: number;
9
+ minHeight?: number;
10
+ maxWidth?: number;
11
+ maxHeight?: number;
12
+ square?: boolean;
13
+ fast?: boolean;
14
+ edgePadding?: boolean;
15
+ duplicatePadding?: boolean;
16
+ multiPage?: boolean;
17
+ preserveInputOrderOnTie?: boolean;
18
+ }
19
+
20
+ const DEFAULT_SETTINGS: Required<MaxRectsPackerCompatSettings> = {
21
+ pot: true,
22
+ mof: true,
23
+ padding: 2,
24
+ rotation: false,
25
+ minWidth: 16,
26
+ minHeight: 16,
27
+ maxWidth: 2048,
28
+ maxHeight: 2048,
29
+ square: false,
30
+ fast: true,
31
+ edgePadding: false,
32
+ duplicatePadding: false,
33
+ multiPage: false,
34
+ preserveInputOrderOnTie: false,
35
+ };
36
+
37
+ let sizeScheme: Array<{ width: number; height: number; area: number; aspectRatio: number; len: number }> | null = null;
38
+
39
+ class BinarySearchCompat {
40
+ private readonly min: number;
41
+ private readonly max: number;
42
+ private readonly fuzziness: number;
43
+ private low: number;
44
+ private high: number;
45
+ private current: number;
46
+
47
+ public constructor(min: number, max: number, fuzziness: number, private readonly pot: boolean, private readonly mof: boolean) {
48
+ this.fuzziness = pot ? 0 : fuzziness;
49
+ if (pot) {
50
+ this.min = Math.log(MaxRectsPackerCompat.getNextPowerOfTwo(min)) / Math.log(2);
51
+ this.max = Math.log(MaxRectsPackerCompat.getNextPowerOfTwo(max)) / Math.log(2);
52
+ } else if (mof) {
53
+ this.min = min / 4;
54
+ this.max = max / 4;
55
+ } else {
56
+ this.min = min;
57
+ this.max = max;
58
+ }
59
+ this.low = this.min;
60
+ this.high = this.max;
61
+ this.current = this.min;
62
+ }
63
+
64
+ public reset(): number {
65
+ this.low = this.min;
66
+ this.high = this.max;
67
+ this.current = (this.low + this.high) >>> 1;
68
+ return this.getCurrent();
69
+ }
70
+
71
+ public next(failed: boolean): number {
72
+ if (this.low >= this.high) return -1;
73
+ if (failed) this.low = this.current + 1;
74
+ else this.high = this.current - 1;
75
+ this.current = (this.low + this.high) >>> 1;
76
+ if (Math.abs(this.low - this.high) < this.fuzziness) return -1;
77
+ return this.getCurrent();
78
+ }
79
+
80
+ private getCurrent(): number {
81
+ if (this.pot) return Math.trunc(Math.pow(2, this.current));
82
+ if (this.mof) return this.current * 4;
83
+ return this.current;
84
+ }
85
+ }
86
+
87
+ export class MaxRectsPackerCompat {
88
+ private readonly maxRects = new MaxRectsCompat();
89
+ private readonly settings: Required<MaxRectsPackerCompatSettings>;
90
+
91
+ public constructor(settings: MaxRectsPackerCompatSettings = {}) {
92
+ this.settings = { ...DEFAULT_SETTINGS, ...settings };
93
+ }
94
+
95
+ public static getNextPowerOfTwo(value: number): number {
96
+ if (Number.isInteger(value) && value > 0 && (value & (value - 1)) === 0) return value;
97
+ let result = 1;
98
+ const target = value - 1e-9;
99
+ while (result < target) result <<= 1;
100
+ return result;
101
+ }
102
+
103
+ public pack(inputRects: CompatNodeRect[]): CompatPage[] | null {
104
+ const rects = inputRects.map(cloneCompatRect);
105
+ if (this.settings.fast) {
106
+ const compare = this.settings.preserveInputOrderOnTie
107
+ ? (this.settings.rotation ? compareNodeRectStable : compareNodeRect2Stable)
108
+ : (this.settings.rotation ? compareNodeRect : compareNodeRect2);
109
+ vectorSortCompat(rects, compare);
110
+ }
111
+
112
+ const padding = this.settings.padding;
113
+ let hasDuplicatePadding = false;
114
+ for (const rect of rects) {
115
+ if (duplicatePadding(rect)) hasDuplicatePadding = true;
116
+ if (this.settings.maxWidth - rect.width > padding || duplicatePadding(rect)) rect.width += padding;
117
+ if (this.settings.maxHeight - rect.height > padding || duplicatePadding(rect)) rect.height += padding;
118
+ }
119
+
120
+ const pages: CompatPage[] = [];
121
+ let remaining = rects;
122
+ while (remaining.length > 0) {
123
+ const page = this.packPage(remaining);
124
+ if (!page) return null;
125
+ if (this.settings.pot) {
126
+ page.width = MaxRectsPackerCompat.getNextPowerOfTwo(page.width);
127
+ page.height = MaxRectsPackerCompat.getNextPowerOfTwo(page.height);
128
+ } else if (this.settings.mof) {
129
+ page.width = Math.ceil(page.width / 4) * 4;
130
+ page.height = Math.ceil(page.height / 4) * 4;
131
+ }
132
+ if (this.settings.square) {
133
+ const side = Math.max(page.width, page.height);
134
+ page.width = side;
135
+ page.height = side;
136
+ }
137
+ pages.push(page);
138
+ remaining = page.remainingRects.map(cloneCompatRect);
139
+ }
140
+
141
+ pages.sort(comparePage);
142
+ for (const page of pages) {
143
+ for (const rect of page.outputRects) {
144
+ shrinkRectForPadding(rect, padding, this.settings.maxWidth, this.settings.maxHeight);
145
+ if (hasDuplicatePadding) {
146
+ if (rect.width !== page.width) rect.x += Math.floor(padding / 2);
147
+ if (rect.height !== page.height) rect.y += Math.floor(padding / 2);
148
+ }
149
+ }
150
+ for (const rect of page.remainingRects) {
151
+ shrinkRectForPadding(rect, padding, this.settings.maxWidth, this.settings.maxHeight);
152
+ }
153
+ }
154
+
155
+ return pages;
156
+ }
157
+
158
+ private packPage(rects: CompatNodeRect[]): CompatPage | null {
159
+ if (!sizeScheme) sizeScheme = initSizeScheme();
160
+ const edgePadding = this.settings.edgePadding ? this.settings.padding : 0;
161
+ let totalArea = 0;
162
+ for (const rect of rects) totalArea += rect.width * rect.height;
163
+
164
+ const candidates = sizeScheme.filter((entry) =>
165
+ entry.area >= totalArea &&
166
+ entry.width <= this.settings.maxWidth &&
167
+ entry.height <= this.settings.maxHeight,
168
+ );
169
+ if (candidates.length === 0) {
170
+ candidates.push({ width: this.settings.maxWidth, height: this.settings.maxHeight, area: 0, aspectRatio: 0, len: 0 });
171
+ }
172
+
173
+ let page: CompatPage | null = null;
174
+ let selectedWidth = 0;
175
+ let selectedHeight = 0;
176
+ for (let index = 0; index < candidates.length; index += 1) {
177
+ selectedWidth = candidates[index].width;
178
+ selectedHeight = candidates[index].height;
179
+ page = this.packAtSize(index !== candidates.length - 1, selectedWidth - edgePadding, selectedHeight - edgePadding, rects);
180
+ if (page) break;
181
+ }
182
+
183
+ if (page && !this.settings.pot && page.remainingRects.length === 0) {
184
+ let bestRefined: CompatPage | null = null;
185
+ if (this.settings.square) {
186
+ const min = Math.min(selectedWidth / 2, selectedHeight / 2);
187
+ const max = Math.max(selectedWidth, selectedHeight);
188
+ const search = new BinarySearchCompat(min, max, this.settings.fast ? 25 : 15, this.settings.pot, this.settings.mof);
189
+ let current = search.reset();
190
+ while (current !== -1) {
191
+ const refined = this.packAtSize(true, current - edgePadding, current - edgePadding, rects);
192
+ bestRefined = getBestPage(bestRefined, refined);
193
+ current = search.next(refined == null);
194
+ }
195
+ } else {
196
+ const widthSearch = new BinarySearchCompat(selectedWidth / 2, selectedWidth, this.settings.fast ? 25 : 15, this.settings.pot, this.settings.mof);
197
+ const heightSearch = new BinarySearchCompat(selectedHeight / 2, selectedHeight, this.settings.fast ? 25 : 15, this.settings.pot, this.settings.mof);
198
+ let currentHeight = heightSearch.reset();
199
+ let currentWidth = widthSearch.reset();
200
+ while (true) {
201
+ let bestForHeight: CompatPage | null = null;
202
+ while (currentWidth !== -1) {
203
+ const refined = this.packAtSize(true, currentWidth - edgePadding, currentHeight - edgePadding, rects);
204
+ bestForHeight = getBestPage(bestForHeight, refined);
205
+ currentWidth = widthSearch.next(refined == null);
206
+ }
207
+ bestRefined = getBestPage(bestRefined, bestForHeight);
208
+ currentHeight = heightSearch.next(bestForHeight == null);
209
+ if (currentHeight === -1) break;
210
+ currentWidth = widthSearch.reset();
211
+ }
212
+ }
213
+ if (bestRefined) page = bestRefined;
214
+ }
215
+
216
+ return page;
217
+ }
218
+
219
+ private packAtSize(requireFullFit: boolean, width: number, height: number, rects: CompatNodeRect[]): CompatPage | null {
220
+ const methods = [MAX_RECTS_METHOD.BestShortSideFit, MAX_RECTS_METHOD.BestLongSideFit, MAX_RECTS_METHOD.BestAreaFit];
221
+ let best: CompatPage | null = null;
222
+ for (const method of methods) {
223
+ this.maxRects.init(width, height, this.settings.rotation);
224
+ let page: CompatPage;
225
+ if (!this.settings.fast) {
226
+ page = this.maxRects.pack(rects, method);
227
+ } else {
228
+ const remaining: CompatNodeRect[] = [];
229
+ let index = 0;
230
+ while (index < rects.length) {
231
+ if (this.maxRects.insert(rects[index], method) == null) {
232
+ while (index < rects.length) {
233
+ remaining.push(cloneCompatRect(rects[index]));
234
+ index += 1;
235
+ }
236
+ break;
237
+ }
238
+ index += 1;
239
+ }
240
+ page = this.maxRects.getResult();
241
+ page.remainingRects = remaining;
242
+ }
243
+ if (!(requireFullFit && page.remainingRects.length > 0) && page.outputRects.length !== 0) {
244
+ best = getBestPage(best, page);
245
+ }
246
+ }
247
+ return best;
248
+ }
249
+ }
250
+
251
+ function vectorSortCompat(items: CompatNodeRect[], compare: (left: CompatNodeRect, right: CompatNodeRect) => number): void {
252
+ if (items.length <= 1) return;
253
+ avmQuickSortCompat(items, 0, items.length - 1, compare);
254
+ }
255
+
256
+ function avmQuickSortCompat(
257
+ items: CompatNodeRect[],
258
+ initialLo: number,
259
+ initialHi: number,
260
+ compare: (left: CompatNodeRect, right: CompatNodeRect) => number,
261
+ ): void {
262
+ if (initialLo >= initialHi) return;
263
+ const stack: Array<{ lo: number; hi: number }> = [];
264
+ let lo = initialLo;
265
+ let hi = initialHi;
266
+ while (true) {
267
+ const size = hi - lo + 1;
268
+ if (size < 4) {
269
+ if (size === 3) {
270
+ if (compare(items[lo], items[lo + 1]) > 0) {
271
+ swapCompat(items, lo, lo + 1);
272
+ if (compare(items[lo + 1], items[lo + 2]) > 0) {
273
+ swapCompat(items, lo + 1, lo + 2);
274
+ if (compare(items[lo], items[lo + 1]) > 0) swapCompat(items, lo, lo + 1);
275
+ }
276
+ } else if (compare(items[lo + 1], items[lo + 2]) > 0) {
277
+ swapCompat(items, lo + 1, lo + 2);
278
+ if (compare(items[lo], items[lo + 1]) > 0) swapCompat(items, lo, lo + 1);
279
+ }
280
+ } else if (size === 2 && compare(items[lo], items[lo + 1]) > 0) {
281
+ swapCompat(items, lo, lo + 1);
282
+ }
283
+ } else {
284
+ const pivot = lo + (size >> 1);
285
+ swapCompat(items, pivot, lo);
286
+ let left = lo;
287
+ let right = hi + 1;
288
+ while (true) {
289
+ do left += 1; while (left <= hi && compare(items[left], items[lo]) <= 0);
290
+ do right -= 1; while (right > lo && compare(items[right], items[lo]) >= 0);
291
+ if (right < left) break;
292
+ swapCompat(items, left, right);
293
+ }
294
+ swapCompat(items, lo, right);
295
+ if (right - 1 - lo >= hi - left) {
296
+ if (lo + 1 < right) stack.push({ lo, hi: right - 1 });
297
+ if (left < hi) {
298
+ lo = left;
299
+ continue;
300
+ }
301
+ } else {
302
+ if (left < hi) stack.push({ lo: left, hi });
303
+ if (lo + 1 < right) {
304
+ hi = right - 1;
305
+ continue;
306
+ }
307
+ }
308
+ }
309
+ if (stack.length === 0) return;
310
+ const frame = stack.pop()!;
311
+ lo = frame.lo;
312
+ hi = frame.hi;
313
+ }
314
+ }
315
+
316
+ function swapCompat(items: CompatNodeRect[], left: number, right: number): void {
317
+ const value = items[left];
318
+ items[left] = items[right];
319
+ items[right] = value;
320
+ }
321
+
322
+ function initSizeScheme(): Array<{ width: number; height: number; area: number; aspectRatio: number; len: number }> {
323
+ const result = [];
324
+ for (let w = 5; w <= 13; w += 1) {
325
+ for (let h = 5; h <= 13; h += 1) {
326
+ const width = Math.pow(2, w);
327
+ const height = Math.pow(2, h);
328
+ const area = width * height;
329
+ const aspectRatio = width > height ? width / height : height / width;
330
+ result.push({ width, height, area, aspectRatio, len: Math.max(width, height) });
331
+ }
332
+ }
333
+ result.sort(compareSizeScheme);
334
+ return result;
335
+ }
336
+
337
+ function compareSizeScheme(
338
+ left: { width: number; height: number; area: number; aspectRatio: number; len: number },
339
+ right: { width: number; height: number; area: number; aspectRatio: number; len: number },
340
+ ): number {
341
+ if (left.len < right.len) return -1;
342
+ if (left.len > right.len) return 1;
343
+ if (left.area < right.area) return -1;
344
+ if (left.area > right.area) return 1;
345
+ if (left.aspectRatio < right.aspectRatio) return -1;
346
+ if (left.aspectRatio > right.aspectRatio) return 1;
347
+ if (left.width > left.height) return -1;
348
+ if (right.width > right.height) return 1;
349
+ return 0;
350
+ }
351
+
352
+ function getBestPage(left: CompatPage | null, right: CompatPage | null): CompatPage | null {
353
+ if (!left) return right;
354
+ if (!right) return left;
355
+ return left.occupancy > right.occupancy ? left : right;
356
+ }
357
+
358
+ function comparePage(left: CompatPage, right: CompatPage): number {
359
+ return right.outputRects.length - left.outputRects.length;
360
+ }
361
+
362
+ function compareNodeRect(left: CompatNodeRect, right: CompatNodeRect): number {
363
+ const leftEdge = left.width > left.height ? left.width : left.height;
364
+ const rightEdge = right.width > right.height ? right.width : right.height;
365
+ return rightEdge - leftEdge;
366
+ }
367
+
368
+ function compareNodeRectStable(left: CompatNodeRect, right: CompatNodeRect): number {
369
+ const delta = compareNodeRect(left, right);
370
+ if (delta !== 0) return delta;
371
+ if (left.sourceKind === 'movieclip-frame' && right.sourceKind === 'movieclip-frame') {
372
+ const areaDelta = right.width * right.height - left.width * left.height;
373
+ if (areaDelta !== 0) return areaDelta;
374
+ const widthDelta = right.width - left.width;
375
+ if (widthDelta !== 0) return widthDelta;
376
+ }
377
+ return left.index - right.index;
378
+ }
379
+
380
+ function compareNodeRect2(left: CompatNodeRect, right: CompatNodeRect): number {
381
+ return right.width - left.width;
382
+ }
383
+
384
+ function compareNodeRect2Stable(left: CompatNodeRect, right: CompatNodeRect): number {
385
+ const delta = compareNodeRect2(left, right);
386
+ if (delta !== 0) return delta;
387
+ if (left.sourceKind === 'movieclip-frame' && right.sourceKind === 'movieclip-frame') {
388
+ const areaDelta = right.width * right.height - left.width * left.height;
389
+ if (areaDelta !== 0) return areaDelta;
390
+ const heightDelta = right.height - left.height;
391
+ if (heightDelta !== 0) return heightDelta;
392
+ }
393
+ return left.index - right.index;
394
+ }
395
+
396
+ function duplicatePadding(rect: CompatNodeRect): boolean {
397
+ return (rect.flags & COMPAT_NODE_RECT_FLAGS.DUPLICATE_PADDING) !== 0;
398
+ }
399
+
400
+ function shrinkRectForPadding(rect: CompatNodeRect, padding: number, maxWidth: number, maxHeight: number): void {
401
+ if (!rect.rotated) {
402
+ if (maxWidth - rect.width > padding || duplicatePadding(rect)) rect.width -= padding;
403
+ if (maxHeight - rect.height > padding || duplicatePadding(rect)) rect.height -= padding;
404
+ } else {
405
+ if (maxHeight - rect.width > padding || duplicatePadding(rect)) rect.width -= padding;
406
+ if (maxWidth - rect.height > padding || duplicatePadding(rect)) rect.height -= padding;
407
+ }
408
+ }
409
+
410
+ function cloneCompatRect(rect: CompatNodeRect): CompatNodeRect {
411
+ return { ...rect };
412
+ }
package/src/prune.ts ADDED
@@ -0,0 +1,86 @@
1
+ import type { Document, Transform } from '@openfairygui/core';
2
+ import { createTransform } from './utils.js';
3
+
4
+ export interface PruneOptions {
5
+ /** Remove components with no children. Default: false. */
6
+ emptyComponents?: boolean;
7
+ /** Remove unreferenced resources (images, sounds, fonts, movieclips not used in any component). Default: true. */
8
+ unusedResources?: boolean;
9
+ }
10
+
11
+ const PRUNE_DEFAULTS: Required<PruneOptions> = {
12
+ emptyComponents: false,
13
+ unusedResources: true,
14
+ };
15
+
16
+ /**
17
+ * Removes unreferenced resources from the project.
18
+ *
19
+ * By default, removes image/sound/font/movieclip resources that are not
20
+ * referenced by any component's display objects via `src` or `ui://` URLs.
21
+ *
22
+ * ```ts
23
+ * await doc.transform(prune());
24
+ * await doc.transform(prune({ emptyComponents: true }));
25
+ * ```
26
+ */
27
+ export function prune(_options: PruneOptions = {}): Transform {
28
+ const options = { ...PRUNE_DEFAULTS, ..._options };
29
+
30
+ return createTransform('prune', (doc: Document): void => {
31
+ const root = doc.getRoot();
32
+ const logger = doc.getLogger();
33
+ let pruned = 0;
34
+
35
+ if (options.unusedResources) {
36
+ // Collect all src references from all component display objects
37
+ const referencedIds = new Set<string>();
38
+
39
+ for (const pkg of root.listPackages()) {
40
+ for (const comp of pkg.listComponents()) {
41
+ for (const child of comp.listChildren()) {
42
+ const src = (child as any).getSrc?.() as string | undefined;
43
+ if (!src) continue;
44
+ if (src.startsWith('ui://')) {
45
+ // Extract resourceId part (after 8-char packageId)
46
+ const idPart = src.slice(5);
47
+ if (idPart.length > 8) {
48
+ referencedIds.add(idPart.slice(8));
49
+ } else {
50
+ referencedIds.add(idPart);
51
+ }
52
+ } else {
53
+ referencedIds.add(src);
54
+ }
55
+ }
56
+ }
57
+ }
58
+
59
+ // Remove unreferenced non-component resources
60
+ for (const pkg of root.listPackages()) {
61
+ const resources = pkg.listResources();
62
+ for (const res of resources) {
63
+ if (res.propertyType === 'Component') continue; // skip components
64
+ const resId = (res as any).getId?.() ?? '';
65
+ if (resId && !referencedIds.has(resId) && !(res as any).getExported?.()) {
66
+ res.dispose();
67
+ pruned++;
68
+ }
69
+ }
70
+ }
71
+ }
72
+
73
+ if (options.emptyComponents) {
74
+ for (const pkg of root.listPackages()) {
75
+ for (const comp of pkg.listComponents()) {
76
+ if (comp.listChildren().length === 0 && !(comp as any).getExported?.()) {
77
+ comp.dispose();
78
+ pruned++;
79
+ }
80
+ }
81
+ }
82
+ }
83
+
84
+ logger.info(`prune: Removed ${pruned} unused resource(s).`);
85
+ });
86
+ }