@jjlmoya/utils-games-development 1.69.0 → 1.71.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.
Files changed (43) hide show
  1. package/package.json +1 -1
  2. package/src/category/index.ts +2 -1
  3. package/src/entries.ts +4 -1
  4. package/src/index.ts +1 -0
  5. package/src/tests/locale_completeness.test.ts +9 -2
  6. package/src/tests/tool_validation.test.ts +2 -2
  7. package/src/tool/fixerEditor/auto-trim.ts +37 -0
  8. package/src/tool/fixerEditor/bibliography.astro +6 -0
  9. package/src/tool/fixerEditor/bibliography.ts +6 -0
  10. package/src/tool/fixerEditor/component.astro +114 -0
  11. package/src/tool/fixerEditor/controller.ts +275 -0
  12. package/src/tool/fixerEditor/dom-views.ts +200 -0
  13. package/src/tool/fixerEditor/entry.ts +28 -0
  14. package/src/tool/fixerEditor/evaluator.ts +17 -0
  15. package/src/tool/fixerEditor/events.ts +98 -0
  16. package/src/tool/fixerEditor/exporter.ts +42 -0
  17. package/src/tool/fixerEditor/fixer-editor.css +603 -0
  18. package/src/tool/fixerEditor/i18n/de.ts +82 -0
  19. package/src/tool/fixerEditor/i18n/en.ts +114 -0
  20. package/src/tool/fixerEditor/i18n/es.ts +82 -0
  21. package/src/tool/fixerEditor/i18n/fr.ts +82 -0
  22. package/src/tool/fixerEditor/i18n/id.ts +82 -0
  23. package/src/tool/fixerEditor/i18n/it.ts +82 -0
  24. package/src/tool/fixerEditor/i18n/ja.ts +81 -0
  25. package/src/tool/fixerEditor/i18n/ko.ts +81 -0
  26. package/src/tool/fixerEditor/i18n/localized.ts +391 -0
  27. package/src/tool/fixerEditor/i18n/nl.ts +82 -0
  28. package/src/tool/fixerEditor/i18n/pl.ts +82 -0
  29. package/src/tool/fixerEditor/i18n/pt.ts +82 -0
  30. package/src/tool/fixerEditor/i18n/ru.ts +82 -0
  31. package/src/tool/fixerEditor/i18n/sv.ts +82 -0
  32. package/src/tool/fixerEditor/i18n/tr.ts +82 -0
  33. package/src/tool/fixerEditor/i18n/zh.ts +80 -0
  34. package/src/tool/fixerEditor/index.ts +11 -0
  35. package/src/tool/fixerEditor/logic.test.ts +69 -0
  36. package/src/tool/fixerEditor/logic.ts +234 -0
  37. package/src/tool/fixerEditor/runtime.ts +88 -0
  38. package/src/tool/fixerEditor/seo.astro +15 -0
  39. package/src/tool/fixerEditor/state.ts +10 -0
  40. package/src/tool/fixerEditor/storage.ts +68 -0
  41. package/src/tool/fixerEditor/types.ts +70 -0
  42. package/src/tool/fixerEditor/ui.ts +71 -0
  43. package/src/tools.ts +2 -0
