diff --git a/web/src/lib/components/ToolPage.svelte b/web/src/lib/components/ToolPage.svelte index f865d88..5fc6aff 100644 --- a/web/src/lib/components/ToolPage.svelte +++ b/web/src/lib/components/ToolPage.svelte @@ -6,6 +6,7 @@ import ParamsCard from './tool/ParamsCard.svelte'; import ResultCard from './tool/ResultCard.svelte'; import SourceCard from './tool/SourceCard.svelte'; + import TextInputCard from './tool/TextInputCard.svelte'; let { tool }: { tool: ToolEntry } = $props(); @@ -15,15 +16,19 @@ let source = $state(null); let result = $state(null); let previewResult = $state(null); + let textResult = $state(null); let showMask = $state(false); let info = $state(null); let errorText = $state(''); let values = $state>({}); const isInfo = $derived(tool.resultType === 'info'); + const isSourceless = $derived(tool.sourceMode === 'none'); + const isTextSource = $derived(tool.sourceMode === 'text'); const sanitized = $derived(sanitizeParams(tool, values)); let runToken = 0; + let hasLastRun = false; let lastRunSource: PixelImage | null = null; let lastRunValuesJson = ''; let pipetteTargetId = $state(null); @@ -59,24 +64,55 @@ } } + async function handleTextSubmit(text: string) { + if (!isTextSource || !tool.runFromText) return; + errorText = ''; + status = 'processing'; + try { + source = await tool.runFromText(text, defaultParams(tool)); + values = defaultParams(tool); + result = null; + textResult = null; + previewResult = null; + showMask = false; + pipetteTargetId = null; + info = null; + await runTool(); + } catch (e) { + showError(e); + } + } + async function runTool() { - if (!source || isInfo) return; + if (isInfo) return; + if (!source && !isSourceless) return; const token = ++runToken; + hasLastRun = true; lastRunSource = source; lastRunValuesJson = JSON.stringify(sanitized); try { - const next = await tool.run(source, sanitized); + let next: PixelImage; + if (isSourceless) { + next = await tool.generate!(sanitized); + } else { + next = await tool.run(source!, sanitized); + } let nextPreview: PixelImage | null = null; - if (tool.preview) { + if (tool.preview && source) { try { nextPreview = await tool.preview(source, sanitized); } catch { 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; + textResult = nextText; status = 'loaded'; } catch (e) { if (token !== runToken) return; @@ -86,8 +122,9 @@ $effect(() => { const valuesJson = JSON.stringify(sanitized); - if (source === lastRunSource && valuesJson === lastRunValuesJson) return; - if (!source || isInfo) return; + if (hasLastRun && source === lastRunSource && valuesJson === lastRunValuesJson) return; + if (!source && !isSourceless) return; + if (isInfo) return; const timer = setTimeout(() => void runTool(), 300); return () => clearTimeout(timer); }); @@ -101,6 +138,7 @@ source = null; result = null; previewResult = null; + textResult = null; showMask = false; pipetteTargetId = null; info = null; @@ -138,21 +176,27 @@ {/if} -
-
- (errorText = message)} - onReset={reset} - pipetteActive={!!pipetteTargetId} - onPickColor={handlePickColor} - /> -
+
+ {#if !isSourceless} +
+ {#if isTextSource && !source} + + {:else} + (errorText = message)} + onReset={reset} + pipetteActive={!!pipetteTargetId} + onPickColor={handlePickColor} + /> + {/if} +
+ {/if}
- {#if source && !isInfo} + {#if (source || isSourceless) && !isInfo} ; + textResult: string | null; onDownloadError: (e: unknown) => void; } @@ -33,6 +35,7 @@ info, isInfo, params, + textResult, onDownloadError }: Props = $props(); @@ -62,6 +65,12 @@ {/if}
+ {:else if tool.resultType === 'text'} +
+ {#if textResult !== null} + + {/if} +
{:else} {#if hasPreview} diff --git a/web/src/lib/components/tool/TextInputCard.svelte b/web/src/lib/components/tool/TextInputCard.svelte new file mode 100644 index 0000000..8725d22 --- /dev/null +++ b/web/src/lib/components/tool/TextInputCard.svelte @@ -0,0 +1,57 @@ + + +
+

Текст

+ + +
+ + diff --git a/web/src/lib/components/tool/TextResult.svelte b/web/src/lib/components/tool/TextResult.svelte new file mode 100644 index 0000000..43693f5 --- /dev/null +++ b/web/src/lib/components/tool/TextResult.svelte @@ -0,0 +1,88 @@ + + +
+ +
+ + +
+
+ + diff --git a/web/src/lib/core/bmp.test.ts b/web/src/lib/core/bmp.test.ts new file mode 100644 index 0000000..fb83053 --- /dev/null +++ b/web/src/lib/core/bmp.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { encodeBmpBytes } from './bmp'; +import { makeImage } from './test-helpers'; + +describe('encodeBmpBytes', () => { + it('пишет заголовки BM и размеры для 2x1 без паддинга-неопределённости', () => { + const bytes = encodeBmpBytes( + makeImage(2, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255] + ]) + ); + expect([bytes[0], bytes[1]]).toEqual([0x42, 0x4d]); + const view = new DataView(bytes.buffer); + expect(view.getUint32(2, true)).toBe(54 + 8); + expect(view.getInt32(18, true)).toBe(2); + expect(view.getInt32(22, true)).toBe(1); + expect(view.getUint16(28, true)).toBe(24); + }); + + it('хранит пиксели в BGR снизу-вверх с паддингом строки', () => { + const bytes = encodeBmpBytes( + makeImage(2, 1, [ + [255, 0, 0, 255], + [0, 255, 0, 255] + ]) + ); + expect([...bytes.slice(54, 60)]).toEqual([ + 0, 0, 255, + 0, 255, 0 + ]); + expect(bytes[60]).toBe(0); + expect(bytes[61]).toBe(0); + }); + + it('нижняя строка изображения идёт первой в файле', () => { + const bytes = encodeBmpBytes( + makeImage(4, 2, [ + [10, 10, 10, 255], + [20, 20, 20, 255], + [30, 30, 30, 255], + [40, 40, 40, 255], + [50, 50, 50, 255], + [60, 60, 60, 255], + [70, 70, 70, 255], + [80, 80, 80, 255] + ]) + ); + expect(bytes[54]).toBe(50); + expect(bytes[54 + 12]).toBe(10); + }); +}); diff --git a/web/src/lib/core/bmp.ts b/web/src/lib/core/bmp.ts new file mode 100644 index 0000000..9473bd6 --- /dev/null +++ b/web/src/lib/core/bmp.ts @@ -0,0 +1,37 @@ +import type { PixelImage } from './types'; + +export function encodeBmpBytes(img: PixelImage): Uint8Array { + const rowSize = Math.ceil((img.width * 3) / 4) * 4; + const pixelArraySize = rowSize * img.height; + const fileSize = 54 + pixelArraySize; + + const buf = new Uint8Array(fileSize); + const view = new DataView(buf.buffer); + + buf[0] = 0x42; + buf[1] = 0x4d; + view.setUint32(2, fileSize, true); + view.setUint32(10, 54, true); + view.setUint32(14, 40, true); + view.setInt32(18, img.width, true); + view.setInt32(22, img.height, true); + view.setUint16(26, 1, true); + view.setUint16(28, 24, true); + view.setUint32(30, 0, true); + view.setUint32(34, pixelArraySize, true); + view.setInt32(38, 2835, true); + view.setInt32(42, 2835, true); + + for (let y = 0; y < img.height; y++) { + const srcY = img.height - 1 - y; + let off = 54 + y * rowSize; + for (let x = 0; x < img.width; x++) { + const i = (srcY * img.width + x) * 4; + buf[off++] = img.data[i + 2]; + buf[off++] = img.data[i + 1]; + buf[off++] = img.data[i]; + } + } + + return buf; +} diff --git a/web/src/lib/core/io.ts b/web/src/lib/core/io.ts index f2094d4..885715b 100644 --- a/web/src/lib/core/io.ts +++ b/web/src/lib/core/io.ts @@ -1,6 +1,7 @@ +import { encodeBmpBytes } from './bmp'; import { type PixelImage } from './types'; -export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp'; +export type OutputMime = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/bmp'; export const ACCEPTED_IMAGE_TYPES = 'image/png,image/jpeg,image/webp,image/gif,image/bmp,image/x-icon'; @@ -38,6 +39,9 @@ export async function encode( mime: OutputMime = 'image/png', quality?: number ): Promise { + if (mime === 'image/bmp') { + return new Blob([encodeBmpBytes(img)], { type: mime }); + } if (mime === 'image/jpeg' || mime === 'image/webp') { if (quality !== undefined && (quality < 0 || quality > 1)) { throw new RangeError('quality должен быть в диапазоне 0..1'); diff --git a/web/src/lib/registry.ts b/web/src/lib/registry.ts index ba86b57..f2d101f 100644 --- a/web/src/lib/registry.ts +++ b/web/src/lib/registry.ts @@ -40,18 +40,24 @@ export type OutputFormat = { qualityParamId?: string; }; +export type SourceMode = 'file' | 'none' | 'text'; + export type ToolEntry = { id: string; title: string; description: string; category: CategoryId; + sourceMode?: SourceMode; params: ParamDef[]; 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; preview?: ( img: PixelImage, params: Record ) => Promise | PixelImage; - resultType?: 'image' | 'info'; + resultType?: 'image' | 'info' | 'text'; output?: OutputFormat; };