NapCatQQ source snapshot
Build NapCat Artifacts / Type Check & Test (push) Waiting to run
Release Tag / validate-version (push) Waiting to run

This commit is contained in:
TanTan committed 2026-11-13 09:01:47 +08:00
commit f954252a34
1111 files changed
+139867

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
{
"name": "napcat-image-size",
"version": "0.0.1",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": {
"import": "./src/index.ts"
},
"./src/*": {
"import": "./src/*"
}
},
"devDependencies": {
"@types/node": "^22.0.1"
},
"engines": {
"node": ">=18.0.0"
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 962 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+221
View File
@@ -0,0 +1,221 @@
import { BmpParser } from '@/napcat-image-size/src/parser/BmpParser';
import { GifParser } from '@/napcat-image-size/src/parser/GifParser';
import { JpegParser } from '@/napcat-image-size/src/parser/JpegParser';
import { PngParser } from '@/napcat-image-size/src/parser/PngParser';
import { TiffParser } from '@/napcat-image-size/src/parser/TiffParser';
import { WebpParser } from '@/napcat-image-size/src/parser/WebpParser';
import * as fs from 'fs';
import { ReadStream } from 'fs';
import { Readable } from 'stream';
export interface ImageSize {
width: number;
height: number;
}
export enum ImageType {
JPEG = 'jpeg',
PNG = 'png',
BMP = 'bmp',
GIF = 'gif',
WEBP = 'webp',
TIFF = 'tiff',
UNKNOWN = 'unknown',
}
export interface ImageParser {
readonly type: ImageType;
canParse (buffer: Buffer): boolean;
parseSize (stream: ReadStream): Promise<ImageSize | undefined>;
}
// 魔术匹配
export function matchMagic (buffer: Buffer, magic: number[], offset = 0): boolean {
if (buffer.length < offset + magic.length) {
return false;
}
for (let i = 0; i < magic.length; i++) {
if (buffer[offset + i] !== magic[i]) {
return false;
}
}
return true;
}
// 所有解析器实例
const parserInstances = {
png: new PngParser(),
jpeg: new JpegParser(),
bmp: new BmpParser(),
gif: new GifParser(),
webp: new WebpParser(),
tiff: new TiffParser(),
};
// 首字节到可能的图片类型映射,用于快速筛选
const firstByteMap = new Map<number, ImageType[]>([
[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, ImageParser>([
[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<ImageParser> = Object.values(parserInstances);
export async function detectImageType (filePath: string): Promise<ImageType> {
return new Promise((resolve, reject) => {
const stream = fs.createReadStream(filePath, {
highWaterMark: 64, // 优化读取buffer大小
start: 0,
end: 63,
});
const chunks: Buffer[] = [];
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
chunks.push(chunkBuffer);
});
stream.on('end', () => {
const buffer = Buffer.concat(chunks);
if (buffer.length === 0) {
return resolve(ImageType.UNKNOWN);
}
for (const parser of parsers) {
if (parser.canParse(buffer)) {
return resolve(parser.type);
}
}
resolve(ImageType.UNKNOWN);
});
});
}
export async function imageSizeFromFile (filePath: string): Promise<ImageSize | undefined> {
try {
// 先检测类型
const type = await detectImageType(filePath);
const parser = typeToParser.get(type);
if (!parser) {
return undefined;
}
// 用流式方式解析尺寸
const stream = fs.createReadStream(filePath);
try {
return await parser.parseSize(stream);
} catch (err) {
console.error(`解析图片尺寸出错: ${err}`);
return undefined;
} finally {
if (!stream.destroyed) {
stream.destroy();
}
}
} catch (err) {
console.error(`检测图片类型出错: ${err}`);
return undefined;
}
}
export async function imageSizeFallBack (
filePath: string,
fallback: ImageSize = {
width: 1024,
height: 1024,
}
): Promise<ImageSize> {
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<ImageSize | undefined> {
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<ImageSize> {
return await imageSizeFromBuffer(buffer) ?? fallback;
}
@@ -0,0 +1,32 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// BMP解析器
export class BmpParser implements ImageParser {
readonly type = ImageType.BMP;
// BMP 魔术头:42 4D (BM)
private readonly BMP_SIGNATURE = [0x42, 0x4D];
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.BMP_SIGNATURE);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(26) as Buffer;
if (!buf || buf.length < 26) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32LE(18);
const height = buf.readUInt32LE(22);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
@@ -0,0 +1,37 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// GIF解析器
export class GifParser implements ImageParser {
readonly type = ImageType.GIF;
// GIF87a 魔术头:47 49 46 38 37 61
private readonly GIF87A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x37, 0x61];
// GIF89a 魔术头:47 49 46 38 39 61
private readonly GIF89A_SIGNATURE = [0x47, 0x49, 0x46, 0x38, 0x39, 0x61];
canParse (buffer: Buffer): boolean {
return (
matchMagic(buffer, this.GIF87A_SIGNATURE) ||
matchMagic(buffer, this.GIF89A_SIGNATURE)
);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(10) as Buffer;
if (!buf || buf.length < 10) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt16LE(6);
const height = buf.readUInt16LE(8);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
@@ -0,0 +1,122 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// JPEG解析器
export class JpegParser implements ImageParser {
readonly type = ImageType.JPEG;
// JPEG 魔术头:FF D8
private readonly JPEG_SIGNATURE = [0xFF, 0xD8];
// JPEG标记常量
private readonly SOF_MARKERS = {
SOF0: 0xC0, // 基线DCT
SOF1: 0xC1, // 扩展顺序DCT
SOF2: 0xC2, // 渐进式DCT
SOF3: 0xC3, // 无损
} as const;
// 非SOF标记
private readonly NON_SOF_MARKERS: number[] = [
0xC4, // DHT
0xC8, // JPEG扩展
0xCC, // DAC
] as const;
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.JPEG_SIGNATURE);
}
isSOFMarker (marker: number): boolean {
return (
marker === this.SOF_MARKERS.SOF0 ||
marker === this.SOF_MARKERS.SOF1 ||
marker === this.SOF_MARKERS.SOF2 ||
marker === this.SOF_MARKERS.SOF3
);
}
isNonSOFMarker (marker: number): boolean {
return this.NON_SOF_MARKERS.includes(marker);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise<ImageSize | undefined>((resolve, reject) => {
const BUFFER_SIZE = 1024; // 读取块大小,可以根据需要调整
let buffer = Buffer.alloc(0);
let offset = 0;
let found = false;
// 处理错误
stream.on('error', (err) => {
stream.destroy();
reject(err);
});
// 处理数据块
stream.on('data', (chunk: Buffer | string) => {
// 追加新数据到缓冲区
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer.subarray(offset), chunkBuffer]);
offset = 0;
// 保持缓冲区在合理大小内,只保留最后的部分用于跨块匹配
const bufferSize = buffer.length;
const MIN_REQUIRED_BYTES = 10; // SOF段最低字节数
// 从JPEG头部后开始扫描
while (offset < bufferSize - MIN_REQUIRED_BYTES) {
// 寻找FF标记
if (buffer[offset] === 0xFF && buffer[offset + 1]! >= 0xC0 && buffer[offset + 1]! <= 0xCF) {
const marker = buffer[offset + 1];
if (!marker) {
break;
}
// 跳过非SOF标记
if (this.isNonSOFMarker(marker)) {
offset += 2;
continue;
}
// 处理SOF标记 (包含尺寸信息)
if (this.isSOFMarker(marker)) {
// 确保缓冲区中有足够数据读取尺寸
if (offset + 9 < bufferSize) {
// 解析尺寸: FF XX YY YY PP HH HH WW WW ...
// XX = 标记, YY YY = 段长度, PP = 精度, HH HH = 高, WW WW = 宽
const height = buffer.readUInt16BE(offset + 5);
const width = buffer.readUInt16BE(offset + 7);
found = true;
stream.destroy();
resolve({ width, height });
return;
} else {
// 如果缓冲区内数据不够,保留当前位置等待更多数据
break;
}
}
}
offset++;
}
// 缓冲区管理: 如果处理了许多数据但没找到标记,
// 保留最后N字节用于跨块匹配,丢弃之前的数据
if (offset > BUFFER_SIZE) {
const KEEP_BYTES = 20; // 保留足够数据以处理跨块边界的情况
if (offset > KEEP_BYTES) {
buffer = buffer.subarray(offset - KEEP_BYTES);
offset = KEEP_BYTES;
}
}
});
// 处理流结束
stream.on('end', () => {
if (!found) {
resolve(undefined);
}
});
});
}
}
@@ -0,0 +1,32 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// PNG解析器
export class PngParser implements ImageParser {
readonly type = ImageType.PNG;
// PNG 魔术头:89 50 4E 47 0D 0A 1A 0A
private readonly PNG_SIGNATURE = [0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
canParse (buffer: Buffer): boolean {
return matchMagic(buffer, this.PNG_SIGNATURE);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
stream.once('error', reject);
stream.once('readable', () => {
const buf = stream.read(24) as Buffer;
if (!buf || buf.length < 24) {
return resolve(undefined);
}
if (this.canParse(buf)) {
const width = buf.readUInt32BE(16);
const height = buf.readUInt32BE(20);
resolve({ width, height });
} else {
resolve(undefined);
}
});
});
}
}
@@ -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<ImageSize | undefined> {
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;
}
}
@@ -0,0 +1,90 @@
import { ImageParser, ImageType, matchMagic, ImageSize } from '@/napcat-image-size/src';
import { ReadStream } from 'fs';
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
export class WebpParser implements ImageParser {
readonly type = ImageType.WEBP;
// WEBP RIFF 头:52 49 46 46 (RIFF)
private readonly RIFF_SIGNATURE = [0x52, 0x49, 0x46, 0x46];
// WEBP 魔术头:57 45 42 50 (WEBP)
private readonly WEBP_SIGNATURE = [0x57, 0x45, 0x42, 0x50];
// WEBP 块头
private readonly CHUNK_VP8 = [0x56, 0x50, 0x38, 0x20]; // "VP8 "
private readonly CHUNK_VP8L = [0x56, 0x50, 0x38, 0x4C]; // "VP8L"
private readonly CHUNK_VP8X = [0x56, 0x50, 0x38, 0x58]; // "VP8X"
canParse (buffer: Buffer): boolean {
return (
buffer.length >= 12 &&
matchMagic(buffer, this.RIFF_SIGNATURE, 0) &&
matchMagic(buffer, this.WEBP_SIGNATURE, 8)
);
}
isChunkType (buffer: Buffer, offset: number, chunkType: number[]): boolean {
return buffer.length >= offset + 4 && matchMagic(buffer, chunkType, offset);
}
async parseSize (stream: ReadStream): Promise<ImageSize | undefined> {
return new Promise((resolve, reject) => {
// 需要读取足够的字节来检测所有三种格式
const MAX_HEADER_SIZE = 32;
let totalBytes = 0;
let buffer = Buffer.alloc(0);
stream.on('error', reject);
stream.on('data', (chunk: Buffer | string) => {
const chunkBuffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
buffer = Buffer.concat([buffer, chunkBuffer]);
totalBytes += chunk.length;
// 检查是否有足够的字节进行格式检测
if (totalBytes >= MAX_HEADER_SIZE) {
stream.destroy();
// 检查基本的WEBP签名
if (!this.canParse(buffer)) {
return resolve(undefined);
}
// 检查chunk头部,位于字节12-15
if (this.isChunkType(buffer, 12, this.CHUNK_VP8)) {
// VP8格式 - 标准WebP
// 宽度和高度在帧头中
const width = buffer.readUInt16LE(26) & 0x3FFF;
const height = buffer.readUInt16LE(28) & 0x3FFF;
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8L)) {
// VP8L格式 - 无损WebP
// 1字节标记后是14位宽度和14位高度
const bits = buffer.readUInt32LE(21);
const width = 1 + (bits & 0x3FFF);
const height = 1 + ((bits >> 14) & 0x3FFF);
return resolve({ width, height });
} else if (this.isChunkType(buffer, 12, this.CHUNK_VP8X)) {
// VP8X格式 - 扩展WebP
// 24位宽度和高度(减去1)
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);
return resolve({ width, height });
} else {
// 未知的WebP子格式
return resolve(undefined);
}
}
});
stream.on('end', () => {
// 如果没有读到足够的字节
if (totalBytes < MAX_HEADER_SIZE) {
resolve(undefined);
}
});
});
}
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../tsconfig.base.json",
"include": [
"src/**/*.ts"
],
"exclude": [
"node_modules",
"dist"
]
}