Support Windows ICO in image edit and background remove.
office.sejong.dev/pipeline/head This commit looks good
office.sejong.dev/pipeline/head This commit looks good
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 <cursoragent@cursor.com>
This commit is contained in:
1 parent
26c98fe5e8
commit
eadbfb0f86
9 files changed
+319
-19
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
storeHwpFile,
|
storeHwpFile,
|
||||||
} from '@/composables/useSessionLifecycle';
|
} from '@/composables/useSessionLifecycle';
|
||||||
import { stageDiagramFile } from '@/composables/diagramSession';
|
import { stageDiagramFile } from '@/composables/diagramSession';
|
||||||
|
import { stageImageEditFile } from '@/composables/imageEditSession';
|
||||||
import { stageScannerFiles } from '@/composables/scannerSession';
|
import { stageScannerFiles } from '@/composables/scannerSession';
|
||||||
import { stageViewerFiles } from '@/composables/viewerSession';
|
import { stageViewerFiles } from '@/composables/viewerSession';
|
||||||
|
|
||||||
@@ -63,6 +64,11 @@ export function useOpenDocument() {
|
|||||||
await router.push({ name: 'tools-scanner' });
|
await router.push({ name: 'tools-scanner' });
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
if (toolKind === 'imageedit') {
|
||||||
|
stageImageEditFile(file);
|
||||||
|
await router.push({ name: 'tools-image-edit' });
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
const diagramKind = diagramKindFor(file.name);
|
const diagramKind = diagramKindFor(file.name);
|
||||||
if (diagramKind) {
|
if (diagramKind) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const APP_DISPLAY_NAME = "Sejong Office";
|
|||||||
|
|
||||||
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
export type DiagramKind = "excalidraw" | "mermaid" | "drawio";
|
||||||
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop";
|
export type ViewerKind = "cad" | "model3d" | "illustrator" | "photoshop";
|
||||||
export type ToolKind = "scanner";
|
export type ToolKind = "scanner" | "imageedit";
|
||||||
|
|
||||||
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
const DIAGRAM_EXTENSION_KIND: Record<string, DiagramKind> = {
|
||||||
excalidraw: "excalidraw",
|
excalidraw: "excalidraw",
|
||||||
@@ -34,6 +34,7 @@ const TOOL_EXTENSION_KIND: Record<string, ToolKind> = {
|
|||||||
webp: "scanner",
|
webp: "scanner",
|
||||||
gif: "scanner",
|
gif: "scanner",
|
||||||
bmp: "scanner",
|
bmp: "scanner",
|
||||||
|
ico: "imageedit",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Online3DViewer import formats (+ zip container). */
|
/** Online3DViewer import formats (+ zip container). */
|
||||||
|
|||||||
@@ -159,6 +159,7 @@ const EXT_KIND: Record<string, FaviconKind> = {
|
|||||||
bmp: 'scanner',
|
bmp: 'scanner',
|
||||||
bgremove: 'bgremove',
|
bgremove: 'bgremove',
|
||||||
imgedit: 'imgedit',
|
imgedit: 'imgedit',
|
||||||
|
ico: 'imgedit',
|
||||||
txt: 'text',
|
txt: 'text',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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<Blob>((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 };
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import { wrapPngAsIco } from './ico';
|
||||||
|
|
||||||
export type PixelRect = {
|
export type PixelRect = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -5,7 +7,7 @@ export type PixelRect = {
|
|||||||
height: number;
|
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';
|
export type RatioFit = 'crop' | 'pad';
|
||||||
|
|
||||||
@@ -78,6 +80,7 @@ export function formatBytes(bytes: number) {
|
|||||||
export function extensionForFormat(format: ExportFormat) {
|
export function extensionForFormat(format: ExportFormat) {
|
||||||
if (format === 'image/jpeg') return 'jpg';
|
if (format === 'image/jpeg') return 'jpg';
|
||||||
if (format === 'image/webp') return 'webp';
|
if (format === 'image/webp') return 'webp';
|
||||||
|
if (format === 'image/x-icon') return 'ico';
|
||||||
return 'png';
|
return 'png';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,8 +144,10 @@ function canvasToBlob(
|
|||||||
format: ExportFormat,
|
format: ExportFormat,
|
||||||
quality: number,
|
quality: number,
|
||||||
) {
|
) {
|
||||||
|
const mime = format === 'image/x-icon' ? 'image/png' : format;
|
||||||
return new Promise<Blob>((resolve, reject) => {
|
return new Promise<Blob>((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(
|
canvas.toBlob(
|
||||||
(blob) => {
|
(blob) => {
|
||||||
if (!blob) {
|
if (!blob) {
|
||||||
@@ -151,7 +156,7 @@ function canvasToBlob(
|
|||||||
}
|
}
|
||||||
resolve(blob);
|
resolve(blob);
|
||||||
},
|
},
|
||||||
format,
|
mime,
|
||||||
q,
|
q,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
@@ -255,6 +260,13 @@ export async function exportEditedImage(options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const blob = await canvasToBlob(canvas, options.format, options.quality);
|
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 };
|
return { blob, width: size.width, height: size.height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -140,7 +140,7 @@ function onOpenTool(route: string) {
|
|||||||
<input
|
<input
|
||||||
class="drop__input"
|
class="drop__input"
|
||||||
type="file"
|
type="file"
|
||||||
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.ai,.psd,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp"
|
accept=".docx,.xlsx,.pptx,.pdf,.hwp,.hwpx,.odt,.ods,.odp,.doc,.xls,.ppt,.rtf,.txt,.csv,.excalidraw,.mmd,.mermaid,.drawio,.xml,.dxf,.dwg,.ai,.psd,.3dm,.3ds,.3mf,.amf,.bim,.brp,.brep,.dae,.fbx,.fcstd,.gltf,.glb,.ifc,.igs,.iges,.stp,.step,.stl,.obj,.off,.ply,.wrl,.zip,.bin,.mtl,.png,.jpg,.jpeg,.webp,.ico"
|
||||||
multiple
|
multiple
|
||||||
:disabled="busy"
|
:disabled="busy"
|
||||||
@change="onInput"
|
@change="onInput"
|
||||||
@@ -149,7 +149,7 @@ function onOpenTool(route: string) {
|
|||||||
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
{{ busy ? '여는 중…' : '파일을 끌어다 놓거나 클릭하여 선택' }}
|
||||||
</span>
|
</span>
|
||||||
<span class="drop__hint">
|
<span class="drop__hint">
|
||||||
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너)
|
오피스·PDF · HWP/HWPX · 다이어그램 · CAD · 3D · 일러스트(.ai) · 포토샵(.psd) · 이미지(스캐너) · 아이콘(.ico)
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
type BgRemoveMode,
|
type BgRemoveMode,
|
||||||
type Rgb,
|
type Rgb,
|
||||||
} from '@/integrations/bgremove/removeBg';
|
} from '@/integrations/bgremove/removeBg';
|
||||||
|
import { decodeIcoToPng, isIcoFile } from '@/integrations/imageedit/ico';
|
||||||
import { downloadBlob } from '@/utils/downloadBlob';
|
import { downloadBlob } from '@/utils/downloadBlob';
|
||||||
|
|
||||||
defineOptions({ name: 'ToolsBgRemoveView' });
|
defineOptions({ name: 'ToolsBgRemoveView' });
|
||||||
@@ -55,23 +56,35 @@ function onOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isImageFile(file: File) {
|
function isImageFile(file: File) {
|
||||||
|
if (isIcoFile(file)) return true;
|
||||||
if (file.type.startsWith('image/')) return true;
|
if (file.type.startsWith('image/')) return true;
|
||||||
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(file: File) {
|
async function loadFile(file: File) {
|
||||||
if (!isImageFile(file)) {
|
if (!isImageFile(file)) {
|
||||||
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG 이미지를 선택해 주세요.';
|
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG, ICO 이미지를 선택해 주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error.value = '';
|
error.value = '';
|
||||||
notice.value = '';
|
notice.value = '';
|
||||||
revoke(sourceUrl.value);
|
revoke(sourceUrl.value);
|
||||||
revoke(resultUrl.value);
|
revoke(resultUrl.value);
|
||||||
sourceUrl.value = URL.createObjectURL(file);
|
|
||||||
resultUrl.value = null;
|
resultUrl.value = null;
|
||||||
background.value = null;
|
background.value = null;
|
||||||
filename.value = file.name;
|
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();
|
await processImage();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +117,7 @@ async function loadFromUrl(raw = imageUrlInput.value) {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const looksImage =
|
const looksImage =
|
||||||
blob.type.startsWith('image/') ||
|
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) {
|
if (!looksImage) {
|
||||||
throw new Error('응답이 이미지가 아닙니다.');
|
throw new Error('응답이 이미지가 아닙니다.');
|
||||||
}
|
}
|
||||||
@@ -207,7 +220,7 @@ onBeforeUnmount(() => {
|
|||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
type="file"
|
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"
|
@change="onFilesSelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onBeforeUnmount, ref, watch } from 'vue';
|
import { computed, onActivated, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||||
import { useRouter } from 'vue-router';
|
import { useRouter } from 'vue-router';
|
||||||
import AppShell from '@/components/AppShell.vue';
|
import AppShell from '@/components/AppShell.vue';
|
||||||
import EditorToolbar from '@/components/EditorToolbar.vue';
|
import EditorToolbar from '@/components/EditorToolbar.vue';
|
||||||
import CropEditor from '@/components/imageedit/CropEditor.vue';
|
import CropEditor from '@/components/imageedit/CropEditor.vue';
|
||||||
|
import { consumeImageEditFile } from '@/composables/imageEditSession';
|
||||||
import { useFileDropZone } from '@/composables/useFileDropZone';
|
import { useFileDropZone } from '@/composables/useFileDropZone';
|
||||||
import { usePageTitle } from '@/composables/usePageTitle';
|
import { usePageTitle } from '@/composables/usePageTitle';
|
||||||
|
import { decodeIcoToPng, isIcoFile } from '@/integrations/imageedit/ico';
|
||||||
import {
|
import {
|
||||||
exportEditedImage,
|
exportEditedImage,
|
||||||
extensionForFormat,
|
extensionForFormat,
|
||||||
@@ -93,7 +95,9 @@ const outputHint = computed(() =>
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const qualityDisabled = computed(() => format.value === 'image/png');
|
const qualityDisabled = computed(
|
||||||
|
() => format.value === 'image/png' || format.value === 'image/x-icon',
|
||||||
|
);
|
||||||
|
|
||||||
function revoke(url: string | null) {
|
function revoke(url: string | null) {
|
||||||
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
if (url?.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||||
@@ -112,20 +116,34 @@ function onOpen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isImageFile(file: File) {
|
function isImageFile(file: File) {
|
||||||
|
if (isIcoFile(file)) return true;
|
||||||
if (file.type.startsWith('image/')) return true;
|
if (file.type.startsWith('image/')) return true;
|
||||||
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
return /\.(png|jpe?g|webp|gif|bmp|svg)$/i.test(file.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFile(file: File) {
|
async function loadFile(file: File) {
|
||||||
if (!isImageFile(file)) {
|
if (!isImageFile(file)) {
|
||||||
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG 이미지를 선택해 주세요.';
|
error.value = 'PNG, JPEG, WebP, GIF, BMP, SVG, ICO 이미지를 선택해 주세요.';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
error.value = '';
|
error.value = '';
|
||||||
notice.value = '';
|
notice.value = '';
|
||||||
estimateBytes.value = null;
|
estimateBytes.value = null;
|
||||||
const url = URL.createObjectURL(file);
|
busy.value = true;
|
||||||
|
let url: string | null = null;
|
||||||
try {
|
try {
|
||||||
|
let source: Blob = file;
|
||||||
|
let extra = '';
|
||||||
|
if (isIcoFile(file)) {
|
||||||
|
const decoded = await decodeIcoToPng(await file.arrayBuffer());
|
||||||
|
source = decoded.blob;
|
||||||
|
extra =
|
||||||
|
decoded.imageCount > 1
|
||||||
|
? ` · ICO ${decoded.imageCount}장 중 ${decoded.width}×${decoded.height}`
|
||||||
|
: ' · ICO';
|
||||||
|
format.value = 'image/x-icon';
|
||||||
|
}
|
||||||
|
url = URL.createObjectURL(source);
|
||||||
const image = await loadImageFromUrl(url);
|
const image = await loadImageFromUrl(url);
|
||||||
revoke(sourceUrl.value);
|
revoke(sourceUrl.value);
|
||||||
sourceUrl.value = url;
|
sourceUrl.value = url;
|
||||||
@@ -139,14 +157,24 @@ async function loadFile(file: File) {
|
|||||||
scalePercent.value = 100;
|
scalePercent.value = 100;
|
||||||
maxEdgeId.value = '';
|
maxEdgeId.value = '';
|
||||||
crop.value = fullImageCrop(imageWidth.value, imageHeight.value);
|
crop.value = fullImageCrop(imageWidth.value, imageHeight.value);
|
||||||
notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}`;
|
notice.value = `${imageWidth.value}×${imageHeight.value} · ${formatBytes(file.size)}${extra}`;
|
||||||
} catch (reason) {
|
} catch (reason) {
|
||||||
revoke(url);
|
if (url) revoke(url);
|
||||||
error.value =
|
error.value =
|
||||||
reason instanceof Error ? reason.message : '이미지를 열지 못했습니다.';
|
reason instanceof Error ? reason.message : '이미지를 열지 못했습니다.';
|
||||||
|
} finally {
|
||||||
|
busy.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function consumePending() {
|
||||||
|
const pending = consumeImageEditFile();
|
||||||
|
if (pending) void loadFile(pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(consumePending);
|
||||||
|
onActivated(consumePending);
|
||||||
|
|
||||||
async function loadFromUrl(raw = imageUrlInput.value) {
|
async function loadFromUrl(raw = imageUrlInput.value) {
|
||||||
const trimmed = raw.trim();
|
const trimmed = raw.trim();
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
@@ -175,7 +203,7 @@ async function loadFromUrl(raw = imageUrlInput.value) {
|
|||||||
const blob = await response.blob();
|
const blob = await response.blob();
|
||||||
const looksImage =
|
const looksImage =
|
||||||
blob.type.startsWith('image/') ||
|
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) {
|
if (!looksImage) {
|
||||||
throw new Error('응답이 이미지가 아닙니다.');
|
throw new Error('응답이 이미지가 아닙니다.');
|
||||||
}
|
}
|
||||||
@@ -373,7 +401,7 @@ onBeforeUnmount(() => {
|
|||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
class="sr-only"
|
class="sr-only"
|
||||||
type="file"
|
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"
|
@change="onFilesSelected"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -391,7 +419,7 @@ onBeforeUnmount(() => {
|
|||||||
<strong>이미지 편집</strong>
|
<strong>이미지 편집</strong>
|
||||||
<p>
|
<p>
|
||||||
사진을 열거나 끌어다 놓은 뒤 크롭, 축소, 경량화, 비율 변경을 적용해
|
사진을 열거나 끌어다 놓은 뒤 크롭, 축소, 경량화, 비율 변경을 적용해
|
||||||
JPEG·WebP·PNG로 저장합니다. 서버에 업로드하지 않습니다.
|
JPEG·WebP·PNG·ICO로 저장합니다. 서버에 업로드하지 않습니다.
|
||||||
</p>
|
</p>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -506,6 +534,7 @@ onBeforeUnmount(() => {
|
|||||||
<option value="image/jpeg">JPEG</option>
|
<option value="image/jpeg">JPEG</option>
|
||||||
<option value="image/webp">WebP</option>
|
<option value="image/webp">WebP</option>
|
||||||
<option value="image/png">PNG (무손실)</option>
|
<option value="image/png">PNG (무손실)</option>
|
||||||
|
<option value="image/x-icon">ICO</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label class="field">
|
<label class="field">
|
||||||
|
|||||||
Reference in new issue
Block a user