|
| 1 | +import path from 'path'; |
| 2 | +import fsExtra from 'fs-extra'; |
| 3 | + |
| 4 | +const ICO_HEADER_SIZE = 6; |
| 5 | +const ICO_DIR_ENTRY_SIZE = 16; |
| 6 | +const ICO_TYPE_ICON = 1; |
| 7 | + |
| 8 | +export type IcoEntry = { |
| 9 | + index: number; |
| 10 | + width: number; |
| 11 | + height: number; |
| 12 | + bitCount: number; |
| 13 | + bytesInRes: number; |
| 14 | + imageOffset: number; |
| 15 | + directory: Buffer; |
| 16 | + data: Buffer; |
| 17 | +}; |
| 18 | + |
| 19 | +function decodeDimension(value: number): number { |
| 20 | + return value === 0 ? 256 : value; |
| 21 | +} |
| 22 | + |
| 23 | +function compareByPreferredSize( |
| 24 | + preferredSize: number, |
| 25 | +): (a: IcoEntry, b: IcoEntry) => number { |
| 26 | + return (a, b) => { |
| 27 | + const aSize = Math.max(a.width, a.height); |
| 28 | + const bSize = Math.max(b.width, b.height); |
| 29 | + |
| 30 | + const aExact = aSize === preferredSize ? 0 : 1; |
| 31 | + const bExact = bSize === preferredSize ? 0 : 1; |
| 32 | + if (aExact !== bExact) return aExact - bExact; |
| 33 | + |
| 34 | + const aDistance = Math.abs(aSize - preferredSize); |
| 35 | + const bDistance = Math.abs(bSize - preferredSize); |
| 36 | + if (aDistance !== bDistance) return aDistance - bDistance; |
| 37 | + |
| 38 | + const aSmaller = aSize < preferredSize ? 1 : 0; |
| 39 | + const bSmaller = bSize < preferredSize ? 1 : 0; |
| 40 | + if (aSmaller !== bSmaller) return aSmaller - bSmaller; |
| 41 | + |
| 42 | + if (a.bitCount !== b.bitCount) return b.bitCount - a.bitCount; |
| 43 | + if (aSize !== bSize) return bSize - aSize; |
| 44 | + |
| 45 | + return a.index - b.index; |
| 46 | + }; |
| 47 | +} |
| 48 | + |
| 49 | +export function parseIcoBuffer(buffer: Buffer): IcoEntry[] { |
| 50 | + if (buffer.length < ICO_HEADER_SIZE) { |
| 51 | + throw new Error('Invalid ICO: header too short.'); |
| 52 | + } |
| 53 | + |
| 54 | + const reserved = buffer.readUInt16LE(0); |
| 55 | + const type = buffer.readUInt16LE(2); |
| 56 | + const count = buffer.readUInt16LE(4); |
| 57 | + |
| 58 | + if (reserved !== 0 || type !== ICO_TYPE_ICON || count < 1) { |
| 59 | + throw new Error('Invalid ICO: invalid header.'); |
| 60 | + } |
| 61 | + |
| 62 | + const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE; |
| 63 | + if (buffer.length < tableSize) { |
| 64 | + throw new Error('Invalid ICO: directory table too short.'); |
| 65 | + } |
| 66 | + |
| 67 | + const entries: IcoEntry[] = []; |
| 68 | + |
| 69 | + for (let i = 0; i < count; i++) { |
| 70 | + const offset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE; |
| 71 | + const widthByte = buffer.readUInt8(offset); |
| 72 | + const heightByte = buffer.readUInt8(offset + 1); |
| 73 | + const bitCount = buffer.readUInt16LE(offset + 6); |
| 74 | + const bytesInRes = buffer.readUInt32LE(offset + 8); |
| 75 | + const imageOffset = buffer.readUInt32LE(offset + 12); |
| 76 | + |
| 77 | + if (bytesInRes < 1 || imageOffset + bytesInRes > buffer.length) { |
| 78 | + throw new Error('Invalid ICO: frame out of bounds.'); |
| 79 | + } |
| 80 | + |
| 81 | + entries.push({ |
| 82 | + index: i, |
| 83 | + width: decodeDimension(widthByte), |
| 84 | + height: decodeDimension(heightByte), |
| 85 | + bitCount, |
| 86 | + bytesInRes, |
| 87 | + imageOffset, |
| 88 | + directory: buffer.subarray(offset, offset + ICO_DIR_ENTRY_SIZE), |
| 89 | + data: buffer.subarray(imageOffset, imageOffset + bytesInRes), |
| 90 | + }); |
| 91 | + } |
| 92 | + |
| 93 | + return entries; |
| 94 | +} |
| 95 | + |
| 96 | +export function buildReorderedIcoBuffer( |
| 97 | + buffer: Buffer, |
| 98 | + preferredSize: number, |
| 99 | +): Buffer { |
| 100 | + const entries = parseIcoBuffer(buffer); |
| 101 | + const ordered = [...entries].sort(compareByPreferredSize(preferredSize)); |
| 102 | + const count = ordered.length; |
| 103 | + const tableSize = ICO_HEADER_SIZE + count * ICO_DIR_ENTRY_SIZE; |
| 104 | + const payloadSize = ordered.reduce( |
| 105 | + (acc, entry) => acc + entry.data.length, |
| 106 | + 0, |
| 107 | + ); |
| 108 | + const output = Buffer.alloc(tableSize + payloadSize); |
| 109 | + |
| 110 | + output.writeUInt16LE(0, 0); |
| 111 | + output.writeUInt16LE(ICO_TYPE_ICON, 2); |
| 112 | + output.writeUInt16LE(count, 4); |
| 113 | + |
| 114 | + let currentOffset = tableSize; |
| 115 | + for (let i = 0; i < count; i++) { |
| 116 | + const entry = ordered[i]; |
| 117 | + const entryOffset = ICO_HEADER_SIZE + i * ICO_DIR_ENTRY_SIZE; |
| 118 | + |
| 119 | + entry.directory.copy(output, entryOffset, 0, 8); |
| 120 | + output.writeUInt32LE(entry.data.length, entryOffset + 8); |
| 121 | + output.writeUInt32LE(currentOffset, entryOffset + 12); |
| 122 | + entry.data.copy(output, currentOffset); |
| 123 | + currentOffset += entry.data.length; |
| 124 | + } |
| 125 | + |
| 126 | + return output; |
| 127 | +} |
| 128 | + |
| 129 | +export async function writeIcoWithPreferredSize( |
| 130 | + sourcePath: string, |
| 131 | + outputPath: string, |
| 132 | + preferredSize: number, |
| 133 | +): Promise<boolean> { |
| 134 | + try { |
| 135 | + const sourceBuffer = await fsExtra.readFile(sourcePath); |
| 136 | + const reordered = buildReorderedIcoBuffer(sourceBuffer, preferredSize); |
| 137 | + await fsExtra.ensureDir(path.dirname(outputPath)); |
| 138 | + await fsExtra.outputFile(outputPath, reordered); |
| 139 | + return true; |
| 140 | + } catch { |
| 141 | + return false; |
| 142 | + } |
| 143 | +} |
0 commit comments