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
+50 -10
View File
@@ -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',
+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');
}