feat: add text\hex conver tools

This commit is contained in:
2026-08-23 10:40:46 +05:00
parent 3b18d0c6f8
commit 17b0c68eb5
6 changed files with 281 additions and 24 deletions
+9 -7
View File
@@ -91,12 +91,17 @@
lastRunSource = source; lastRunSource = source;
lastRunValuesJson = JSON.stringify(sanitized); lastRunValuesJson = JSON.stringify(sanitized);
try { try {
let next: PixelImage; let next: PixelImage | null = null;
if (isSourceless) { let nextText: string | null = null;
if (tool.resultType === 'text') {
nextText = await tool.toText!(source!, sanitized);
} else if (isSourceless) {
next = await tool.generate!(sanitized); next = await tool.generate!(sanitized);
} else { } else {
next = await tool.run(source!, sanitized); next = await tool.run!(source!, sanitized);
} }
let nextPreview: PixelImage | null = null; let nextPreview: PixelImage | null = null;
if (tool.preview && source) { if (tool.preview && source) {
try { try {
@@ -105,10 +110,7 @@
nextPreview = null; nextPreview = null;
} }
} }
let nextText: string | null = null;
if (tool.toText && source) {
nextText = await tool.toText(source, sanitized);
}
if (token !== runToken) return; if (token !== runToken) return;
result = next; result = next;
previewResult = nextPreview; previewResult = nextPreview;
+43 -3
View File
@@ -16,9 +16,7 @@ export function unsupportedImageMessage(file: File): string {
return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`; return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`;
} }
export async function decodeFile(file: File): Promise<PixelImage> { async function decodeBitmap(bitmap: ImageBitmap): Promise<PixelImage> {
const bitmap = await createImageBitmap(file);
try {
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = bitmap.width; canvas.width = bitmap.width;
canvas.height = bitmap.height; canvas.height = bitmap.height;
@@ -29,11 +27,53 @@ export async function decodeFile(file: File): Promise<PixelImage> {
ctx.drawImage(bitmap, 0, 0); ctx.drawImage(bitmap, 0, 0);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
return { width: imageData.width, height: imageData.height, data: imageData.data }; return { width: imageData.width, height: imageData.height, data: imageData.data };
}
export async function decodeFile(file: File): Promise<PixelImage> {
const bitmap = await createImageBitmap(file);
try {
return await decodeBitmap(bitmap);
} finally { } finally {
bitmap.close(); 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( export async function encode(
img: PixelImage, img: PixelImage,
mime: OutputMime = 'image/png', mime: OutputMime = 'image/png',
+47
View File
@@ -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();
});
});
+45
View File
@@ -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');
}
+20 -4
View File
@@ -21,11 +21,23 @@ const PLAN_TOOL_IDS = [
'convert-png-to-jpg', 'convert-png-to-jpg',
'convert-png-to-webp', 'convert-png-to-webp',
'remove-color-from-png', '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('реестр инструментов', () => { describe('реестр инструментов', () => {
it('содержит ровно 11 инструментов из плана MVP', () => { it('содержит ровно 23 инструмента из плана', () => {
expect(TOOLS.map((t) => t.id).sort()).toEqual([...PLAN_TOOL_IDS].sort()); 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); expect(CATEGORIES.map((c) => c.id)).toContain(tool.category);
}); });
it.each(TOOLS.map((t) => [t.id, t] as const))('%s: run определён', (_, tool) => { 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'); expect(typeof tool.run).toBe('function');
}
if (tool.preview) { if (tool.preview) {
expect(typeof tool.preview).toBe('function'); expect(typeof tool.preview).toBe('function');
} }
@@ -144,7 +160,7 @@ describe('sanitizeParams', () => {
describe('run инструмента resize-png', () => { describe('run инструмента resize-png', () => {
const img = { width: 100, height: 50, data: new Uint8ClampedArray(100 * 50 * 4) }; const img = { width: 100, height: 50, data: new Uint8ClampedArray(100 * 50 * 4) };
const runResize = async (params: Record<string, unknown>) => const runResize = async (params: Record<string, unknown>) =>
getToolOrThrow('resize-png').run(img, params); getToolOrThrow('resize-png').run!(img, params);
it('keepAspect + одна сторона — вторая считается по пропорции', async () => { it('keepAspect + одна сторона — вторая считается по пропорции', async () => {
const out = await runResize({ width: 200, height: 0, keepAspect: true }); const out = await runResize({ width: 200, height: 0, keepAspect: true });
+109 -2
View File
@@ -2,7 +2,8 @@ import type { CategoryId } from './categories';
import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha'; import { colorMask, flattenOntoColor, removeColorToAlpha } from './core/alpha';
import { brightnessContrast, grayscale, invert } from './core/color'; import { brightnessContrast, grayscale, invert } from './core/color';
import { crop, flip, resize, rotate90 } from './core/geometry'; 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'; import { clonePixelImage, type PixelImage } from './core/types';
export type ParamDef = export type ParamDef =
@@ -49,7 +50,7 @@ export type ToolEntry = {
category: CategoryId; category: CategoryId;
sourceMode?: SourceMode; sourceMode?: SourceMode;
params: ParamDef[]; 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; generate?: (params: Record<string, unknown>) => Promise<PixelImage> | PixelImage;
toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string; toText?: (img: PixelImage, params: Record<string, unknown>) => Promise<string> | string;
runFromText?: (text: string, params: Record<string, unknown>) => Promise<PixelImage> | PixelImage; 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; 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[] = [ 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', id: 'resize-png',
title: 'Изменить размер PNG', title: 'Изменить размер PNG',