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,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 };
|
||||
}
|
||||
Reference in new issue
Block a user