mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-02-06 07:29:38 +00:00
426 lines
14 KiB
TypeScript
426 lines
14 KiB
TypeScript
import * as fs from 'fs';
|
||
import { ReadStream } from 'fs';
|
||
|
||
export interface ImageSize {
|
||
width: number;
|
||
height: number;
|
||
}
|
||
|
||
export enum ImageType {
|
||
JPEG = 'jpeg',
|
||
PNG = 'png',
|
||
BMP = 'bmp',
|
||
GIF = 'gif',
|
||
WEBP = 'webp',
|
||
UNKNOWN = 'unknown',
|
||
}
|
||
|
||
interface ImageParser {
|
||
readonly type: ImageType;
|
||
canParse(buffer: Buffer): boolean;
|
||
parseSize(stream: ReadStream): Promise<ImageSize | undefined>;
|
||
}
|
||
|
||
// 魔术匹配
|
||
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;
|
||
}
|
||
|
||
// PNG解析器
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// JPEG解析器
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// BMP解析器
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// GIF解析器
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
// WEBP解析器 - 完整支持VP8, VP8L, VP8X格式
|
||
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[24] || !buffer[25] || !buffer[26] || !buffer[27] || !buffer[28] || !buffer[29]) {
|
||
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);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
}
|
||
|
||
const parsers: ReadonlyArray<ImageParser> = [
|
||
new PngParser(),
|
||
new JpegParser(),
|
||
new BmpParser(),
|
||
new GifParser(),
|
||
new WebpParser(),
|
||
];
|
||
|
||
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
|
||
});
|
||
|
||
let buffer: Buffer | null = null;
|
||
|
||
stream.once('error', (err) => {
|
||
stream.destroy();
|
||
reject(err);
|
||
});
|
||
|
||
stream.once('readable', () => {
|
||
buffer = stream.read(64) as Buffer;
|
||
stream.destroy();
|
||
|
||
if (!buffer) {
|
||
return resolve(ImageType.UNKNOWN);
|
||
}
|
||
|
||
for (const parser of parsers) {
|
||
if (parser.canParse(buffer)) {
|
||
return resolve(parser.type);
|
||
}
|
||
}
|
||
|
||
resolve(ImageType.UNKNOWN);
|
||
});
|
||
|
||
stream.once('end', () => {
|
||
if (!buffer) {
|
||
resolve(ImageType.UNKNOWN);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
export async function imageSizeFromFile(filePath: string): Promise<ImageSize | undefined> {
|
||
try {
|
||
// 先检测类型
|
||
const type = await detectImageType(filePath);
|
||
const parser = parsers.find(p => p.type === 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;
|
||
} |