feat: add text\hex conver tools
This commit is contained in:
@@ -91,12 +91,17 @@
|
||||
lastRunSource = source;
|
||||
lastRunValuesJson = JSON.stringify(sanitized);
|
||||
try {
|
||||
let next: PixelImage;
|
||||
if (isSourceless) {
|
||||
let next: PixelImage | null = null;
|
||||
let nextText: string | null = null;
|
||||
|
||||
if (tool.resultType === 'text') {
|
||||
nextText = await tool.toText!(source!, sanitized);
|
||||
} else if (isSourceless) {
|
||||
next = await tool.generate!(sanitized);
|
||||
} else {
|
||||
next = await tool.run(source!, sanitized);
|
||||
next = await tool.run!(source!, sanitized);
|
||||
}
|
||||
|
||||
let nextPreview: PixelImage | null = null;
|
||||
if (tool.preview && source) {
|
||||
try {
|
||||
@@ -105,10 +110,7 @@
|
||||
nextPreview = null;
|
||||
}
|
||||
}
|
||||
let nextText: string | null = null;
|
||||
if (tool.toText && source) {
|
||||
nextText = await tool.toText(source, sanitized);
|
||||
}
|
||||
|
||||
if (token !== runToken) return;
|
||||
result = next;
|
||||
previewResult = nextPreview;
|
||||
|
||||
+50
-10
@@ -16,24 +16,64 @@ export function unsupportedImageMessage(file: File): string {
|
||||
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
|
||||
}
|
||||
|
||||
async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||
}
|
||||
|
||||
export async function decodeFile(file: File): Promise<PixelImage> {
|
||||
const bitmap = await createImageBitmap(file);
|
||||
try {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = bitmap.width;
|
||||
canvas.height = bitmap.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
}
|
||||
ctx.drawImage(bitmap, 0, 0);
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
return { width: imageData.width, height: imageData.height, data: imageData.data };
|
||||
return await decodeBitmap(bitmap);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
export async function decodeBytes(bytes: Uint8Array<ArrayBuffer>): Promise<PixelImage> {
|
||||
const blob = new Blob([bytes]);
|
||||
const bitmap = await createImageBitmap(blob);
|
||||
try {
|
||||
return await decodeBitmap(bitmap);
|
||||
} finally {
|
||||
bitmap.close();
|
||||
}
|
||||
}
|
||||
|
||||
export function toDataUrl(img: PixelImage): string {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = img.width;
|
||||
canvas.height = img.height;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
throw new Error('Canvas 2D context недоступен в этом браузере');
|
||||
}
|
||||
ctx.putImageData(new ImageData(img.data, img.width, img.height), 0, 0);
|
||||
return canvas.toDataURL('image/png');
|
||||
}
|
||||
|
||||
export function toBase64(img: PixelImage): string {
|
||||
return toDataUrl(img).slice('data:image/png;base64,'.length);
|
||||
}
|
||||
|
||||
export async function decodeTextImage(text: string): Promise<PixelImage> {
|
||||
const cleaned = text.trim().replace(/^data:[^,]*,/, '');
|
||||
if (cleaned.length === 0) {
|
||||
throw new Error('Вставьте base64-строку или data-uri изображения');
|
||||
}
|
||||
const binary = atob(cleaned);
|
||||
const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0));
|
||||
return await decodeBytes(bytes);
|
||||
}
|
||||
|
||||
export async function encode(
|
||||
img: PixelImage,
|
||||
mime: OutputMime = 'image/png',
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { hexToPixels, pixelsToHex } from './text';
|
||||
import { makeImage } from './test-helpers';
|
||||
|
||||
describe('pixelsToHex', () => {
|
||||
it('форматирует пиксели как rrggbbaa построчно', () => {
|
||||
const out = pixelsToHex(
|
||||
makeImage(2, 2, [
|
||||
[255, 0, 0, 255],
|
||||
[0, 255, 0, 200],
|
||||
[16, 32, 48, 64],
|
||||
[0, 0, 0, 0]
|
||||
])
|
||||
);
|
||||
expect(out).toBe('ff0000ff 00ff00c8\n10203040 00000000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('hexToPixels', () => {
|
||||
it('обратим к pixelsToHex', () => {
|
||||
const source = makeImage(3, 1, [
|
||||
[1, 2, 3, 4],
|
||||
[250, 251, 252, 253],
|
||||
[9, 9, 9, 128]
|
||||
]);
|
||||
expect(hexToPixels(pixelsToHex(source), 3)).toEqual(source);
|
||||
});
|
||||
|
||||
it('допускает произвольные переводы строк и регистр', () => {
|
||||
const out = hexToPixels('FF0000FF\n\n00FF0080 00000080', 1);
|
||||
expect(out.width).toBe(1);
|
||||
expect(out.height).toBe(3);
|
||||
expect([...out.data]).toEqual([
|
||||
255, 0, 0, 255,
|
||||
0, 255, 0, 128,
|
||||
0, 0, 0, 128
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['ff0000', 'битые токены'],
|
||||
['ff0000ff ff0000ff ff0000ff', 'не делится на ширину'],
|
||||
['', 'пустой ввод']
|
||||
])('бросает понятную ошибку: %s (%s)', (input) => {
|
||||
expect(() => hexToPixels(input, 2)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { PixelImage } from './types';
|
||||
|
||||
export function pixelsToHex(img: PixelImage): string {
|
||||
const rows: string[] = [];
|
||||
for (let y = 0; y < img.height; y++) {
|
||||
const parts: string[] = [];
|
||||
for (let x = 0; x < img.width; x++) {
|
||||
const i = (y * img.width + x) * 4;
|
||||
parts.push(byteHex(img.data[i]) + byteHex(img.data[i + 1]) + byteHex(img.data[i + 2]) + byteHex(img.data[i + 3]));
|
||||
}
|
||||
rows.push(parts.join(' '));
|
||||
}
|
||||
return rows.join('\n');
|
||||
}
|
||||
|
||||
export function hexToPixels(text: string, width: number): PixelImage {
|
||||
if (!Number.isInteger(width) || width < 1) {
|
||||
throw new Error('Укажите ширину изображения (целое число >= 1)');
|
||||
}
|
||||
const tokens = text.trim().split(/\s+/).filter((t) => t.length > 0);
|
||||
if (tokens.length === 0) {
|
||||
throw new Error('Вставьте hex-данные пикселей');
|
||||
}
|
||||
if (tokens.some((t) => !/^[0-9a-fA-F]{8}$/.test(t))) {
|
||||
throw new Error('Каждый пиксель должен быть 8 hex-символов RRGGBBAA, разделённых пробелами');
|
||||
}
|
||||
const height = tokens.length / width;
|
||||
if (!Number.isInteger(height)) {
|
||||
throw new Error(
|
||||
`Число пикселей (${tokens.length}) не делится на ширину ${width} без остатка`
|
||||
);
|
||||
}
|
||||
const data = new Uint8ClampedArray(tokens.length * 4);
|
||||
tokens.forEach((token, index) => {
|
||||
data[index * 4] = parseInt(token.slice(0, 2), 16);
|
||||
data[index * 4 + 1] = parseInt(token.slice(2, 4), 16);
|
||||
data[index * 4 + 2] = parseInt(token.slice(4, 6), 16);
|
||||
data[index * 4 + 3] = parseInt(token.slice(6, 8), 16);
|
||||
});
|
||||
return { width, height, data };
|
||||
}
|
||||
|
||||
function byteHex(value: number): string {
|
||||
return value.toString(16).padStart(2, '0');
|
||||
}
|
||||
@@ -21,11 +21,23 @@ const PLAN_TOOL_IDS = [
|
||||
'convert-png-to-jpg',
|
||||
'convert-png-to-webp',
|
||||
'remove-color-from-png',
|
||||
'png-info'
|
||||
'png-info',
|
||||
'jpg-to-png',
|
||||
'webp-to-png',
|
||||
'gif-to-png',
|
||||
'bmp-to-png',
|
||||
'ico-to-png',
|
||||
'png-to-bmp',
|
||||
'png-to-base64',
|
||||
'base64-to-png',
|
||||
'png-to-data-uri',
|
||||
'data-uri-to-png',
|
||||
'png-to-hex',
|
||||
'hex-to-png'
|
||||
];
|
||||
|
||||
describe('реестр инструментов', () => {
|
||||
it('содержит ровно 11 инструментов из плана MVP', () => {
|
||||
it('содержит ровно 23 инструмента из плана', () => {
|
||||
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort());
|
||||
});
|
||||
|
||||
@@ -41,8 +53,12 @@ describe('реестр инструментов', () => {
|
||||
expect(CATEGORIES.map((c) => c.id)).toContain(tool.category);
|
||||
});
|
||||
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => {
|
||||
expect(typeof tool.run).toBe('function');
|
||||
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: исполнители определены', (_, tool) => {
|
||||
if (tool.resultType === 'text') {
|
||||
expect(typeof tool.toText).toBe('function');
|
||||
} else {
|
||||
expect(typeof tool.run).toBe('function');
|
||||
}
|
||||
if (tool.preview) {
|
||||
expect(typeof tool.preview).toBe('function');
|
||||
}
|
||||
@@ -144,7 +160,7 @@ describe('sanitizeParams', () => {
|
||||
describe('run инструмента resize-png', () => {
|
||||
const img = { width: 100, height: 50, data: new Uint8ClampedArray(100 * 50 * 4) };
|
||||
const runResize = async (params: Record<string, unknown>) =>
|
||||
getToolOrThrow('resize-png').run(img, params);
|
||||
getToolOrThrow('resize-png').run!(img, params);
|
||||
|
||||
it('keepAspect + одна сторона — вторая считается по пропорции', async () => {
|
||||
const out = await runResize({ width: 200, height: 0, keepAspect: true });
|
||||
|
||||
+109
-2
@@ -2,7 +2,8 @@ import type { CategoryId } from './categories';
|
||||
import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha';
|
||||
import { brightnessContrast, grayscale, invert } from './core/color';
|
||||
import { crop, flip, resize, rotate90 } from './core/geometry';
|
||||
import type { OutputMime } from './core/io';
|
||||
import { decodeTextImage, toBase64, toDataUrl, type OutputMime } from './core/io';
|
||||
import { hexToPixels, pixelsToHex } from './core/text';
|
||||
import { clonePixelImage, type PixelImage } from './core/types';
|
||||
|
||||
export type ParamDef =
|
||||
@@ -49,7 +50,7 @@ export type ToolEntry = {
|
||||
category: CategoryId;
|
||||
sourceMode?: SourceMode;
|
||||
params: ParamDef[];
|
||||
run: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
run?: (img: PixelImage, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
generate?: (params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string;
|
||||
runFromText?: (text: string, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
|
||||
@@ -79,7 +80,113 @@ function str(params: Record<string, unknown>, id: string): string {
|
||||
return v;
|
||||
}
|
||||
|
||||
function decodeToPng(id: string, title: string, description: string): ToolEntry {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
description,
|
||||
category: 'convert',
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img)
|
||||
};
|
||||
}
|
||||
|
||||
export const TOOLS: ToolEntry[] = [
|
||||
decodeToPng(
|
||||
'jpg-to-png',
|
||||
'Конвертировать JPG в PNG',
|
||||
'Открывает JPEG и сохраняет его как PNG без потерь. Прозрачность, если была, сохраняется.'
|
||||
),
|
||||
decodeToPng(
|
||||
'webp-to-png',
|
||||
'Конвертировать WebP в PNG',
|
||||
'Перекодирует WebP-изображение в универсальный PNG.'
|
||||
),
|
||||
decodeToPng(
|
||||
'gif-to-png',
|
||||
'Конвертировать GIF в PNG',
|
||||
'Dостаёт первый кадр GIF-анимации и сохраняет его как PNG.'
|
||||
),
|
||||
decodeToPng(
|
||||
'bmp-to-png',
|
||||
'Конвертировать BMP в PNG',
|
||||
'Перекодирует BMP в компактный PNG без потерь.'
|
||||
),
|
||||
decodeToPng(
|
||||
'ico-to-png',
|
||||
'Конвертировать ICO в PNG',
|
||||
'Превращает иконку .ico в обычный PNG нужного размера.'
|
||||
),
|
||||
{
|
||||
id: 'png-to-bmp',
|
||||
title: 'Конвертировать PNG в BMP',
|
||||
description:
|
||||
'Sохраняет изображение в 24-битный BMP без альфа-канала: прозрачность заменяется чёрным фоном.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
run: (img) => flattenOntoColor(img, '#000000'),
|
||||
output: { mime: 'image/bmp', ext: 'bmp' }
|
||||
},
|
||||
{
|
||||
id: 'png-to-base64',
|
||||
title: 'PNG в Base64',
|
||||
description: 'Кодирует изображение в base64-строку для вставки в код или стили.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => toBase64(img)
|
||||
},
|
||||
{
|
||||
id: 'base64-to-png',
|
||||
title: 'Base64 в PNG',
|
||||
description:
|
||||
'Dекодирует base64-строку или data-uri обратно в картинку. Вставьте строку слева.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'png-to-data-uri',
|
||||
title: 'PNG в Data URI',
|
||||
description: 'Строит полный data-uri (data:image/png;base64,…) для встраивания в HTML/CSS.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => toDataUrl(img)
|
||||
},
|
||||
{
|
||||
id: 'data-uri-to-png',
|
||||
title: 'Data URI в PNG',
|
||||
description: 'Dекодирует data:image/…;base64,… обратно в файл картинки.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [],
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'png-to-hex',
|
||||
title: 'PNG в HEX-пиксели',
|
||||
description:
|
||||
'Показывает все пиксели как hex-значения rrggbbaa — по строкам, через пробел.',
|
||||
category: 'convert',
|
||||
params: [],
|
||||
resultType: 'text',
|
||||
toText: (img) => pixelsToHex(img)
|
||||
},
|
||||
{
|
||||
id: 'hex-to-png',
|
||||
title: 'HEX-пиксели в PNG',
|
||||
description:
|
||||
'Sобирает картинку из hex-значений rrggbbaa (через пробел). Укажите ширину — высота рассчитается сама.',
|
||||
category: 'convert',
|
||||
sourceMode: 'text',
|
||||
params: [
|
||||
{ id: 'width', label: 'Ширина изображения', type: 'number', min: 1, max: 10000, step: 1, default: 1 }
|
||||
],
|
||||
runFromText: (text, p) => hexToPixels(text, Math.trunc(Number(p['width']))),
|
||||
run: (img) => clonePixelImage(img)
|
||||
},
|
||||
{
|
||||
id: 'resize-png',
|
||||
title: 'Изменить размер PNG',
|
||||
|
||||
Reference in New Issue
Block a user