diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte index 5fc6aff..a4d1648 100644 --- a/web/src/lib/components/ToolPage.svelte +++ b/web/src/lib/components/ToolPage.svelte @@ -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; diff --git a/web/src/lib/core/io.ts b/web/src/lib/core/io.ts index 885715b..2c0f0a8 100644 --- a/web/src/lib/core/io.ts +++ b/web/src/lib/core/io.ts @@ -16,24 +16,64 @@ export function unsupportedImageMessage(file: File): string { return `Неподдерживаемый формат файла (${file.type || 'неизвестный'}). Поддерживаются PNG, JPEG, WebP, GIF и BMP.`; } +async function decodeBitmap(bitmap: ImageBitmap): Promise { + 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 { 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): Promise { + 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 { + 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', diff --git a/web/src/lib/core/text.test.ts b/web/src/lib/core/text.test.ts new file mode 100644 index 0000000..0c02d61 --- /dev/null +++ b/web/src/lib/core/text.test.ts @@ -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(); + }); +}); diff --git a/web/src/lib/core/text.ts b/web/src/lib/core/text.ts new file mode 100644 index 0000000..2254490 --- /dev/null +++ b/web/src/lib/core/text.ts @@ -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'); +} diff --git a/web/src/lib/registry.test.ts b/web/src/lib/registry.test.ts index 9dc6aa3..d37743f 100644 --- a/web/src/lib/registry.test.ts +++ b/web/src/lib/registry.test.ts @@ -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) => - 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 }); diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index f2d101f..9946925 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -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) => Promise | PixelImage; + run?: (img: PixelImage, params: Record) => Promise | PixelImage; generate?: (params: Record) => Promise | PixelImage; toText?: (img: PixelImage, params: Record) => Promise | string; runFromText?: (text: string, params: Record) => Promise | PixelImage; @@ -79,7 +80,113 @@ function str(params: Record, 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',