@jjlmoya/utils-games-development 1.54.0 → 1.56.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/entries.ts +7 -1
- package/src/index.ts +2 -0
- package/src/tests/contrast_legibility.test.ts +291 -0
- package/src/tests/locale_completeness.test.ts +2 -2
- package/src/tests/tool_validation.test.ts +2 -2
- package/src/tool/itchioGameTester/itchio-game-tester.css +3 -3
- package/src/tool/localizationSanitizer/localization-sanitizer.css +17 -15
- package/src/tool/pixelArtPaletteSwapper/app-client.ts +229 -0
- package/src/tool/pixelArtPaletteSwapper/bibliography.astro +6 -0
- package/src/tool/pixelArtPaletteSwapper/bibliography.ts +16 -0
- package/src/tool/pixelArtPaletteSwapper/component.astro +153 -0
- package/src/tool/pixelArtPaletteSwapper/entry.ts +28 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/de.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/en.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/es.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/fr.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/id.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/it.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ja.ts +225 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ko.ts +225 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/nl.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/pl.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/pt.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/ru.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/sv.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/tr.ts +233 -0
- package/src/tool/pixelArtPaletteSwapper/i18n/zh.ts +225 -0
- package/src/tool/pixelArtPaletteSwapper/index.ts +12 -0
- package/src/tool/pixelArtPaletteSwapper/logic.test.ts +43 -0
- package/src/tool/pixelArtPaletteSwapper/logic.ts +171 -0
- package/src/tool/pixelArtPaletteSwapper/pixel-art-palette-swapper.css +728 -0
- package/src/tool/pixelArtPaletteSwapper/seo.astro +15 -0
- package/src/tool/pixelArtPaletteSwapper/ui.ts +35 -0
- package/src/tool/retroSfxGenerator/bibliography.astro +6 -0
- package/src/tool/retroSfxGenerator/bibliography.ts +16 -0
- package/src/tool/retroSfxGenerator/client.ts +263 -0
- package/src/tool/retroSfxGenerator/component.astro +146 -0
- package/src/tool/retroSfxGenerator/entry.ts +27 -0
- package/src/tool/retroSfxGenerator/i18n/de.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/en.ts +224 -0
- package/src/tool/retroSfxGenerator/i18n/es.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/fr.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/id.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/it.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/ja.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/ko.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/nl.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/pl.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/pt.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/ru.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/sv.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/tr.ts +211 -0
- package/src/tool/retroSfxGenerator/i18n/zh.ts +211 -0
- package/src/tool/retroSfxGenerator/index.ts +11 -0
- package/src/tool/retroSfxGenerator/logic.test.ts +61 -0
- package/src/tool/retroSfxGenerator/logic.ts +214 -0
- package/src/tool/retroSfxGenerator/retro-sfx-generator.css +435 -0
- package/src/tool/retroSfxGenerator/seo.astro +15 -0
- package/src/tool/retroSfxGenerator/ui.ts +41 -0
- package/src/tool/saveFileEditor/game-save-file-editor.css +5 -5
- package/src/tool/spriteSheetPacker/sprite-sheet-packer.css +5 -5
- package/src/tool/steamBbcodeTranslator/steam-bbcode-translator.css +8 -8
- package/src/tool/steamCapsuleGenerator/steam-capsule-generator.css +6 -6
- package/src/tools.ts +4 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import type { ToolLocaleContent } from '../../../types';
|
|
2
|
+
import type { PixelArtPaletteSwapperUI } from '../ui';
|
|
3
|
+
|
|
4
|
+
export const content: ToolLocaleContent<PixelArtPaletteSwapperUI> = {
|
|
5
|
+
slug: 'pixel-art-palette-swapper',
|
|
6
|
+
title: '像素艺术调色板调换工具',
|
|
7
|
+
description: '在浏览器中将精灵图和精灵图集快速转换为复古主机调色板或自定义 Hex 颜色。',
|
|
8
|
+
ui: {
|
|
9
|
+
uploadTitle: '拖放精灵图或精灵图集',
|
|
10
|
+
uploadHint: 'PNG、JPEG 或 WebP 文件均在本地处理',
|
|
11
|
+
chooseImage: '选择图片',
|
|
12
|
+
replaceImage: '替换图片',
|
|
13
|
+
paletteTitle: '选择调色板',
|
|
14
|
+
gameBoyPalette: 'Game Boy',
|
|
15
|
+
nesPalette: 'NES 风格',
|
|
16
|
+
pico8Palette: 'PICO-8',
|
|
17
|
+
commodore64Palette: 'Commodore 64',
|
|
18
|
+
dawnBringerPalette: 'DawnBringer 16',
|
|
19
|
+
customPalette: '自定义颜色',
|
|
20
|
+
customPaletteHint: '使用逗号、空格或换行分隔 Hex 颜色代码。',
|
|
21
|
+
applyCustomPalette: '应用调色板',
|
|
22
|
+
resetCustomPalette: '重置',
|
|
23
|
+
sourcePreview: '原图',
|
|
24
|
+
resultPreview: '转换结果',
|
|
25
|
+
waitingForImage: '等待加载图片',
|
|
26
|
+
uploadToPreview: '上传图片以预览效果',
|
|
27
|
+
resultEmpty: '原图与调色板转换后的效果将并排显示。',
|
|
28
|
+
downloadPng: '下载 PNG',
|
|
29
|
+
downloadDisabled: '请先上传图片以启用导出。',
|
|
30
|
+
colorCount: '原图颜色数',
|
|
31
|
+
mappedCount: '已用颜色数',
|
|
32
|
+
imageSize: '图片尺寸',
|
|
33
|
+
paletteCount: '调色板颜色数',
|
|
34
|
+
preserveAlpha: '保留透明度',
|
|
35
|
+
zoomLabel: '缩放',
|
|
36
|
+
processing: '正在映射像素',
|
|
37
|
+
invalidPalette: '请至少添加一个有效的 Hex 颜色代码',
|
|
38
|
+
invalidImage: '请选择 PNG、JPEG 或 WebP 格式图片',
|
|
39
|
+
readyStatus: '就绪',
|
|
40
|
+
dropActive: '松开即可加载',
|
|
41
|
+
mappedSummary: '已将 {source} 种原图颜色映射至 {mapped} 种调色板颜色',
|
|
42
|
+
},
|
|
43
|
+
seo: [
|
|
44
|
+
{
|
|
45
|
+
type: 'title',
|
|
46
|
+
level: 2,
|
|
47
|
+
text: '将全彩像素图转换为经典的复古主机配色',
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
type: 'paragraph',
|
|
51
|
+
html: '限制色彩数量能赋予像素艺术独特的复古风格。本工具支持转换为 Game Boy、NES、PICO-8 等经典主机的调色板。',
|
|
52
|
+
},
|
|
53
|
+
{
|
|
54
|
+
type: 'title',
|
|
55
|
+
level: 2,
|
|
56
|
+
text: '颜色映射计算原理',
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
type: 'paragraph',
|
|
60
|
+
html: '工具会计算每个像素 RGB 值的距离,并将其替换为目标调色板中最接近的颜色,同时完好保留透明通道。',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
type: 'comparative',
|
|
64
|
+
columns: 2,
|
|
65
|
+
items: [
|
|
66
|
+
{
|
|
67
|
+
title: '调色板限制',
|
|
68
|
+
description: '将原有颜色替换为最接近的色块。',
|
|
69
|
+
points: [
|
|
70
|
+
'适合像素角色、图标及图块集',
|
|
71
|
+
'保持原始尺寸与像素位置不变',
|
|
72
|
+
'便于精确掌控色彩预算',
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
{
|
|
76
|
+
title: '调色板调换(Palette Swapping)',
|
|
77
|
+
description: '快速生成不同配色的角色或场景。',
|
|
78
|
+
points: [
|
|
79
|
+
'常用于换装、变色或状态变化',
|
|
80
|
+
'支持自定义 Hex 列表导入',
|
|
81
|
+
'可直接下载 PNG 导入引擎',
|
|
82
|
+
],
|
|
83
|
+
},
|
|
84
|
+
],
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
type: 'title',
|
|
88
|
+
level: 2,
|
|
89
|
+
text: '复古调色板对比',
|
|
90
|
+
},
|
|
91
|
+
{
|
|
92
|
+
type: 'table',
|
|
93
|
+
headers: ['调色板', '颜色数', '适用场景', '注意事项'],
|
|
94
|
+
rows: [
|
|
95
|
+
['Game Boy', '4', '黑白掌机复古风格', '对比度较低时细节易混淆'],
|
|
96
|
+
['NES 风格', '16', '经典街机与平台动作游戏', '高饱和度颜色需注意搭配'],
|
|
97
|
+
['PICO-8', '16', '现代鲜艳像素风格', '注意控制高饱和色彩的对比'],
|
|
98
|
+
['Commodore 64', '16', '复古电脑端艺术风格', '清晰的轮廓有助于提升识别度'],
|
|
99
|
+
['DawnBringer 16', '16', '通用性极高的手绘调色板', '需要明确的光影方向'],
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
{
|
|
103
|
+
type: 'title',
|
|
104
|
+
level: 2,
|
|
105
|
+
text: '精灵图集工作流建议',
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
type: 'paragraph',
|
|
109
|
+
html: '上传导出好的精灵图集,利用放大功能仔细检查面部细节和轮廓线是否保持清晰。',
|
|
110
|
+
},
|
|
111
|
+
{
|
|
112
|
+
type: 'tip',
|
|
113
|
+
title: '控制色彩数量',
|
|
114
|
+
html: '将颜色控制在 4 到 16 色之间通常能获得最干练清晰的效果。',
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
type: 'title',
|
|
118
|
+
level: 2,
|
|
119
|
+
text: '导出前检查清单',
|
|
120
|
+
},
|
|
121
|
+
{
|
|
122
|
+
type: 'diagnostic',
|
|
123
|
+
variant: 'info',
|
|
124
|
+
title: '导入游戏前的准备',
|
|
125
|
+
html: '在 100% 比例下检查画面,确保透明边缘干净无杂色。',
|
|
126
|
+
},
|
|
127
|
+
{
|
|
128
|
+
type: 'glossary',
|
|
129
|
+
items: [
|
|
130
|
+
{
|
|
131
|
+
term: '色彩量化(Quantization)',
|
|
132
|
+
definition: '将大量颜色缩减为较小固定颜色集合的过程。',
|
|
133
|
+
},
|
|
134
|
+
{
|
|
135
|
+
term: '色彩阶梯(Ramp)',
|
|
136
|
+
definition: '用于表现明暗过渡和材质体感的一组同系颜色。',
|
|
137
|
+
},
|
|
138
|
+
{
|
|
139
|
+
term: '索引调色板',
|
|
140
|
+
definition: '像素通过引用色表序号来确定颜色的存储形式。',
|
|
141
|
+
},
|
|
142
|
+
],
|
|
143
|
+
},
|
|
144
|
+
],
|
|
145
|
+
faq: [
|
|
146
|
+
{
|
|
147
|
+
question: '我的图片会被上传到服务器吗?',
|
|
148
|
+
answer: '不会。所有图像处理均在您的浏览器本地进行。',
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
question: '支持使用我自己的调色板吗?',
|
|
152
|
+
answer: '支持。在自定义颜色框中输入 Hex 代码并点击应用调色板即可。',
|
|
153
|
+
},
|
|
154
|
+
],
|
|
155
|
+
howTo: [
|
|
156
|
+
{
|
|
157
|
+
name: '加载图片',
|
|
158
|
+
text: '拖放 PNG、JPEG 或 WebP 图片到工作区。',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
name: '选择调色板',
|
|
162
|
+
text: '选择预设或输入自定义 Hex 代码。',
|
|
163
|
+
},
|
|
164
|
+
{
|
|
165
|
+
name: '导出结果',
|
|
166
|
+
text: '下载转换后的 PNG 格式图片。',
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
schemas: [
|
|
170
|
+
{
|
|
171
|
+
'@context': 'https://schema.org',
|
|
172
|
+
'@type': 'SoftwareApplication',
|
|
173
|
+
name: '像素艺术调色板调换工具',
|
|
174
|
+
applicationCategory: 'DesignApplication',
|
|
175
|
+
operatingSystem: 'Any',
|
|
176
|
+
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' },
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
'@context': 'https://schema.org',
|
|
180
|
+
'@type': 'FAQPage',
|
|
181
|
+
mainEntity: [
|
|
182
|
+
{
|
|
183
|
+
'@type': 'Question',
|
|
184
|
+
name: '我的图片会被上传到服务器吗?',
|
|
185
|
+
acceptedAnswer: {
|
|
186
|
+
'@type': 'Answer',
|
|
187
|
+
text: '不会。所有图像处理均在您的浏览器本地进行。',
|
|
188
|
+
},
|
|
189
|
+
},
|
|
190
|
+
{
|
|
191
|
+
'@type': 'Question',
|
|
192
|
+
name: '支持使用我自己的调色板吗?',
|
|
193
|
+
acceptedAnswer: {
|
|
194
|
+
'@type': 'Answer',
|
|
195
|
+
text: '支持。在自定义颜色框中输入 Hex 代码并点击应用调色板即可。',
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
'@context': 'https://schema.org',
|
|
202
|
+
'@type': 'HowTo',
|
|
203
|
+
name: '如何将像素图转换至复古调色板',
|
|
204
|
+
step: [
|
|
205
|
+
{ '@type': 'HowToStep', name: '加载图片', text: '拖放图片到工作区。' },
|
|
206
|
+
{ '@type': 'HowToStep', name: '选择调色板', text: '选择预设或输入自定义 Hex 代码。' },
|
|
207
|
+
{ '@type': 'HowToStep', name: '导出结果', text: '下载转换后的 PNG 格式图片。' },
|
|
208
|
+
],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
bibliography: [
|
|
212
|
+
{
|
|
213
|
+
name: 'MDN Canvas API',
|
|
214
|
+
url: 'https://developer.mozilla.org/en-US/docs/Web/API/Canvas_API',
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
name: 'Lospec Palette List',
|
|
218
|
+
url: 'https://lospec.com/palette-list',
|
|
219
|
+
},
|
|
220
|
+
{
|
|
221
|
+
name: 'W3C Portable Network Graphics Specification',
|
|
222
|
+
url: 'https://www.w3.org/TR/png-3/',
|
|
223
|
+
},
|
|
224
|
+
],
|
|
225
|
+
};
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolDefinition } from '../../types';
|
|
2
|
+
import { pixelArtPaletteSwapper } from './entry';
|
|
3
|
+
|
|
4
|
+
export * from './entry';
|
|
5
|
+
export * from './logic';
|
|
6
|
+
|
|
7
|
+
export const PIXEL_ART_PALETTE_SWAPPER_TOOL: ToolDefinition = {
|
|
8
|
+
entry: pixelArtPaletteSwapper,
|
|
9
|
+
Component: () => import('./component.astro'),
|
|
10
|
+
SEOComponent: () => import('./seo.astro'),
|
|
11
|
+
BibliographyComponent: () => import('./bibliography.astro'),
|
|
12
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import {
|
|
3
|
+
PRESET_PALETTES,
|
|
4
|
+
findNearestColor,
|
|
5
|
+
hexToRgb,
|
|
6
|
+
parsePaletteInput,
|
|
7
|
+
quantizeImageData,
|
|
8
|
+
rgbToHex,
|
|
9
|
+
} from './logic';
|
|
10
|
+
|
|
11
|
+
describe('pixel art palette swapper logic', () => {
|
|
12
|
+
it('parses short and full hex colors into normalized values', () => {
|
|
13
|
+
expect(hexToRgb('#0f8')).toEqual({ r: 0, g: 255, b: 136 });
|
|
14
|
+
expect(rgbToHex({ r: 18, g: 52, b: 86 })).toBe('#123456');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('removes invalid and duplicate custom colors', () => {
|
|
18
|
+
expect(parsePaletteInput('#000, #ffffff, nope, #000000')).toEqual([
|
|
19
|
+
{ r: 0, g: 0, b: 0, hex: '#000000' },
|
|
20
|
+
{ r: 255, g: 255, b: 255, hex: '#ffffff' },
|
|
21
|
+
]);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('chooses the closest palette color in RGB space', () => {
|
|
25
|
+
const palette = parsePaletteInput('#000000 #ffffff');
|
|
26
|
+
expect(findNearestColor({ r: 220, g: 220, b: 220 }, palette)?.hex).toBe('#ffffff');
|
|
27
|
+
expect(findNearestColor({ r: 30, g: 30, b: 30 }, palette)?.hex).toBe('#000000');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('maps opaque pixels while preserving transparent pixels and alpha', () => {
|
|
31
|
+
const source = new Uint8ClampedArray([
|
|
32
|
+
240, 20, 20, 255,
|
|
33
|
+
10, 10, 10, 128,
|
|
34
|
+
255, 255, 255, 0,
|
|
35
|
+
]);
|
|
36
|
+
const result = quantizeImageData(source, PRESET_PALETTES.pico8);
|
|
37
|
+
expect(Array.from(result.data.slice(0, 4))).toEqual([255, 0, 77, 255]);
|
|
38
|
+
expect(result.data[7]).toBe(128);
|
|
39
|
+
expect(Array.from(result.data.slice(8, 12))).toEqual([255, 255, 255, 0]);
|
|
40
|
+
expect(result.sourceColors).toBe(2);
|
|
41
|
+
expect(result.mappedColors).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
export interface RGBColor {
|
|
2
|
+
r: number;
|
|
3
|
+
g: number;
|
|
4
|
+
b: number;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export interface PaletteColor extends RGBColor {
|
|
8
|
+
hex: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export interface QuantizationResult {
|
|
12
|
+
data: Uint8ClampedArray;
|
|
13
|
+
sourceColors: number;
|
|
14
|
+
mappedColors: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export const PRESET_PALETTES: Record<string, PaletteColor[]> = {
|
|
18
|
+
gameBoy: ['#0f380f', '#306230', '#8bac0f', '#9bbc0f'].map(createPresetPaletteColor),
|
|
19
|
+
nes: [
|
|
20
|
+
'#000000', '#fcfcfc', '#f8f8f8', '#b8b8b8', '#7c7c7c', '#a80000', '#e45c10', '#f8b800',
|
|
21
|
+
'#f8d878', '#00a800', '#58d854', '#b8f8b8', '#0058f8', '#00a8e8', '#3cbcfc', '#a4e4fc',
|
|
22
|
+
].map(createPresetPaletteColor),
|
|
23
|
+
pico8: [
|
|
24
|
+
'#000000', '#1d2b53', '#7e2553', '#008751', '#ab5236', '#5f574f', '#c2c3c7', '#fff1e8',
|
|
25
|
+
'#ff004d', '#ffa300', '#ffec27', '#00e436', '#29adff', '#83769c', '#ff77a8', '#ffccaa',
|
|
26
|
+
].map(createPresetPaletteColor),
|
|
27
|
+
commodore64: [
|
|
28
|
+
'#000000', '#ffffff', '#813338', '#75cec8', '#8e3c97', '#56ac4d', '#2e2c9b', '#edf171',
|
|
29
|
+
'#8e5029', '#553800', '#c46c71', '#4a4a4a', '#7b7b7b', '#a9ff9f', '#706deb', '#b2b2b2',
|
|
30
|
+
].map(createPresetPaletteColor),
|
|
31
|
+
dawnBringer16: [
|
|
32
|
+
'#140c1c', '#442434', '#30346d', '#4e4a4e', '#854c30', '#346524', '#d04648', '#757161',
|
|
33
|
+
'#597dce', '#d27d2c', '#8595a1', '#6daa2c', '#d2aa99', '#6dc2ca', '#dad45e', '#deeed6',
|
|
34
|
+
].map(createPresetPaletteColor),
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function createPaletteColor(hex: string): PaletteColor {
|
|
38
|
+
const rgb = hexToRgb(hex);
|
|
39
|
+
if (!rgb) {
|
|
40
|
+
throw new Error(`Invalid color: ${hex}`);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return { ...rgb, hex: rgbToHex(rgb) };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function hexToRgb(value: string): RGBColor | null {
|
|
47
|
+
const normalized = value.trim().replace(/^#/, '');
|
|
48
|
+
const expanded = normalized.length === 3
|
|
49
|
+
? normalized.split('').map((channel) => `${channel}${channel}`).join('')
|
|
50
|
+
: normalized;
|
|
51
|
+
|
|
52
|
+
if (!/^[0-9a-f]{6}$/i.test(expanded)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
r: Number.parseInt(expanded.slice(0, 2), 16),
|
|
58
|
+
g: Number.parseInt(expanded.slice(2, 4), 16),
|
|
59
|
+
b: Number.parseInt(expanded.slice(4, 6), 16),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function rgbToHex(color: RGBColor): string {
|
|
64
|
+
return `#${[color.r, color.g, color.b]
|
|
65
|
+
.map((channel) => Math.max(0, Math.min(255, Math.round(channel))).toString(16).padStart(2, '0'))
|
|
66
|
+
.join('')}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function parsePaletteInput(value: string): PaletteColor[] {
|
|
70
|
+
const colors = value
|
|
71
|
+
.split(/[\s,;]+/)
|
|
72
|
+
.map((token) => token.trim())
|
|
73
|
+
.filter(Boolean);
|
|
74
|
+
const seen = new Set<string>();
|
|
75
|
+
const palette: PaletteColor[] = [];
|
|
76
|
+
|
|
77
|
+
for (const color of colors) {
|
|
78
|
+
const parsed = hexToRgb(color);
|
|
79
|
+
if (!parsed) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const hex = rgbToHex(parsed);
|
|
83
|
+
if (!seen.has(hex)) {
|
|
84
|
+
seen.add(hex);
|
|
85
|
+
palette.push({ ...parsed, hex });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return palette;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function findNearestColor(source: RGBColor, palette: PaletteColor[]): PaletteColor | null {
|
|
93
|
+
if (palette.length === 0) {
|
|
94
|
+
return null;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
let nearest = palette[0];
|
|
98
|
+
let distance = colorDistanceSquared(source, nearest);
|
|
99
|
+
|
|
100
|
+
for (let index = 1; index < palette.length; index += 1) {
|
|
101
|
+
const candidate = palette[index];
|
|
102
|
+
const candidateDistance = colorDistanceSquared(source, candidate);
|
|
103
|
+
if (candidateDistance < distance) {
|
|
104
|
+
nearest = candidate;
|
|
105
|
+
distance = candidateDistance;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return nearest;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function colorDistanceSquared(first: RGBColor, second: RGBColor): number {
|
|
113
|
+
const red = first.r - second.r;
|
|
114
|
+
const green = first.g - second.g;
|
|
115
|
+
const blue = first.b - second.b;
|
|
116
|
+
return red * red + green * green + blue * blue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
interface QuantizePixelContext {
|
|
120
|
+
data: Uint8ClampedArray;
|
|
121
|
+
index: number;
|
|
122
|
+
palette: PaletteColor[];
|
|
123
|
+
preserveTransparency: boolean;
|
|
124
|
+
sourceColors: Set<string>;
|
|
125
|
+
mappedColors: Set<string>;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function processPixel(ctx: QuantizePixelContext): void {
|
|
129
|
+
const { data, index, palette, preserveTransparency, sourceColors, mappedColors } = ctx;
|
|
130
|
+
const alpha = data[index + 3];
|
|
131
|
+
if (alpha === 0 && preserveTransparency) return;
|
|
132
|
+
|
|
133
|
+
const sourceColor = { r: data[index], g: data[index + 1], b: data[index + 2] };
|
|
134
|
+
const mapped = findNearestColor(sourceColor, palette);
|
|
135
|
+
if (!mapped) return;
|
|
136
|
+
|
|
137
|
+
sourceColors.add(rgbToHex(sourceColor));
|
|
138
|
+
mappedColors.add(mapped.hex);
|
|
139
|
+
data[index] = mapped.r;
|
|
140
|
+
data[index + 1] = mapped.g;
|
|
141
|
+
data[index + 2] = mapped.b;
|
|
142
|
+
if (!preserveTransparency) data[index + 3] = 255;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function quantizeImageData(
|
|
146
|
+
source: Uint8ClampedArray,
|
|
147
|
+
palette: PaletteColor[],
|
|
148
|
+
preserveTransparency = true,
|
|
149
|
+
): QuantizationResult {
|
|
150
|
+
const data = new Uint8ClampedArray(source);
|
|
151
|
+
const sourceColors = new Set<string>();
|
|
152
|
+
const mappedColors = new Set<string>();
|
|
153
|
+
|
|
154
|
+
if (palette.length === 0) {
|
|
155
|
+
return { data, sourceColors: 0, mappedColors: 0 };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
for (let index = 0; index < data.length; index += 4) {
|
|
159
|
+
processPixel({ data, index, palette, preserveTransparency, sourceColors, mappedColors });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return { data, sourceColors: sourceColors.size, mappedColors: mappedColors.size };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function createPresetPaletteColor(hex: string): PaletteColor {
|
|
166
|
+
const rgb = hexToRgb(hex);
|
|
167
|
+
if (!rgb) {
|
|
168
|
+
throw new Error(`Invalid color: ${hex}`);
|
|
169
|
+
}
|
|
170
|
+
return { ...rgb, hex: rgbToHex(rgb) };
|
|
171
|
+
}
|