From eadbfb0f862fbca01aa0fc7631a7ebfa187ac343 Mon Sep 17 00:00:00 2001 From: Sejong Yoo Date: Mon, 14 Sep 2026 12:04:56 +0900 Subject: [PATCH] Support Windows ICO in image edit and background remove. Decode the largest BMP or PNG frame for editing, export PNG-in-ICO, and open .ico from the home drop zone. Co-authored-by: Cursor --- .../src/composables/imageEditSession.ts | 11 + .../src/composables/useOpenDocument.ts | 6 + packages/frontend/src/config/app.ts | 3 +- packages/frontend/src/config/favicons.ts | 1 + .../src/integrations/imageedit/ico.ts | 227 ++++++++++++++++++ .../src/integrations/imageedit/process.ts | 18 +- packages/frontend/src/views/HomeView.vue | 4 +- .../frontend/src/views/ToolsBgRemoveView.vue | 21 +- .../frontend/src/views/ToolsImageEditView.vue | 47 +++- 9 files changed, 319 insertions(+), 19 deletions(-) create mode 100644 packages/frontend/src/composables/imageEditSession.ts create mode 100644 packages/frontend/src/integrations/imageedit/ico.ts diff --git a/packages/frontend/src/composables/imageEditSession.ts b/packages/frontend/src/composables/imageEditSession.ts new file mode 100644 index 0000000..abf141c --- /dev/null +++ b/packages/frontend/src/composables/imageEditSession.ts @@ -0,0 +1,11 @@ +let pending: File | null = null; + +export function stageImageEditFile(file: File) { + pending = file; +} + +export function consumeImageEditFile(): File | null { + const file = pending; + pending = null; + return file; +} diff --git a/packages/frontend/src/composables/useOpenDocument.ts b/packages/frontend/src/composables/useOpenDocument.ts index e9d64a6..521d8da 100644 --- a/packages/frontend/src/composables/useOpenDocument.ts +++ b/packages/frontend/src/composables/useOpenDocument.ts @@ -19,6 +19,7 @@ import { storeHwpFile, } from '@/composables/useSessionLifecycle'; import { stageDiagramFile } from '@/composables/diagramSession'; +import { stageImageEditFile } from '@/composables/imageEditSession'; import { stageScannerFiles } from '@/composables/scannerSession'; import { stageViewerFiles } from '@/composables/viewerSession'; @@ -63,6 +64,11 @@ export function useOpenDocument() { await router.push({ name: 'tools-scanner' }); return true; } + if (toolKind === 'imageedit') { + stageImageEditFile(file); + await router.push({ name: 'tools-image-edit' }); + return true; + } const diagramKind = diagramKindFor(file.name); if (diagramKind) { diff --git a/packages/frontend/src/config/app.ts b/packages/frontend/src/config/app.ts index 25a58ad..ed766eb 100644 --- a/packages/frontend/src/config/app.ts +++ b/packages/frontend/src/config/app.ts @@ -7,7 +7,7 @@ export const APP_DISPLAY_NAME = "Sejong Office"; export type DiagramKind = "excalidraw" | "mermaid" | "drawio"; export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop"; -export type ToolKind = "scanner"; +export type ToolKind = "scanner" | "imageedit"; const DIAGRAM_EXTENSION_KIND: Record = { excalidraw: "excalidraw", @@ -34,6 +34,7 @@ const TOOL_EXTENSION_KIND: Record = { webp: "scanner", gif: "scanner", bmp: "scanner", + ico: "imageedit", }; /** Online3DViewer import formats (+ zip container). */ diff --git a/packages/frontend/src/config/favicons.ts b/packages/frontend/src/config/favicons.ts index cdd2b4e..7f164a6 100644 --- a/packages/frontend/src/config/favicons.ts +++ b/packages/frontend/src/config/favicons.ts @@ -159,6 +159,7 @@ const EXT_KIND: Record = { bmp: 'scanner', bgremove: 'bgremove', imgedit: 'imgedit', + ico: 'imgedit', txt: 'text', }; diff --git a/packages/frontend/src/integrations/imageedit/ico.ts b/packages/frontend/src/integrations/imageedit/ico.ts new file mode 100644 index 0000000..73d0a3f --- /dev/null +++ b/packages/frontend/src/integrations/imageedit/ico.ts @@ -0,0 +1,227 @@ +const PNG_SIG = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]; + +export function isIcoFile(file: File) { + return ( + /image\/(x-icon|vnd\.microsoft\.icon|icon)/i.test(file.type) || + /\.ico$/i.test(file.name) + ); +} + +function isPng(bytes: Uint8Array) { + return ( + bytes.length >= 8 && PNG_SIG.every((value, index) => bytes[index] === value) + ); +} + +function rowStride(width: number, bits: number) { + return (((width * bits + 31) >> 5) << 2); +} + +function decodeDib(bytes: Uint8Array): ImageData { + if (bytes.length < 40) { + throw new Error('ICO 비트맵 헤더가 올바르지 않습니다.'); + } + const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); + const headerSize = view.getUint32(0, true); + if (headerSize < 40) { + throw new Error('지원하지 않는 ICO 비트맵입니다.'); + } + const width = Math.abs(view.getInt32(4, true)); + const rawHeight = view.getInt32(8, true); + const bitCount = view.getUint16(14, true); + const compression = view.getUint32(16, true); + if (!(width > 0) || compression !== 0) { + throw new Error('압축된 ICO 비트맵은 열 수 없습니다.'); + } + + const storedHeight = Math.abs(rawHeight) || 1; + const bottomUp = rawHeight >= 0; + const paletteColors = + bitCount <= 8 + ? view.getUint32(32, true) || 1 << bitCount + : 0; + let offset = headerSize + paletteColors * 4; + const xorStride = rowStride(width, bitCount); + const remaining = Math.max(0, bytes.length - offset); + let height = storedHeight; + if (storedHeight % 2 === 0) { + const half = storedHeight / 2; + const xorHalf = xorStride * half; + const andHalf = rowStride(width, 1) * half; + if (xorHalf + andHalf <= remaining || xorHalf <= remaining) { + height = half; + } + } + const xorSize = xorStride * height; + if (!(height > 0) || offset + xorSize > bytes.length) { + throw new Error('ICO 비트맵 데이터가 잘렸습니다.'); + } + + const palette: Array<[number, number, number, number]> = []; + for (let i = 0; i < paletteColors; i += 1) { + const p = headerSize + i * 4; + palette.push([bytes[p + 2]!, bytes[p + 1]!, bytes[p]!, 255]); + } + + const pixels = new Uint8ClampedArray(width * height * 4); + const andOffset = offset + xorSize; + const andStride = rowStride(width, 1); + const hasAnd = andOffset + andStride * height <= bytes.length; + + function xorPixel(x: number, y: number): [number, number, number, number] { + const row = bottomUp ? height - 1 - y : y; + const rowStart = offset + row * xorStride; + if (bitCount === 32) { + const i = rowStart + x * 4; + return [bytes[i + 2]!, bytes[i + 1]!, bytes[i]!, bytes[i + 3]!]; + } + if (bitCount === 24) { + const i = rowStart + x * 3; + return [bytes[i + 2]!, bytes[i + 1]!, bytes[i]!, 255]; + } + if (bitCount === 8) { + return palette[bytes[rowStart + x]!] ?? [0, 0, 0, 255]; + } + if (bitCount === 4) { + const byte = bytes[rowStart + (x >> 1)]!; + const index = x & 1 ? byte & 0x0f : byte >> 4; + return palette[index] ?? [0, 0, 0, 255]; + } + if (bitCount === 1) { + const byte = bytes[rowStart + (x >> 3)]!; + const index = (byte >> (7 - (x & 7))) & 1; + return palette[index] ?? [0, 0, 0, 255]; + } + throw new Error(`지원하지 않는 ICO 색 깊이입니다 (${bitCount}bit).`); + } + + function andTransparent(x: number, y: number) { + if (!hasAnd) return false; + const row = bottomUp ? height - 1 - y : y; + const byte = bytes[andOffset + row * andStride + (x >> 3)]!; + return ((byte >> (7 - (x & 7))) & 1) === 1; + } + + let anyAlpha = false; + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const [r, g, b, a] = xorPixel(x, y); + if (a > 0 && a < 255) anyAlpha = true; + if (a === 255) anyAlpha = anyAlpha || bitCount === 32; + const i = (y * width + x) * 4; + pixels[i] = r; + pixels[i + 1] = g; + pixels[i + 2] = b; + pixels[i + 3] = a; + } + } + + const useAnd = bitCount < 32 || !anyAlpha; + if (useAnd) { + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + if (andTransparent(x, y)) { + pixels[(y * width + x) * 4 + 3] = 0; + } + } + } + } + + return new ImageData(pixels, width, height); +} + +function canvasFromImageData(data: ImageData) { + const canvas = document.createElement('canvas'); + canvas.width = data.width; + canvas.height = data.height; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('캔버스를 만들 수 없습니다.'); + ctx.putImageData(data, 0, 0); + return canvas; +} + +function pngBlobFromCanvas(canvas: HTMLCanvasElement) { + return new Promise((resolve, reject) => { + canvas.toBlob( + (blob) => + blob ? resolve(blob) : reject(new Error('ICO를 PNG로 바꾸지 못했습니다.')), + 'image/png', + ); + }); +} + +export function wrapPngAsIco(png: ArrayBuffer, width: number, height: number) { + const header = 22; + const out = new Uint8Array(header + png.byteLength); + const view = new DataView(out.buffer); + view.setUint16(0, 0, true); + view.setUint16(2, 1, true); + view.setUint16(4, 1, true); + out[6] = width >= 256 ? 0 : width; + out[7] = height >= 256 ? 0 : height; + view.setUint16(10, 1, true); + view.setUint16(12, 32, true); + view.setUint32(14, png.byteLength, true); + view.setUint32(18, header, true); + out.set(new Uint8Array(png), header); + return new Blob([out], { type: 'image/x-icon' }); +} + +async function decodeEntry(bytes: Uint8Array) { + if (isPng(bytes)) { + const blob = new Blob([bytes.slice()], { type: 'image/png' }); + const url = URL.createObjectURL(blob); + try { + const image = new Image(); + image.decoding = 'async'; + image.src = url; + await image.decode(); + return { + blob, + width: image.naturalWidth || image.width, + height: image.naturalHeight || image.height, + }; + } finally { + URL.revokeObjectURL(url); + } + } + const canvas = canvasFromImageData(decodeDib(bytes)); + const blob = await pngBlobFromCanvas(canvas); + return { blob, width: canvas.width, height: canvas.height }; +} + +/** Decode an ICO and return the largest image as a PNG blob. */ +export async function decodeIcoToPng(buffer: ArrayBuffer) { + const view = new DataView(buffer); + if (view.byteLength < 22) { + throw new Error('ICO 파일이 너무 짧습니다.'); + } + const reserved = view.getUint16(0, true); + const type = view.getUint16(2, true); + const count = view.getUint16(4, true); + if (reserved !== 0 || (type !== 1 && type !== 2) || count < 1) { + throw new Error('올바른 ICO 파일이 아닙니다.'); + } + + const bytes = new Uint8Array(buffer); + let best: { score: number; offset: number; size: number } | null = null; + for (let i = 0; i < count; i += 1) { + const entry = 6 + i * 16; + const width = view.getUint8(entry) || 256; + const height = view.getUint8(entry + 1) || 256; + const size = view.getUint32(entry + 8, true); + const offset = view.getUint32(entry + 12, true); + if (offset + size > buffer.byteLength) continue; + const score = width * height + size; + if (!best || score > best.score) { + best = { score, offset, size }; + } + } + if (!best) { + throw new Error('ICO에서 이미지를 찾지 못했습니다.'); + } + + const slice = bytes.subarray(best.offset, best.offset + best.size); + const decoded = await decodeEntry(slice); + return { ...decoded, imageCount: count }; +} diff --git a/packages/frontend/src/integrations/imageedit/process.ts b/packages/frontend/src/integrations/imageedit/process.ts index 63f1e72..45a7a0e 100644 --- a/packages/frontend/src/integrations/imageedit/process.ts +++ b/packages/frontend/src/integrations/imageedit/process.ts @@ -1,3 +1,5 @@ +import { wrapPngAsIco } from './ico'; + export type PixelRect = { x: number; y: number; @@ -5,7 +7,7 @@ export type PixelRect = { height: number; }; -export type ExportFormat = 'image/jpeg' | 'image/webp' | 'image/png'; +export type ExportFormat = 'image/jpeg' | 'image/webp' | 'image/png' | 'image/x-icon'; export type RatioFit = 'crop' | 'pad'; @@ -78,6 +80,7 @@ export function formatBytes(bytes: number) { export function extensionForFormat(format: ExportFormat) { if (format === 'image/jpeg') return 'jpg'; if (format === 'image/webp') return 'webp'; + if (format === 'image/x-icon') return 'ico'; return 'png'; } @@ -141,8 +144,10 @@ function canvasToBlob( format: ExportFormat, quality: number, ) { + const mime = format === 'image/x-icon' ? 'image/png' : format; return new Promise((resolve, reject) => { - const q = format === 'image/png' ? undefined : Math.min(1, Math.max(0.01, quality)); + const q = + mime === 'image/png' ? undefined : Math.min(1, Math.max(0.01, quality)); canvas.toBlob( (blob) => { if (!blob) { @@ -151,7 +156,7 @@ function canvasToBlob( } resolve(blob); }, - format, + mime, q, ); }); @@ -255,6 +260,13 @@ export async function exportEditedImage(options: { } const blob = await canvasToBlob(canvas, options.format, options.quality); + if (options.format === 'image/x-icon') { + return { + blob: wrapPngAsIco(await blob.arrayBuffer(), size.width, size.height), + width: size.width, + height: size.height, + }; + } return { blob, width: size.width, height: size.height }; } diff --git a/packages/frontend/src/views/HomeView.vue b/packages/frontend/src/views/HomeView.vue index 6f3c1b9..cca4dbd 100644 --- a/packages/frontend/src/views/HomeView.vue +++ b/packages/frontend/src/views/HomeView.vue @@ -140,7 +140,7 @@ function onOpenTool(route: string) { - 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너) + 오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너) · 아이콘(.ico) diff --git a/packages/frontend/src/views/ToolsBgRemoveView.vue b/packages/frontend/src/views/ToolsBgRemoveView.vue index d6a60dc..08382b7 100644 --- a/packages/frontend/src/views/ToolsBgRemoveView.vue +++ b/packages/frontend/src/views/ToolsBgRemoveView.vue @@ -11,6 +11,7 @@ import { type BgRemoveMode, type Rgb, } from '@/integrations/bgremove/removeBg'; +import { decodeIcoToPng, isIcoFile } from '@/integrations/imageedit/ico'; import { downloadBlob } from '@/utils/downloadBlob'; defineOptions({ name: 'ToolsBgRemoveView' }); @@ -55,23 +56,35 @@ function onOpen() { } function isImageFile(file: File) { + if (isIcoFile(file)) return true; if (file.type.startsWith('image/')) return true; return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name); } async function loadFile(file: File) { if (!isImageFile(file)) { - error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG 이미지를 선택해 주세요.'; + error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG, ICO 이미지를 선택해 주세요.'; return; } error.value = ''; notice.value = ''; revoke(sourceUrl.value); revoke(resultUrl.value); - sourceUrl.value = URL.createObjectURL(file); resultUrl.value = null; background.value = null; filename.value = file.name; + if (isIcoFile(file)) { + try { + const decoded = await decodeIcoToPng(await file.arrayBuffer()); + sourceUrl.value = URL.createObjectURL(decoded.blob); + } catch (reason) { + error.value = + reason instanceof Error ? reason.message : 'ICO를 열지 못했습니다.'; + return; + } + } else { + sourceUrl.value = URL.createObjectURL(file); + } await processImage(); } @@ -104,7 +117,7 @@ async function loadFromUrl(raw = imageUrlInput.value) { const blob = await response.blob(); const looksImage = blob.type.startsWith('image/') || - /\.(png|jpe?g|webp|gif|bmp|svg)(\?|$)/i.test(parsed.pathname); + /\.(png|jpe?g|webp|gif|bmp|svg|ico)(\?|$)/i.test(parsed.pathname); if (!looksImage) { throw new Error('응답이 이미지가 아닙니다.'); } @@ -207,7 +220,7 @@ onBeforeUnmount(() => { ref="fileInput" class="sr-only" type="file" - accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/svg+xml,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg" + accept="image/png,image/jpeg,image/webp,image/gif,image/bmp,image/svg+xml,image/x-icon,image/vnd.microsoft.icon,.png,.jpg,.jpeg,.webp,.gif,.bmp,.svg,.ico" @change="onFilesSelected" /> diff --git a/packages/frontend/src/views/ToolsImageEditView.vue b/packages/frontend/src/views/ToolsImageEditView.vue index 6f4441f..5a2fab8 100644 --- a/packages/frontend/src/views/ToolsImageEditView.vue +++ b/packages/frontend/src/views/ToolsImageEditView.vue @@ -1,11 +1,13 @@