From f971c312b9995cbb07405a4a4d1f4ef933a1e882 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=89=8B=E7=93=9C=E4=B8=80=E5=8D=81=E9=9B=AA?= Date: Sun, 1 Feb 2026 17:42:58 +0800 Subject: [PATCH] Add TIFF parser and buffer-based image size APIs Introduce a TIFF parser and integrate it into the image detection/size parsing pipeline. Add buffer-based APIs (detectImageTypeFromBuffer, imageSizeFromBuffer, imageSizeFromBufferFallBack) and a helper to convert Buffer to a Readable stream; refactor parser registry into a type-to-parser map and a first-byte fast-path map for quicker detection. Harden WebP parsing with safer length checks. Add sample image resources and a comprehensive Vitest test suite (packages/napcat-test) with updated package dependency and resolve aliases. pnpm-lock updated to link the new package. --- .../napcat-image-size/resource/test-20x20.jpg | Bin 0 -> 1423 bytes .../napcat-image-size/resource/test-20x20.png | Bin 0 -> 1080 bytes .../resource/test-20x20.tiff | Bin 0 -> 2318 bytes .../resource/test-20x20.webp | Bin 0 -> 962 bytes .../resource/test-490x498.gif | Bin 0 -> 35668 bytes packages/napcat-image-size/src/index.ts | 135 ++++++- .../src/parser/TiffParser.ts | 124 +++++++ .../src/parser/WebpParser.ts | 6 +- packages/napcat-test/imageSize.test.ts | 346 ++++++++++++++++++ packages/napcat-test/package.json | 3 +- packages/napcat-test/vitest.config.ts | 5 +- pnpm-lock.yaml | 3 + 12 files changed, 597 insertions(+), 25 deletions(-) create mode 100644 packages/napcat-image-size/resource/test-20x20.jpg create mode 100644 packages/napcat-image-size/resource/test-20x20.png create mode 100644 packages/napcat-image-size/resource/test-20x20.tiff create mode 100644 packages/napcat-image-size/resource/test-20x20.webp create mode 100644 packages/napcat-image-size/resource/test-490x498.gif create mode 100644 packages/napcat-image-size/src/parser/TiffParser.ts create mode 100644 packages/napcat-test/imageSize.test.ts diff --git a/packages/napcat-image-size/resource/test-20x20.jpg b/packages/napcat-image-size/resource/test-20x20.jpg new file mode 100644 index 0000000000000000000000000000000000000000..214ae247859b661ce671f38de0c4667fd4df3bb9 GIT binary patch literal 1423 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 sN_m`n_m%;T99J-#UZg#nP1O%Bi{vN@QSOPl<{9 literal 0 HcmV?d00001 diff --git a/packages/napcat-image-size/resource/test-20x20.webp b/packages/napcat-image-size/resource/test-20x20.webp new file mode 100644 index 0000000000000000000000000000000000000000..60e96f0a5a16826bed5716e5afd1c49796b5ff59 GIT binary patch literal 962 zcmV;z13mmwNk&Gx0{{S5MM6+kP&il$0000W0000J000vJ09H^qOq&A$052231ON?n zA)@~QN!GTRw)59twNabbimjfVwC!P}ojEyENY}RQYg5}WJ4`CwOSOAnnbYEWBGwpdLa`U|U?APVXw^uIyyd6eBAx|_( zR)9Sud&9NyZ0N&h=_4A+8+u3p z%qB5kdQO&Cm;LOcub=zyFId;FW)hI$X!>m zH2L1gSXLG#%x$gykPMvC(G^ww%J%m%G-Z1lt-2EXy)|Xjrtll%8|?ko_9sV0TS-g6 z&)Bspb+kpRCYSsx#^>vi(mlZ!=9N*`Nry22Plmv=aiVbZ@2E@EeOgjzrNMOJ?wbuV z;?S*dGk{9dQN=;XKP8Iw;rk2kB_GH%iAUXTe$v~jU#pKsaI?AIgHO{9R+-Cqi%b(_u=ge z`iL__(N9xeTMUf6oAbMN{^q9u0uQ!tMwIEXw|XLeMD73E|L0d;eLPzCePk>{6bSZX z-Q=$|-MfEhZ}G~8{$=3^OZHXK$?IrC+SdFTCx_~`J$$TBTa}$V&};cuJ^ZV(b@(+7 zo7uWiCZEiUm%mo*|FUCm+1uy-{GaP?x*qW=lxuTs+*++aq+~Pb(R%#zelp}DE0(ii z>H2@Z?Edq`G1`2;cCyWX>(GRzGRd(gv3*#NDw<+xe~ii1prkC^eVM%XJNtV?P0bF~ zFK$`?xbD|SFuD8lnxIhlF+5T%4H8?HJ%IEy!X=yS-(?b;wT;<-6Z-FZ8=0=fY`Wq7 k%Rj~0Hc?w-DpOb2r>87x>U8TnHsoYP)PJ4++zGE&0J2^0LI3~& literal 0 HcmV?d00001 diff --git a/packages/napcat-image-size/resource/test-490x498.gif b/packages/napcat-image-size/resource/test-490x498.gif new file mode 100644 index 0000000000000000000000000000000000000000..5dcae1bec0737b1a1bddf8e052ade104c3f6b713 GIT binary patch literal 35668 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 = [ - new PngParser(), - new JpegParser(), - new BmpParser(), - new GifParser(), - new WebpParser(), -]; +// 所有解析器实例 +const parserInstances = { + png: new PngParser(), + jpeg: new JpegParser(), + bmp: new BmpParser(), + gif: new GifParser(), + webp: new WebpParser(), + tiff: new TiffParser(), +}; + +// 首字节到可能的图片类型映射,用于快速筛选 +const firstByteMap = new Map([ + [0x42, [ImageType.BMP]], // 'B' - BMP + [0x47, [ImageType.GIF]], // 'G' - GIF + [0x49, [ImageType.TIFF]], // 'I' - TIFF (II - little endian) + [0x4D, [ImageType.TIFF]], // 'M' - TIFF (MM - big endian) + [0x52, [ImageType.WEBP]], // 'R' - RIFF (WebP) + [0x89, [ImageType.PNG]], // PNG signature + [0xFF, [ImageType.JPEG]], // JPEG SOI +]); + +// 类型到解析器的映射 +const typeToParser = new Map([ + [ImageType.PNG, parserInstances.png], + [ImageType.JPEG, parserInstances.jpeg], + [ImageType.BMP, parserInstances.bmp], + [ImageType.GIF, parserInstances.gif], + [ImageType.WEBP, parserInstances.webp], + [ImageType.TIFF, parserInstances.tiff], +]); + +// 所有解析器列表(用于回退) +const parsers: ReadonlyArray = Object.values(parserInstances); export async function detectImageType (filePath: string): Promise { return new Promise((resolve, reject) => { @@ -56,18 +85,22 @@ export async function detectImageType (filePath: string): Promise { end: 63, }); - let buffer: Buffer | null = null; + const chunks: Buffer[] = []; - stream.once('error', (err) => { + stream.on('error', (err) => { stream.destroy(); reject(err); }); - stream.once('readable', () => { - buffer = stream.read(64) as Buffer; - stream.destroy(); + stream.on('data', (chunk: Buffer | string) => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(chunkBuffer); + }); - if (!buffer) { + stream.on('end', () => { + const buffer = Buffer.concat(chunks); + + if (buffer.length === 0) { return resolve(ImageType.UNKNOWN); } @@ -79,12 +112,6 @@ export async function detectImageType (filePath: string): Promise { resolve(ImageType.UNKNOWN); }); - - stream.once('end', () => { - if (!buffer) { - resolve(ImageType.UNKNOWN); - } - }); }); } @@ -92,7 +119,7 @@ export async function imageSizeFromFile (filePath: string): Promise p.type === type); + const parser = typeToParser.get(type); if (!parser) { return undefined; } @@ -124,3 +151,71 @@ export async function imageSizeFallBack ( ): Promise { return await imageSizeFromFile(filePath) ?? fallback; } + +// 从 Buffer 创建可读流 +function bufferToReadStream (buffer: Buffer): ReadStream { + const readable = new Readable({ + read () { + this.push(buffer); + this.push(null); + } + }); + return readable as unknown as ReadStream; +} + +// 从 Buffer 检测图片类型(使用首字节快速筛选) +export function detectImageTypeFromBuffer (buffer: Buffer): ImageType { + if (buffer.length === 0) { + return ImageType.UNKNOWN; + } + + const firstByte = buffer[0]!; + const possibleTypes = firstByteMap.get(firstByte); + + if (possibleTypes) { + // 根据首字节快速筛选可能的类型 + for (const type of possibleTypes) { + const parser = typeToParser.get(type); + if (parser && parser.canParse(buffer)) { + return parser.type; + } + } + } + + // 回退:遍历所有解析器 + for (const parser of parsers) { + if (parser.canParse(buffer)) { + return parser.type; + } + } + + return ImageType.UNKNOWN; +} + +// 从 Buffer 解析图片尺寸 +export async function imageSizeFromBuffer (buffer: Buffer): Promise { + const type = detectImageTypeFromBuffer(buffer); + const parser = typeToParser.get(type); + if (!parser) { + return undefined; + } + + try { + const stream = bufferToReadStream(buffer); + return await parser.parseSize(stream); + } catch (err) { + console.error(`解析图片尺寸出错: ${err}`); + return undefined; + } +} + +// 从 Buffer 解析图片尺寸,带回退值 +export async function imageSizeFromBufferFallBack ( + buffer: Buffer, + fallback: ImageSize = { + width: 1024, + height: 1024, + } +): Promise { + return await imageSizeFromBuffer(buffer) ?? fallback; +} diff --git a/packages/napcat-image-size/src/parser/TiffParser.ts b/packages/napcat-image-size/src/parser/TiffParser.ts new file mode 100644 index 00000000..243ebe31 --- /dev/null +++ b/packages/napcat-image-size/src/parser/TiffParser.ts @@ -0,0 +1,124 @@ +import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src'; +import { ReadStream } from 'fs'; + +// TIFF解析器 +export class TiffParser implements ImageParser { + readonly type = ImageType.TIFF; + // TIFF Little Endian 魔术头:49 49 2A 00 (II) + private readonly TIFF_LE_SIGNATURE = [0x49, 0x49, 0x2A, 0x00]; + // TIFF Big Endian 魔术头:4D 4D 00 2A (MM) + private readonly TIFF_BE_SIGNATURE = [0x4D, 0x4D, 0x00, 0x2A]; + + canParse (buffer: Buffer): boolean { + return ( + matchMagic(buffer, this.TIFF_LE_SIGNATURE) || + matchMagic(buffer, this.TIFF_BE_SIGNATURE) + ); + } + + async parseSize (stream: ReadStream): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let totalBytes = 0; + const MAX_BYTES = 64 * 1024; // 最多读取 64KB + + stream.on('error', reject); + + stream.on('data', (chunk: Buffer | string) => { + const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); + chunks.push(chunkBuffer); + totalBytes += chunkBuffer.length; + + if (totalBytes >= MAX_BYTES) { + stream.destroy(); + } + }); + + stream.on('end', () => { + const buffer = Buffer.concat(chunks); + const size = this.parseTiffSize(buffer); + resolve(size); + }); + + stream.on('close', () => { + if (chunks.length > 0) { + const buffer = Buffer.concat(chunks); + const size = this.parseTiffSize(buffer); + resolve(size); + } + }); + }); + } + + private parseTiffSize (buffer: Buffer): ImageSize | undefined { + if (buffer.length < 8) { + return undefined; + } + + // 判断字节序 + const isLittleEndian = buffer[0] === 0x49; // 'I' + + const readUInt16 = isLittleEndian + ? (offset: number) => buffer.readUInt16LE(offset) + : (offset: number) => buffer.readUInt16BE(offset); + + const readUInt32 = isLittleEndian + ? (offset: number) => buffer.readUInt32LE(offset) + : (offset: number) => buffer.readUInt32BE(offset); + + // 获取第一个 IFD 的偏移量 + const ifdOffset = readUInt32(4); + if (ifdOffset + 2 > buffer.length) { + return undefined; + } + + // 读取 IFD 条目数量 + const numEntries = readUInt16(ifdOffset); + let width: number | undefined; + let height: number | undefined; + + // TIFF 标签 + const TAG_IMAGE_WIDTH = 0x0100; + const TAG_IMAGE_HEIGHT = 0x0101; + + // 遍历 IFD 条目 + for (let i = 0; i < numEntries; i++) { + const entryOffset = ifdOffset + 2 + i * 12; + if (entryOffset + 12 > buffer.length) { + break; + } + + const tag = readUInt16(entryOffset); + const type = readUInt16(entryOffset + 2); + // const count = readUInt32(entryOffset + 4); + + // 根据类型读取值 + let value: number; + if (type === 3) { + // SHORT (2 bytes) + value = readUInt16(entryOffset + 8); + } else if (type === 4) { + // LONG (4 bytes) + value = readUInt32(entryOffset + 8); + } else { + continue; + } + + if (tag === TAG_IMAGE_WIDTH) { + width = value; + } else if (tag === TAG_IMAGE_HEIGHT) { + height = value; + } + + if (width !== undefined && height !== undefined) { + return { width, height }; + } + } + + if (width !== undefined && height !== undefined) { + return { width, height }; + } + + return undefined; + } +} diff --git a/packages/napcat-image-size/src/parser/WebpParser.ts b/packages/napcat-image-size/src/parser/WebpParser.ts index 47ae52b0..cb58f07a 100644 --- a/packages/napcat-image-size/src/parser/WebpParser.ts +++ b/packages/napcat-image-size/src/parser/WebpParser.ts @@ -66,11 +66,11 @@ export class WebpParser implements ImageParser { } else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) { // VP8X格式 - 扩展WebP // 24位宽度和高度(减去1) - if (!buffer[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) { + if (buffer.length < 30) { return resolve(undefined); } - const width = 1 + ((buffer[24] | (buffer[25] << 8) | (buffer[26] << 16)) & 0xFFFFFF); - const height = 1 + ((buffer[27] | (buffer[28] << 8) | (buffer[29] << 16)) & 0xFFFFFF); + const width = 1 + ((buffer[24]! | (buffer[25]! << 8) | (buffer[26]! << 16)) & 0xFFFFFF); + const height = 1 + ((buffer[27]! | (buffer[28]! << 8) | (buffer[29]! << 16)) & 0xFFFFFF); return resolve({ width, height }); } else { // 未知的WebP子格式 diff --git a/packages/napcat-test/imageSize.test.ts b/packages/napcat-test/imageSize.test.ts new file mode 100644 index 00000000..f9306816 --- /dev/null +++ b/packages/napcat-test/imageSize.test.ts @@ -0,0 +1,346 @@ +import { describe, it, expect } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import { + detectImageTypeFromBuffer, + imageSizeFromBuffer, + imageSizeFromBufferFallBack, + imageSizeFromFile, + matchMagic, + ImageType, +} from '@/napcat-image-size/src'; + +// resource 目录路径 +const resourceDir = path.resolve(__dirname, '../napcat-image-size/resource'); + +// 测试用的 Buffer 数据 +const testBuffers = { + // PNG 测试图片 (100x200) + png: Buffer.from([ + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, + 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, 0xC8, + 0x08, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + ]), + + // JPEG 测试图片 (320x240) + jpeg: Buffer.from([ + 0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, + 0x4A, 0x46, 0x49, 0x46, 0x00, + 0x01, 0x01, 0x00, 0x00, 0x01, 0x00, 0x01, 0x00, 0x00, + 0xFF, 0xC0, 0x00, 0x0B, 0x08, + 0x00, 0xF0, 0x01, 0x40, 0x03, 0x01, 0x22, 0x00, + ]), + + // BMP 测试图片 (640x480) + bmp: (() => { + const buf = Buffer.alloc(54); + buf.write('BM', 0); + buf.writeUInt32LE(54, 2); + buf.writeUInt32LE(0, 6); + buf.writeUInt32LE(54, 10); + buf.writeUInt32LE(40, 14); + buf.writeUInt32LE(640, 18); + buf.writeUInt32LE(480, 22); + buf.writeUInt16LE(1, 26); + buf.writeUInt16LE(24, 28); + return buf; + })(), + + // GIF87a 测试图片 (800x600) + gif87a: Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x37, 0x61, + 0x20, 0x03, 0x58, 0x02, 0x00, 0x00, 0x00, + ]), + + // GIF89a 测试图片 (1024x768) + gif89a: Buffer.from([ + 0x47, 0x49, 0x46, 0x38, 0x39, 0x61, + 0x00, 0x04, 0x00, 0x03, 0x00, 0x00, 0x00, + ]), + + // WebP VP8 测试图片 (1920x1080) + webpVP8: (() => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0); + buf.writeUInt32LE(24, 4); + buf.write('WEBP', 8); + buf.write('VP8 ', 12); + buf.writeUInt32LE(14, 16); + buf.writeUInt8(0x9D, 20); + buf.writeUInt8(0x01, 21); + buf.writeUInt8(0x2A, 22); + buf.writeUInt16LE(1920 & 0x3FFF, 26); + buf.writeUInt16LE(1080 & 0x3FFF, 28); + return buf; + })(), + + // WebP VP8L 测试图片 (256x128) + webpVP8L: (() => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0); + buf.writeUInt32LE(24, 4); + buf.write('WEBP', 8); + buf.write('VP8L', 12); + buf.writeUInt32LE(10, 16); + buf.writeUInt8(0x2F, 20); + const vp8lBits = (256 - 1) | ((128 - 1) << 14); + buf.writeUInt32LE(vp8lBits, 21); + return buf; + })(), + + // WebP VP8X 测试图片 (512x384) + webpVP8X: (() => { + const buf = Buffer.alloc(32); + buf.write('RIFF', 0); + buf.writeUInt32LE(24, 4); + buf.write('WEBP', 8); + buf.write('VP8X', 12); + buf.writeUInt32LE(10, 16); + buf.writeUInt8((512 - 1) & 0xFF, 24); + buf.writeUInt8(((512 - 1) >> 8) & 0xFF, 25); + buf.writeUInt8(((512 - 1) >> 16) & 0xFF, 26); + buf.writeUInt8((384 - 1) & 0xFF, 27); + buf.writeUInt8(((384 - 1) >> 8) & 0xFF, 28); + buf.writeUInt8(((384 - 1) >> 16) & 0xFF, 29); + return buf; + })(), + + // TIFF Little Endian 测试图片 + tiffLE: Buffer.from([ + 0x49, 0x49, 0x2A, 0x00, // II + magic + 0x08, 0x00, 0x00, 0x00, // IFD offset = 8 + 0x02, 0x00, // 2 entries + // Entry 1: ImageWidth = 100 + 0x00, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0x64, 0x00, 0x00, 0x00, + // Entry 2: ImageHeight = 200 + 0x01, 0x01, 0x03, 0x00, 0x01, 0x00, 0x00, 0x00, 0xC8, 0x00, 0x00, 0x00, + ]), + + // TIFF Big Endian 测试图片 + tiffBE: Buffer.from([ + 0x4D, 0x4D, 0x00, 0x2A, // MM + magic + 0x00, 0x00, 0x00, 0x08, // IFD offset = 8 + 0x00, 0x02, // 2 entries + // Entry 1: ImageWidth = 100 + 0x01, 0x00, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00, 0x00, + // Entry 2: ImageHeight = 200 + 0x01, 0x01, 0x00, 0x03, 0x00, 0x00, 0x00, 0x01, 0x00, 0xC8, 0x00, 0x00, + ]), + + invalid: Buffer.from('This is not an image file'), + empty: Buffer.alloc(0), +}; + +describe('napcat-image-size', () => { + describe('matchMagic', () => { + it('should match magic bytes at the beginning', () => { + const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(true); + }); + + it('should match magic bytes at offset', () => { + const buffer = Buffer.from([0x00, 0x00, 0x89, 0x50, 0x4E, 0x47]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47], 2)).toBe(true); + }); + + it('should return false for non-matching magic', () => { + const buffer = Buffer.from([0x00, 0x00, 0x00, 0x00]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false); + }); + + it('should return false for buffer too short', () => { + const buffer = Buffer.from([0x89, 0x50]); + expect(matchMagic(buffer, [0x89, 0x50, 0x4E, 0x47])).toBe(false); + }); + + it('should return false for offset beyond buffer', () => { + const buffer = Buffer.from([0x89, 0x50, 0x4E, 0x47]); + expect(matchMagic(buffer, [0x89, 0x50], 10)).toBe(false); + }); + }); + + describe('detectImageTypeFromBuffer', () => { + it('should detect PNG image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.png)).toBe(ImageType.PNG); + }); + + it('should detect JPEG image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.jpeg)).toBe(ImageType.JPEG); + }); + + it('should detect BMP image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.bmp)).toBe(ImageType.BMP); + }); + + it('should detect GIF87a image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.gif87a)).toBe(ImageType.GIF); + }); + + it('should detect GIF89a image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.gif89a)).toBe(ImageType.GIF); + }); + + it('should detect WebP VP8 image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.webpVP8)).toBe(ImageType.WEBP); + }); + + it('should detect WebP VP8L image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.webpVP8L)).toBe(ImageType.WEBP); + }); + + it('should detect WebP VP8X image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.webpVP8X)).toBe(ImageType.WEBP); + }); + + it('should detect TIFF Little Endian image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.tiffLE)).toBe(ImageType.TIFF); + }); + + it('should detect TIFF Big Endian image type', () => { + expect(detectImageTypeFromBuffer(testBuffers.tiffBE)).toBe(ImageType.TIFF); + }); + + it('should return UNKNOWN for invalid data', () => { + expect(detectImageTypeFromBuffer(testBuffers.invalid)).toBe(ImageType.UNKNOWN); + }); + + it('should return UNKNOWN for empty buffer', () => { + expect(detectImageTypeFromBuffer(testBuffers.empty)).toBe(ImageType.UNKNOWN); + }); + }); + + describe('imageSizeFromBuffer', () => { + it('should parse PNG image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.png)).toEqual({ width: 100, height: 200 }); + }); + + it('should parse JPEG image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.jpeg)).toEqual({ width: 320, height: 240 }); + }); + + it('should parse BMP image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.bmp)).toEqual({ width: 640, height: 480 }); + }); + + it('should parse GIF87a image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.gif87a)).toEqual({ width: 800, height: 600 }); + }); + + it('should parse GIF89a image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.gif89a)).toEqual({ width: 1024, height: 768 }); + }); + + it('should parse WebP VP8 image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.webpVP8)).toEqual({ width: 1920, height: 1080 }); + }); + + it('should parse WebP VP8L image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.webpVP8L)).toEqual({ width: 256, height: 128 }); + }); + + it('should parse WebP VP8X image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.webpVP8X)).toEqual({ width: 512, height: 384 }); + }); + + it('should parse TIFF Little Endian image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.tiffLE)).toEqual({ width: 100, height: 200 }); + }); + + it('should parse TIFF Big Endian image size correctly', async () => { + expect(await imageSizeFromBuffer(testBuffers.tiffBE)).toEqual({ width: 100, height: 200 }); + }); + + it('should return undefined for invalid data', async () => { + expect(await imageSizeFromBuffer(testBuffers.invalid)).toBeUndefined(); + }); + + it('should return undefined for empty buffer', async () => { + expect(await imageSizeFromBuffer(testBuffers.empty)).toBeUndefined(); + }); + }); + + describe('imageSizeFromBufferFallBack', () => { + it('should return actual size for valid image', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.png)).toEqual({ width: 100, height: 200 }); + }); + + it('should return default fallback for invalid data', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.invalid)).toEqual({ width: 1024, height: 1024 }); + }); + + it('should return custom fallback for invalid data', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.invalid, { width: 500, height: 300 })).toEqual({ width: 500, height: 300 }); + }); + + it('should return default fallback for empty buffer', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.empty)).toEqual({ width: 1024, height: 1024 }); + }); + + it('should return custom fallback for empty buffer', async () => { + expect(await imageSizeFromBufferFallBack(testBuffers.empty, { width: 800, height: 600 })).toEqual({ width: 800, height: 600 }); + }); + }); + + describe('ImageType enum', () => { + it('should have correct enum values', () => { + expect(ImageType.JPEG).toBe('jpeg'); + expect(ImageType.PNG).toBe('png'); + expect(ImageType.BMP).toBe('bmp'); + expect(ImageType.GIF).toBe('gif'); + expect(ImageType.WEBP).toBe('webp'); + expect(ImageType.TIFF).toBe('tiff'); + expect(ImageType.UNKNOWN).toBe('unknown'); + }); + }); + + describe('Real image files from resource directory', () => { + it('should detect and parse test-20x20.jpg', async () => { + const filePath = path.join(resourceDir, 'test-20x20.jpg'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.JPEG); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-20x20.png', async () => { + const filePath = path.join(resourceDir, 'test-20x20.png'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.PNG); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-20x20.tiff', async () => { + const filePath = path.join(resourceDir, 'test-20x20.tiff'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.TIFF); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-20x20.webp', async () => { + const filePath = path.join(resourceDir, 'test-20x20.webp'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.WEBP); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 20, height: 20 }); + }); + + it('should detect and parse test-490x498.gif', async () => { + const filePath = path.join(resourceDir, 'test-490x498.gif'); + const buffer = fs.readFileSync(filePath); + expect(detectImageTypeFromBuffer(buffer)).toBe(ImageType.GIF); + const size = await imageSizeFromBuffer(buffer); + expect(size).toEqual({ width: 490, height: 498 }); + }); + + it('should parse real images using imageSizeFromFile', async () => { + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.jpg'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.png'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.tiff'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-20x20.webp'))).toEqual({ width: 20, height: 20 }); + expect(await imageSizeFromFile(path.join(resourceDir, 'test-490x498.gif'))).toEqual({ width: 490, height: 498 }); + }); + }); +}); diff --git a/packages/napcat-test/package.json b/packages/napcat-test/package.json index 6c51d86a..812c57bb 100644 --- a/packages/napcat-test/package.json +++ b/packages/napcat-test/package.json @@ -11,6 +11,7 @@ "vitest": "^4.0.9" }, "dependencies": { - "napcat-core": "workspace:*" + "napcat-core": "workspace:*", + "napcat-image-size": "workspace:*" } } \ No newline at end of file diff --git a/packages/napcat-test/vitest.config.ts b/packages/napcat-test/vitest.config.ts index 2ec5edf6..3451aa60 100644 --- a/packages/napcat-test/vitest.config.ts +++ b/packages/napcat-test/vitest.config.ts @@ -8,7 +8,10 @@ export default defineConfig({ }, resolve: { alias: { - '@': resolve(__dirname, '../../'), + '@/napcat-image-size': resolve(__dirname, '../napcat-image-size'), + '@/napcat-test': resolve(__dirname, '.'), + '@/napcat-common': resolve(__dirname, '../napcat-common'), + '@/napcat-core': resolve(__dirname, '../napcat-core'), }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 03c82e2d..a5dc338b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -354,6 +354,9 @@ importers: napcat-core: specifier: workspace:* version: link:../napcat-core + napcat-image-size: + specifier: workspace:* + version: link:../napcat-image-size devDependencies: vitest: specifier: ^4.0.9