@@ -0,0 +1,82 @@
1
+ import { createLocalizedContent } from "./localized";
2
+
3
+ export const content = createLocalizedContent({
4
+ locale: "ru",
5
+ slug: "redaktor-ispravleniya-sprite-sheet",
6
+ title: "Редактор исправления Sprite Sheet",
7
+ description:
8
+ "Исправляйте обрезку и выравнивание пикселей спрайт-листа в браузере, затем экспортируйте лист и метаданные кадров.",
9
+ ui: {
10
+ uploadTitle: "Открыть спрайт-лист",
11
+ uploadHint:
12
+ "Перетащите PNG или WebP, чтобы разместить кадры на рабочем поле",
13
+ chooseImage: "Выбрать изображение",
14
+ sourceFile: "Исходный файл",
15
+ noImage: "Спрайт-лист не загружен",
16
+ gridSettings: "Настройка сетки",
17
+ columns: "Столбцы",
18
+ rows: "Строки",
19
+ cellWidth: "Ширина ячейки",
20
+ cellHeight: "Высота ячейки",
21
+ marginX: "Левое поле",
22
+ marginY: "Верхнее поле",
23
+ gapX: "Интервал столбцов",
24
+ gapY: "Интервал строк",
25
+ frameTools: "Настройки кадра",
26
+ cropX: "Обрезка слева",
27
+ cropY: "Обрезка сверху",
28
+ cropWidth: "Ширина обрезки",
29
+ cropHeight: "Высота обрезки",
30
+ offsetX: "Смещение X",
31
+ offsetY: "Смещение Y",
32
+ trimTransparent: "Обрезать прозрачные пиксели",
33
+ trimAll: "Автообрезка всех кадров",
34
+ autoDetectGrid: "Определить сетку автоматически",
35
+ resetFrame: "Сбросить выбранный кадр",
36
+ nudgeStep: "Шаг перемещения",
37
+ stepOne: "Один пиксель",
38
+ stepTwo: "Два пикселя",
39
+ stepFour: "Четыре пикселя",
40
+ history: "Журнал исправлений",
41
+ undo: "Отменить",
42
+ redo: "Повторить",
43
+ resetAll: "Сбросить лист",
44
+ saveProject: "Сохранить проект",
45
+ loadProject: "Загрузить проект",
46
+ exportSheet: "Экспортировать исправленный лист",
47
+ exportJson: "Экспортировать JSON кадров",
48
+ sheetView: "Вид регистрации",
49
+ correctedView: "Исправленный лист",
50
+ activeFrame: "Инспектор кадра",
51
+ frameStrip: "Кадры",
52
+ flipbook: "Флипбук",
53
+ play: "Воспроизвести",
54
+ pause: "Пауза",
55
+ fps: "FPS",
56
+ zoom: "Масштаб предпросмотра",
57
+ ready: "Изменения видны сразу",
58
+ loaded: "Спрайт-лист загружен",
59
+ modified: "Изменён",
60
+ unchanged: "Исходная позиция",
61
+ frameCount: "Кадры",
62
+ correctionCount: "Исправленные кадры",
63
+ imageSize: "Размер изображения",
64
+ statusEmpty: "Ожидание спрайт-листа",
65
+ statusSaved: "Проект сохранён локально",
66
+ statusLoaded: "Проект загружен",
67
+ statusExported: "Экспорт готов",
68
+ statusInvalidImage: "Не удалось открыть это изображение",
69
+ statusInvalidProject: "Файл проекта недействителен",
70
+ statusProjectNeedsImage:
71
+ "Проект сохранён. Откройте исходное изображение для применения",
72
+ statusImageMismatch: "Проект загружен с другим размером источника",
73
+ metadataNote: "Прозрачные пиксели остаются прозрачными при экспорте",
74
+ sourceLabel: "Исходный лист",
75
+ resultLabel: "Исправленный лист",
76
+ selectFrame: "Выбрать кадр",
77
+ moveUp: "Переместить кадр вверх",
78
+ moveDown: "Переместить кадр вниз",
79
+ moveLeft: "Переместить кадр влево",
80
+ moveRight: "Переместить кадр вправо",
81
+ },
82
+ });
@@ -0,0 +1,82 @@
1
+ import { createLocalizedContent } from "./localized";
2
+
3
+ export const content = createLocalizedContent({
4
+ locale: "sv",
5
+ slug: "sprite-sheet-korrigering-editor",
6
+ title: "Redigerare för Sprite Sheet korrigering",
7
+ description:
8
+ "Justera beskärning och pixeljustering i ett sprite sheet direkt i webbläsaren och exportera sedan resultatet och bildrutedata.",
9
+ ui: {
10
+ uploadTitle: "Öppna ett sprite sheet",
11
+ uploadHint:
12
+ "Släpp en PNG eller WebP för att placera bildrutor på arbetsytan",
13
+ chooseImage: "Välj bild",
14
+ sourceFile: "Källfil",
15
+ noImage: "Inget sprite sheet har lästs in",
16
+ gridSettings: "Registrera rutnät",
17
+ columns: "Kolumner",
18
+ rows: "Rader",
19
+ cellWidth: "Cellbredd",
20
+ cellHeight: "Cellhöjd",
21
+ marginX: "Vänstermarginal",
22
+ marginY: "Övre marginal",
23
+ gapX: "Kolumnavstånd",
24
+ gapY: "Radavstånd",
25
+ frameTools: "Kontroller för bildrutor",
26
+ cropX: "Beskär vänster",
27
+ cropY: "Beskär övre",
28
+ cropWidth: "Beskärningsbredd",
29
+ cropHeight: "Beskärningshöjd",
30
+ offsetX: "Förskjutning X",
31
+ offsetY: "Förskjutning Y",
32
+ trimTransparent: "Beskär transparenta pixlar",
33
+ trimAll: "Beskär alla bildrutor automatiskt",
34
+ autoDetectGrid: "Identifiera rutnät automatiskt",
35
+ resetFrame: "Återställ vald bildruta",
36
+ nudgeStep: "Förflyttningssteg",
37
+ stepOne: "En pixel",
38
+ stepTwo: "Två pixlar",
39
+ stepFour: "Fyra pixlar",
40
+ history: "Korrigeringslogg",
41
+ undo: "Ångra",
42
+ redo: "Gör om",
43
+ resetAll: "Återställ sheet",
44
+ saveProject: "Spara projekt",
45
+ loadProject: "Läs in projekt",
46
+ exportSheet: "Exportera korrigerat sheet",
47
+ exportJson: "Exportera bildrute-JSON",
48
+ sheetView: "Registreringsvy",
49
+ correctedView: "Korrigerat sheet",
50
+ activeFrame: "Bildruteinspektör",
51
+ frameStrip: "Bildrutor",
52
+ flipbook: "Blädderbok",
53
+ play: "Spela",
54
+ pause: "Pausa",
55
+ fps: "FPS",
56
+ zoom: "Förhandsgranskningszoom",
57
+ ready: "Ändringar syns direkt",
58
+ loaded: "Sprite sheet inläst",
59
+ modified: "Justerad",
60
+ unchanged: "Ursprunglig position",
61
+ frameCount: "Bildrutor",
62
+ correctionCount: "Justerade bildrutor",
63
+ imageSize: "Bildstorlek",
64
+ statusEmpty: "Väntar på ett sprite sheet",
65
+ statusSaved: "Projektet sparades lokalt",
66
+ statusLoaded: "Projektet lästes in",
67
+ statusExported: "Export klar",
68
+ statusInvalidImage: "Bilden kunde inte öppnas",
69
+ statusInvalidProject: "Projektfilen är ogiltig",
70
+ statusProjectNeedsImage:
71
+ "Projektet är sparat. Öppna källbilden för att använda det",
72
+ statusImageMismatch: "Projektet har en annan källstorlek",
73
+ metadataNote: "Transparenta pixlar förblir transparenta vid export",
74
+ sourceLabel: "Käll-sheet",
75
+ resultLabel: "Korrigerat sheet",
76
+ selectFrame: "Välj bildruta",
77
+ moveUp: "Flytta bildruta upp",
78
+ moveDown: "Flytta bildruta ned",
79
+ moveLeft: "Flytta bildruta åt vänster",
80
+ moveRight: "Flytta bildruta åt höger",
81
+ },
82
+ });
@@ -0,0 +1,82 @@
1
+ import { createLocalizedContent } from "./localized";
2
+
3
+ export const content = createLocalizedContent({
4
+ locale: "tr",
5
+ slug: "sprite-sheet-duzeltici-editor",
6
+ title: "Sprite Sheet Düzeltme Editörü",
7
+ description:
8
+ "Sprite sheet kırpma ve piksel hizalamasını tarayıcıda düzeltin, ardından düzeltilmiş görseli ve kare metadatasını dışa aktarın.",
9
+ ui: {
10
+ uploadTitle: "Sprite sheet aç",
11
+ uploadHint:
12
+ "Kareleri düzeltme çalışma alanına yerleştirmek için PNG veya WebP bırakın",
13
+ chooseImage: "Görsel seç",
14
+ sourceFile: "Kaynak dosya",
15
+ noImage: "Sprite sheet yüklenmedi",
16
+ gridSettings: "Izgara kaydı",
17
+ columns: "Sütunlar",
18
+ rows: "Satırlar",
19
+ cellWidth: "Hücre genişliği",
20
+ cellHeight: "Hücre yüksekliği",
21
+ marginX: "Sol kenar boşluğu",
22
+ marginY: "Üst kenar boşluğu",
23
+ gapX: "Sütun aralığı",
24
+ gapY: "Satır aralığı",
25
+ frameTools: "Kare ayarlama araçları",
26
+ cropX: "Soldan kırp",
27
+ cropY: "Üstten kırp",
28
+ cropWidth: "Kırpma genişliği",
29
+ cropHeight: "Kırpma yüksekliği",
30
+ offsetX: "X ofseti",
31
+ offsetY: "Y ofseti",
32
+ trimTransparent: "Saydam pikselleri kırp",
33
+ trimAll: "Tüm kareleri otomatik kırp",
34
+ autoDetectGrid: "Izgarayı otomatik algıla",
35
+ resetFrame: "Seçili kareyi sıfırla",
36
+ nudgeStep: "Kaydırma adımı",
37
+ stepOne: "Bir piksel",
38
+ stepTwo: "İki piksel",
39
+ stepFour: "Dört piksel",
40
+ history: "Düzeltme günlüğü",
41
+ undo: "Geri al",
42
+ redo: "Yinele",
43
+ resetAll: "Sheeti sıfırla",
44
+ saveProject: "Projeyi kaydet",
45
+ loadProject: "Projeyi yükle",
46
+ exportSheet: "Düzeltilmiş sheeti dışa aktar",
47
+ exportJson: "Kare JSONunu dışa aktar",
48
+ sheetView: "Kayıt görünümü",
49
+ correctedView: "Düzeltilmiş sheet",
50
+ activeFrame: "Kare denetçisi",
51
+ frameStrip: "Kareler",
52
+ flipbook: "Flipbook",
53
+ play: "Oynat",
54
+ pause: "Duraklat",
55
+ fps: "FPS",
56
+ zoom: "Önizleme yakınlaştırması",
57
+ ready: "Ayarlar anında görünür",
58
+ loaded: "Sprite sheet yüklendi",
59
+ modified: "Düzenlendi",
60
+ unchanged: "Orijinal konum",
61
+ frameCount: "Kare",
62
+ correctionCount: "Düzeltilen kare",
63
+ imageSize: "Görsel boyutu",
64
+ statusEmpty: "Sprite sheet bekleniyor",
65
+ statusSaved: "Proje yerel olarak kaydedildi",
66
+ statusLoaded: "Proje yüklendi",
67
+ statusExported: "Dışa aktarmaya hazır",
68
+ statusInvalidImage: "Bu görsel açılamadı",
69
+ statusInvalidProject: "Proje dosyası geçersiz",
70
+ statusProjectNeedsImage:
71
+ "Proje hatırlandı. Uygulamak için kaynak görseli açın",
72
+ statusImageMismatch: "Proje farklı kaynak boyutuyla yüklendi",
73
+ metadataNote: "Saydam pikseller dışa aktarımda da saydam kalır",
74
+ sourceLabel: "Kaynak sheet",
75
+ resultLabel: "Düzeltilmiş sheet",
76
+ selectFrame: "Kare seç",
77
+ moveUp: "Kareyi yukarı taşı",
78
+ moveDown: "Kareyi aşağı taşı",
79
+ moveLeft: "Kareyi sola taşı",
80
+ moveRight: "Kareyi sağa taşı",
81
+ },
82
+ });
@@ -0,0 +1,80 @@
1
+ import { createLocalizedContent } from "./localized";
2
+
3
+ export const content = createLocalizedContent({
4
+ locale: "zh",
5
+ slug: "fixer-editor",
6
+ title: "Sprite Sheet 修正编辑器",
7
+ description:
8
+ "直接在浏览器中修正精灵图的裁剪和像素对齐,然后导出修正后的图像与帧元数据。",
9
+ ui: {
10
+ uploadTitle: "打开精灵图",
11
+ uploadHint: "拖入 PNG 或 WebP,将帧放到修正工作台",
12
+ chooseImage: "选择图像",
13
+ sourceFile: "源文件",
14
+ noImage: "尚未加载精灵图",
15
+ gridSettings: "网格注册",
16
+ columns: "列",
17
+ rows: "行",
18
+ cellWidth: "单元格宽度",
19
+ cellHeight: "单元格高度",
20
+ marginX: "左边距",
21
+ marginY: "上边距",
22
+ gapX: "列间距",
23
+ gapY: "行间距",
24
+ frameTools: "帧调整工具",
25
+ cropX: "左侧裁剪",
26
+ cropY: "顶部裁剪",
27
+ cropWidth: "裁剪宽度",
28
+ cropHeight: "裁剪高度",
29
+ offsetX: "X 偏移",
30
+ offsetY: "Y 偏移",
31
+ trimTransparent: "裁剪透明像素",
32
+ trimAll: "自动裁剪所有帧",
33
+ autoDetectGrid: "自动检测网格",
34
+ resetFrame: "重置选中帧",
35
+ nudgeStep: "移动步长",
36
+ stepOne: "一个像素",
37
+ stepTwo: "两个像素",
38
+ stepFour: "四个像素",
39
+ history: "修正记录",
40
+ undo: "撤销",
41
+ redo: "重做",
42
+ resetAll: "重置精灵图",
43
+ saveProject: "保存项目",
44
+ loadProject: "加载项目",
45
+ exportSheet: "导出修正后的精灵图",
46
+ exportJson: "导出帧 JSON",
47
+ sheetView: "注册视图",
48
+ correctedView: "修正后的精灵图",
49
+ activeFrame: "帧检查器",
50
+ frameStrip: "帧列表",
51
+ flipbook: "翻页预览",
52
+ play: "播放",
53
+ pause: "暂停",
54
+ fps: "FPS",
55
+ zoom: "预览缩放",
56
+ ready: "调整会立即显示",
57
+ loaded: "精灵图已加载",
58
+ modified: "已调整",
59
+ unchanged: "原始位置",
60
+ frameCount: "帧",
61
+ correctionCount: "已调整帧",
62
+ imageSize: "图像尺寸",
63
+ statusEmpty: "等待精灵图",
64
+ statusSaved: "项目已保存到本地",
65
+ statusLoaded: "项目已加载",
66
+ statusExported: "可以导出",
67
+ statusInvalidImage: "无法打开此图像",
68
+ statusInvalidProject: "项目文件无效",
69
+ statusProjectNeedsImage: "已记住项目。请打开源图像以应用它",
70
+ statusImageMismatch: "项目加载时使用了不同的源尺寸",
71
+ metadataNote: "透明像素在导出时仍保持透明",
72
+ sourceLabel: "源精灵图",
73
+ resultLabel: "修正后的精灵图",
74
+ selectFrame: "选择帧",
75
+ moveUp: "向上移动帧",
76
+ moveDown: "向下移动帧",
77
+ moveLeft: "向左移动帧",
78
+ moveRight: "向右移动帧",
79
+ },
80
+ });
@@ -0,0 +1,11 @@
1
+ import type { ToolDefinition } from '../../types';
2
+ import { fixerEditor } from './entry';
3
+
4
+ export * from './entry';
5
+
6
+ export const FIXER_EDITOR_TOOL: ToolDefinition = {
7
+ entry: fixerEditor,
8
+ Component: () => import('./component.astro'),
9
+ SEOComponent: () => import('./seo.astro'),
10
+ BibliographyComponent: () => import('./bibliography.astro'),
11
+ };
@@ -0,0 +1,69 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { createDefaultAdjustment, createFrameMetadata, createFrameSpecs, createHistoryState, createProjectFile, DEFAULT_GRID, detectGridFromImageData, isModified, nudgeAdjustment, normalizeAdjustment, normalizeGrid, parseProjectFile, recalculateGrid, trimTransparent, updateAdjustment } from './logic';
3
+
4
+ const frame = createFrameSpecs(128, 64, DEFAULT_GRID)[0];
5
+
6
+ if (!frame) throw new Error('Expected a test frame');
7
+
8
+ describe('fixer editor logic', () => {
9
+ it('normalizes grid values and creates registered frames', () => {
10
+ expect(normalizeGrid({ ...DEFAULT_GRID, columns: 0, rows: 200, cellWidth: 2.6 })).toEqual({ ...DEFAULT_GRID, columns: 1, rows: 128, cellWidth: 3 });
11
+ expect(createFrameSpecs(128, 64, DEFAULT_GRID)).toHaveLength(2);
12
+ });
13
+
14
+ it('recalculates columns and rows from the source dimensions', () => {
15
+ expect(recalculateGrid({ ...DEFAULT_GRID, cellWidth: 32 }, 128, 64, 'columns').columns).toBe(4);
16
+ expect(recalculateGrid({ ...DEFAULT_GRID, cellHeight: 16 }, 128, 64, 'rows').rows).toBe(4);
17
+ expect(recalculateGrid({ ...DEFAULT_GRID, cellWidth: 30, gapX: 2 }, 128, 64, 'columns').columns).toBe(4);
18
+ });
19
+
20
+ it('detects a transparent four by two sheet', () => {
21
+ const width = 128;
22
+ const height = 64;
23
+ const pixels = new Uint8ClampedArray(width * height * 4);
24
+ for (let y = 4; y < 28; y += 1) {
25
+ for (let x = 4; x < 28; x += 1) pixels[(y * width + x) * 4 + 3] = 255;
26
+ }
27
+ for (let y = 36; y < 60; y += 1) {
28
+ for (let x = 68; x < 92; x += 1) pixels[(y * width + x) * 4 + 3] = 255;
29
+ }
30
+ expect(detectGridFromImageData(pixels, width, height)).toMatchObject({ columns: 4, rows: 2 });
31
+ });
32
+
33
+ it('clamps a crop and offset to the frame canvas', () => {
34
+ expect(normalizeAdjustment({ crop: { x: -4, y: 80, width: 90, height: 0 }, offsetX: 90, offsetY: -90 }, frame)).toEqual({ crop: { x: 0, y: 63, width: 64, height: 1 }, offsetX: 64, offsetY: -64 });
35
+ expect(updateAdjustment(frame, createDefaultAdjustment(64, 64), { crop: { x: 4, width: 20 }, offsetY: 3 })).toEqual({ crop: { x: 4, y: 0, width: 20, height: 64 }, offsetX: 0, offsetY: 3 });
36
+ });
37
+
38
+ it('nudges and detects adjusted frames', () => {
39
+ const original = createDefaultAdjustment(64, 64);
40
+ const moved = nudgeAdjustment(frame, original, 'x', 2);
41
+ expect(moved.offsetX).toBe(2);
42
+ expect(isModified(moved, frame)).toBe(true);
43
+ expect(isModified(original, frame)).toBe(false);
44
+ });
45
+
46
+ it('finds visible pixels while preserving an empty frame', () => {
47
+ const pixels = new Uint8ClampedArray(4 * 4 * 4);
48
+ pixels[(1 * 4 + 2) * 4 + 3] = 255;
49
+ expect(trimTransparent(pixels, 4, 4)).toEqual({ x: 2, y: 1, width: 1, height: 1 });
50
+ expect(trimTransparent(new Uint8ClampedArray(4 * 4 * 4), 4, 4)).toEqual({ x: 0, y: 0, width: 4, height: 4 });
51
+ });
52
+
53
+ it('serializes valid projects and rejects malformed files', () => {
54
+ const project = createProjectFile({ sourceName: 'hero.png', sourceWidth: 128, sourceHeight: 64, grid: DEFAULT_GRID, adjustments: { '0': createDefaultAdjustment(64, 64) } });
55
+ expect(parseProjectFile(project)).toEqual(project);
56
+ expect(parseProjectFile(null)).toBeNull();
57
+ expect(parseProjectFile({ version: 2 })).toBeNull();
58
+ expect(parseProjectFile({ version: 1, grid: DEFAULT_GRID, adjustments: null })).toBeNull();
59
+ expect(createHistoryState(DEFAULT_GRID, {}, 0).selected).toBe(0);
60
+ });
61
+
62
+ it('creates neutral metadata for export', () => {
63
+ const frames = createFrameSpecs(128, 64, DEFAULT_GRID);
64
+ const first = frames[0];
65
+ if (!first) throw new Error('Expected a metadata frame');
66
+ const metadata = createFrameMetadata(frames, { '0': nudgeAdjustment(first, createDefaultAdjustment(64, 64), 'y', 1) });
67
+ expect(metadata[0]).toMatchObject({ index: 0, x: 0, y: 0, modified: true, offset: { x: 0, y: 1 } });
68
+ });
69
+ });
@@ -0,0 +1,234 @@
1
+ import type { CropRect, FrameAdjustment, FrameMetadata, FrameSpec, GridSettings, HistoryState, ProjectFile } from './types';
2
+
3
+ export const DEFAULT_GRID: GridSettings = {
4
+ columns: 4,
5
+ rows: 2,
6
+ cellWidth: 64,
7
+ cellHeight: 64,
8
+ marginX: 0,
9
+ marginY: 0,
10
+ gapX: 0,
11
+ gapY: 0,
12
+ };
13
+
14
+ const MIN_VALUE = 1;
15
+
16
+ export function clamp(value: number, min: number, max: number): number {
17
+ return Math.min(Math.max(value, min), max);
18
+ }
19
+
20
+ export function normalizeGrid(grid: GridSettings): GridSettings {
21
+ return {
22
+ columns: clampInteger(grid.columns, 1, 128),
23
+ rows: clampInteger(grid.rows, 1, 128),
24
+ cellWidth: clampInteger(grid.cellWidth, MIN_VALUE, 4096),
25
+ cellHeight: clampInteger(grid.cellHeight, MIN_VALUE, 4096),
26
+ marginX: clampInteger(grid.marginX, 0, 4096),
27
+ marginY: clampInteger(grid.marginY, 0, 4096),
28
+ gapX: clampInteger(grid.gapX, 0, 4096),
29
+ gapY: clampInteger(grid.gapY, 0, 4096),
30
+ };
31
+ }
32
+
33
+ export function recalculateGrid(grid: GridSettings, imageWidth: number, imageHeight: number, axis: 'columns' | 'rows'): GridSettings {
34
+ const safeGrid = normalizeGrid(grid);
35
+ if (axis === 'columns') {
36
+ const availableWidth = Math.max(0, imageWidth - safeGrid.marginX);
37
+ const columns = Math.max(1, Math.floor((availableWidth + safeGrid.gapX) / (safeGrid.cellWidth + safeGrid.gapX)));
38
+ return normalizeGrid({ ...safeGrid, columns });
39
+ }
40
+ const availableHeight = Math.max(0, imageHeight - safeGrid.marginY);
41
+ const rows = Math.max(1, Math.floor((availableHeight + safeGrid.gapY) / (safeGrid.cellHeight + safeGrid.gapY)));
42
+ return normalizeGrid({ ...safeGrid, rows });
43
+ }
44
+
45
+ export function detectGridFromImageData(data: Uint8ClampedArray, width: number, height: number): GridSettings {
46
+ const columns = buildProjection(data, width, height, 'x');
47
+ const rows = buildProjection(data, width, height, 'y');
48
+ const marginX = findLeadingBlank(columns);
49
+ const marginY = findLeadingBlank(rows);
50
+ return normalizeGrid({ columns: detectAxis(columns, DEFAULT_GRID.columns), rows: detectAxis(rows, DEFAULT_GRID.rows), cellWidth: Math.max(1, Math.floor((width - marginX) / detectAxis(columns, DEFAULT_GRID.columns))), cellHeight: Math.max(1, Math.floor((height - marginY) / detectAxis(rows, DEFAULT_GRID.rows))), marginX, marginY, gapX: 0, gapY: 0 });
51
+ }
52
+
53
+ function buildProjection(data: Uint8ClampedArray, width: number, height: number, axis: 'x' | 'y'): number[] {
54
+ const isColumn = axis === 'x';
55
+ const length = isColumn ? width : height;
56
+ const projection = Array.from({ length }, () => 0);
57
+ for (let y = 0; y < height; y += 1) {
58
+ for (let x = 0; x < width; x += 1) {
59
+ if ((data[(y * width + x) * 4 + 3] ?? 0) <= 8) continue;
60
+ const targetIndex = isColumn ? x : y;
61
+ projection[targetIndex] = (projection[targetIndex] ?? 0) + 1;
62
+ }
63
+ }
64
+ return projection;
65
+ }
66
+
67
+ function findLeadingBlank(projection: number[]): number {
68
+ const firstInk = projection.findIndex((value) => value > 0);
69
+ return firstInk < 0 ? 0 : firstInk;
70
+ }
71
+
72
+ function detectAxis(projection: number[], fallback: number): number {
73
+ const maxParts = Math.min(32, Math.max(1, Math.floor(projection.length / 8)));
74
+ let bestScore = Number.POSITIVE_INFINITY;
75
+ let bestParts = 1;
76
+ for (let parts = 2; parts <= maxParts; parts += 1) {
77
+ const score = boundaryScore(projection, parts);
78
+ if (score < bestScore) {
79
+ bestScore = score;
80
+ bestParts = parts;
81
+ } else if (score <= bestScore + 0.03) {
82
+ bestParts = Math.max(bestParts, parts);
83
+ }
84
+ }
85
+ return bestScore < 0.98 ? bestParts : Math.min(fallback, maxParts);
86
+ }
87
+
88
+ function boundaryScore(projection: number[], parts: number): number {
89
+ const totalInk = projection.reduce((sum, value) => sum + value, 0);
90
+ if (totalInk === 0) return 1;
91
+ const cellWidth = projection.length / parts;
92
+ const boundaryInk = Array.from({ length: parts - 1 }, (_, index) => Math.round((index + 1) * cellWidth)).reduce((sum, boundary) => sum + getBoundaryInk(projection, boundary), 0);
93
+ return boundaryInk / (totalInk * 0.02 + 1);
94
+ }
95
+
96
+ function getBoundaryInk(projection: number[], boundary: number): number {
97
+ return [-2, -1, 0, 1, 2].reduce((sum, offset) => sum + (projection[boundary + offset] ?? 0), 0);
98
+ }
99
+
100
+ function clampInteger(value: number, min: number, max: number): number {
101
+ return clamp(Math.round(Number.isFinite(value) ? value : min), min, max);
102
+ }
103
+
104
+ export function createDefaultAdjustment(width: number, height: number): FrameAdjustment {
105
+ return { crop: { x: 0, y: 0, width, height }, offsetX: 0, offsetY: 0 };
106
+ }
107
+
108
+ export function createFrameSpecs(imageWidth: number, imageHeight: number, grid: GridSettings): FrameSpec[] {
109
+ const safeGrid = normalizeGrid(grid);
110
+ const frames: FrameSpec[] = [];
111
+ for (let row = 0; row < safeGrid.rows; row += 1) {
112
+ for (let column = 0; column < safeGrid.columns; column += 1) {
113
+ const index = row * safeGrid.columns + column;
114
+ const sourceX = safeGrid.marginX + column * (safeGrid.cellWidth + safeGrid.gapX);
115
+ const sourceY = safeGrid.marginY + row * (safeGrid.cellHeight + safeGrid.gapY);
116
+ frames.push({ index, row, column, sourceX, sourceY, width: safeGrid.cellWidth, height: safeGrid.cellHeight, adjustment: createDefaultAdjustment(safeGrid.cellWidth, safeGrid.cellHeight) });
117
+ }
118
+ }
119
+ return frames.filter((frame) => frame.sourceX < imageWidth && frame.sourceY < imageHeight);
120
+ }
121
+
122
+ export function normalizeAdjustment(adjustment: FrameAdjustment, frame: FrameSpec): FrameAdjustment {
123
+ const width = clampInteger(adjustment.crop.width, 1, frame.width);
124
+ const height = clampInteger(adjustment.crop.height, 1, frame.height);
125
+ const x = clampInteger(adjustment.crop.x, 0, frame.width - width);
126
+ const y = clampInteger(adjustment.crop.y, 0, frame.height - height);
127
+ const offsetX = clampInteger(adjustment.offsetX, -frame.width, frame.width);
128
+ const offsetY = clampInteger(adjustment.offsetY, -frame.height, frame.height);
129
+ return { crop: { x, y, width, height }, offsetX, offsetY };
130
+ }
131
+
132
+ export function updateAdjustment(frame: FrameSpec, current: FrameAdjustment, patch: { crop?: Partial<CropRect>; offsetX?: number; offsetY?: number }): FrameAdjustment {
133
+ const nextCrop = { ...current.crop, ...patch.crop };
134
+ return normalizeAdjustment({ crop: nextCrop, offsetX: patch.offsetX ?? current.offsetX, offsetY: patch.offsetY ?? current.offsetY }, frame);
135
+ }
136
+
137
+ export function nudgeAdjustment(frame: FrameSpec, current: FrameAdjustment, axis: 'x' | 'y', delta: number): FrameAdjustment {
138
+ const patch = axis === 'x' ? { offsetX: current.offsetX + delta } : { offsetY: current.offsetY + delta };
139
+ return updateAdjustment(frame, current, patch);
140
+ }
141
+
142
+ export function isModified(adjustment: FrameAdjustment, frame: FrameSpec): boolean {
143
+ const offsets = [adjustment.crop.x, adjustment.crop.y, adjustment.offsetX, adjustment.offsetY];
144
+ return offsets.some((value) => value !== 0) || adjustment.crop.width !== frame.width || adjustment.crop.height !== frame.height;
145
+ }
146
+
147
+ export function countModified(frames: FrameSpec[], adjustments: Record<string, FrameAdjustment>): number {
148
+ return frames.reduce((total, frame) => total + (isModified(adjustments[String(frame.index)] ?? frame.adjustment, frame) ? 1 : 0), 0);
149
+ }
150
+
151
+ export function trimTransparent(data: Uint8ClampedArray, width: number, height: number): CropRect {
152
+ let minX = width;
153
+ let minY = height;
154
+ let maxX = -1;
155
+ let maxY = -1;
156
+ for (let y = 0; y < height; y += 1) {
157
+ for (let x = 0; x < width; x += 1) {
158
+ if ((data[(y * width + x) * 4 + 3] ?? 0) > 0) {
159
+ minX = Math.min(minX, x);
160
+ minY = Math.min(minY, y);
161
+ maxX = Math.max(maxX, x);
162
+ maxY = Math.max(maxY, y);
163
+ }
164
+ }
165
+ }
166
+ if (maxX < 0) return { x: 0, y: 0, width, height };
167
+ return { x: minX, y: minY, width: maxX - minX + 1, height: maxY - minY + 1 };
168
+ }
169
+
170
+ export function createHistoryState(grid: GridSettings, adjustments: Record<string, FrameAdjustment>, selected: number): HistoryState {
171
+ return { grid: normalizeGrid(grid), adjustments: cloneAdjustments(adjustments), selected };
172
+ }
173
+
174
+ export function cloneAdjustments(adjustments: Record<string, FrameAdjustment>): Record<string, FrameAdjustment> {
175
+ return JSON.parse(JSON.stringify(adjustments)) as Record<string, FrameAdjustment>;
176
+ }
177
+
178
+ export function createProjectFile(input: { sourceName: string; sourceWidth: number; sourceHeight: number; grid: GridSettings; adjustments: Record<string, FrameAdjustment> }): ProjectFile {
179
+ return { version: 1, sourceName: input.sourceName, sourceWidth: input.sourceWidth, sourceHeight: input.sourceHeight, grid: normalizeGrid(input.grid), adjustments: cloneAdjustments(input.adjustments) };
180
+ }
181
+
182
+ export function parseProjectFile(value: unknown): ProjectFile | null {
183
+ if (!value || typeof value !== 'object') return null;
184
+ const candidate = value as Partial<ProjectFile>;
185
+ if (!hasProjectHeader(candidate)) return null;
186
+ if (!hasProjectSourceSize(candidate)) return null;
187
+ if (!isGridSettings(candidate.grid) || !isAdjustmentMap(candidate.adjustments)) return null;
188
+ return createProjectFile({ sourceName: typeof candidate.sourceName === 'string' ? candidate.sourceName : '', sourceWidth: candidate.sourceWidth, sourceHeight: candidate.sourceHeight, grid: candidate.grid, adjustments: candidate.adjustments });
189
+ }
190
+
191
+ function hasProjectHeader(value: Partial<ProjectFile>): boolean {
192
+ return value.version === 1 && Boolean(value.grid) && Boolean(value.adjustments) && typeof value.adjustments === 'object';
193
+ }
194
+
195
+ function hasProjectSourceSize(value: Partial<ProjectFile>): value is ProjectFile {
196
+ return isFinitePositiveInteger(value.sourceWidth) && isFinitePositiveInteger(value.sourceHeight);
197
+ }
198
+
199
+ function isFinitePositiveInteger(value: unknown): value is number {
200
+ return typeof value === 'number' && Number.isFinite(value) && Number.isInteger(value) && value > 0;
201
+ }
202
+
203
+ function isGridSettings(value: unknown): value is GridSettings {
204
+ if (!value || typeof value !== 'object') return false;
205
+ const candidate = value as Record<string, unknown>;
206
+ return ['columns', 'rows', 'cellWidth', 'cellHeight', 'marginX', 'marginY', 'gapX', 'gapY'].every((key) => typeof candidate[key] === 'number' && Number.isFinite(candidate[key]));
207
+ }
208
+
209
+ function isAdjustmentMap(value: object): value is Record<string, FrameAdjustment> {
210
+ return Object.values(value).every((candidate) => isAdjustment(candidate));
211
+ }
212
+
213
+ function isAdjustment(value: unknown): value is FrameAdjustment {
214
+ if (!value || typeof value !== 'object') return false;
215
+ const candidate = value as Record<string, unknown>;
216
+ return isCropRect(candidate.crop) && isFiniteNumber(candidate.offsetX) && isFiniteNumber(candidate.offsetY);
217
+ }
218
+
219
+ function isCropRect(value: unknown): boolean {
220
+ if (!value || typeof value !== 'object') return false;
221
+ const crop = value as Record<string, unknown>;
222
+ return ['x', 'y', 'width', 'height'].every((key) => isFiniteNumber(crop[key]));
223
+ }
224
+
225
+ function isFiniteNumber(value: unknown): value is number {
226
+ return typeof value === 'number' && Number.isFinite(value);
227
+ }
228
+
229
+ export function createFrameMetadata(frames: FrameSpec[], adjustments: Record<string, FrameAdjustment>): FrameMetadata[] {
230
+ return frames.map((frame) => {
231
+ const adjustment = normalizeAdjustment(adjustments[String(frame.index)] ?? frame.adjustment, frame);
232
+ return { index: frame.index, x: frame.sourceX, y: frame.sourceY, width: frame.width, height: frame.height, crop: adjustment.crop, offset: { x: adjustment.offsetX, y: adjustment.offsetY }, modified: isModified(adjustment, frame) };
233
+ });
234
+ }