Javascript如何判断文件类型?
背景
在 web 端开发中我们经常会遇到上传文件的功能,由于某些特殊场景的要求,我们需要限制文件上传的类型,比如限制只能上传 PDF 或者 Docx 格式的文件,对于这种问题,首先想到的就是通过 input
元素的 accept
属性来限制上传的文件类型:
<input type="file" accept=".docx" />
这种方式基本能满足大多数场景要求,但是不免有些用户会更改文件的后缀,导致限制失效,比如将 Txt 格式的文件后缀名更改为 Docx,为了避免出现这种问题就需要我们能够精准的获取到用户上传文件的类型,而不单单只根据文件后缀名去判断,社区比较火的 file-type 库就是个很好的例子。
文件签名
计算机系统并不是通过文件的后缀名来判断文件类型,而是通过文件签名来判断,文件签名是文件头部的一个固定字节序列,一般用16进制表示,用于标示文件的格式和类型,通常这种签名也被称为魔数。下面是常见文件的魔数:
MIME 类型 | 文件后缀 | 16进制魔数 |
---|---|---|
image/jpeg | jpg/jpeg | 0xFF D8 FF |
image/png | png | 0x89 50 4E 47 0D 0A 1A 0A |
image/gif | gif | 0x47 49 46 38 |
application/pdf | 0x25 50 44 46 | |
application/wasm | wasm | 0x00 61 73 6D |
font/ttf | ttf | 0x00 01 00 00 00 |
下面用 hexed.it 这个网站来查看一下 PDF 的魔数是否正确:
但是有一些文件例外,比如docx、ppt、xlsx,这些文件的内容都基于 Office Open XML (OOXML) 标准的文件格式,但是要注意的是他们同时也属于压缩文件,即他们的魔数是相同,所以不能简单地通过魔数来确定这些文件的类型,具体怎么判断下面会具体介绍。
检测文件类型
通常我们想要在浏览器获取文件时,常用的方式就是使用 标签,通过该标签可以获取到一个描述选择文件的 File 对象,但是 File 本身不提供直接访问文件二进制数据的接口,因此需要借助 FileReader 将文件数据读取到内存中,同时因为读出来的数据是一个 ArrayBuffer 实例,没有直观的数据形式,所以我们需要将他转换为 TypedArray 或 DataView,代码如下:
const getFileView = (file: File) => {
return new Promise<Uint8Array>((resolve, reject) => {
const fileReader = new FileReader();
fileReader.onload = () => {
const fileView = new Uint8Array(fileReader.result as ArrayBuffer);
resolve(fileView);
}
fileReader.onerror = reject;
fileReader.readAsArrayBuffer(file);
})
}
因为文件的二进制流都是由一系列的字节组成,每个字节由 8 位二进制组成,而 8 位二进制所能表示的最大值是 255,因此这里选用 Uint8Array 来存储文件。
为了实现逐字节比对并能够更好地实现复用,下面定义了一个 check 函数,专门用来比对魔数:
const check = (headers: number[]) => {
return (fileView: Uint8Array, offset?: number) => {
headers.every((header, index) => header === fileView[index + (offset || 0)])
}
}
这个函数接受一个魔数并返回一个比对函数,比对函数通过按位比对魔数来判断数据是否相等,同时这个比对函数还支持传入一个偏移量,指示应从数据的哪个位置开始对比。比如判断 ISOBMFF 格式的文件时魔数的偏移量为 4。
普通文件检测
对于 pdf、png、jpg 这种普通文件,可以直接通过比较魔数是否相等来判断文件的类型,下面具体实现检测 PDF 文件的功能:
const isPdf = check([0x25, 0x50, 0x44, 0x46])
// input标签的onChange事件
const inputChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0] as File;
const fileView = await getFileView(file);
console(isPdf(fileView) ? 'pdf文件' : '非pdf文件')
}
特殊文件检测
上面提到过docx、ppt、xlsx,这些文件都属于压缩文件,下面是我解压的一个 docx 文件:
压缩文件的魔数为 0x50, 0x4B, 0x3, 0x4所以不能简单地通过魔数来判断文件的类型,那我们就把目光转向压缩文件的结构上:
一个压缩文件可以包含多个文件,每个文件都有自己的 local header
,每个 local header
占用 30 个字节,它包含了每个文件的名称长度、大小、压缩方式、时间戳等信息,local header
后面紧跟着的就是当前文件压缩后的数据。
这是因为压缩文件中的每个文件都是独立存储的,它们可以有不同的压缩方式、时间戳等属性。通过使用多个local headers
,压缩文件可以准确地描述和表示每个文件的信息。
现在知道 local header
了该怎么去判断文件的类型呢?我们先看 docx 文件解压之后的文件信息,里面有一个 word 文件夹,表示这个文件是与 Word 文档相关的内容,以此类推,pptx 解压后的文件信息中会包含一个名称叫 ppt 的文件夹。
而我们又可以通过每个文件的 local header
获取到每个文件的名称,我们看下文件流中每个 local header
的内容:
蓝框表示 local header
,绿框表示文件名,即解压后 word 文件夹中的 endnotes.xml 文件,黄框表示该文件的压缩内容。
因此整个判断思路就有了:遍历所有的 local header → 查看文件名称的开头是不是 word。
import {Buffer} from 'node:buffer';
import * as Token from 'token-types';
import {fromBuffer, EndOfStreamError} from 'strtok3';
export const isZip = (buffer: Uint8Array, offset?: number) => {
const headers = [0x50, 0x4B, 0x3, 0x4];
return headers.every((correct, index) => correct === buffer[index + (offset || 0)]);
}
export const isDocx = async (fileView: Uint8Array) => {
debugger;
if (!isZip(fileView)) {
return false;
}
const buffer = Buffer.alloc(4100);
// 创建分词器,用于读取和查看分词器流(https://www.npmjs.com/package/strtok3#method-strtok3fromBuffer)
const tokenizer = fromBuffer(fileView)
try {
// 循环读取每一个local header,每个local header占用30字节,position默认是0
while (tokenizer.position + 30 < tokenizer.fileInfo.size!) {
// 使用分词器读取local header到buffer中,position+=30
await tokenizer.readBuffer(buffer, {length: 30});
const zipHeader = {
compressedSize: buffer.readUInt32LE(18), // 文件压缩体积
uncompressedSize: buffer.readUInt32LE(22), // 未压缩体积
filenameLength: buffer.readUInt16LE(26), // 文件名称占用字节长度
extraFieldLength: buffer.readUInt16LE(28), // 额外字段长度
filename: ''
}
// 从当前position开始读取filenameLength长的数据,即文件名称
zipHeader.filename = await tokenizer.readToken(new Token.StringType(zipHeader.filenameLength, 'utf-8'));
// 忽略掉extraFieldLength字节,position+=extraFieldLength
await tokenizer.ignore(zipHeader.extraFieldLength);
// 判断如果是office文件
if (zipHeader.filename.endsWith('.xml')) {
// 获取文件类型,比如当前文件名称是word/styles.xml,则获取结果为word
const type = zipHeader.filename.split('/')[0];
if (type === 'word') {
return true;
}
}
// 如果当前local header标示的文件压缩体积为0,则需要基于当前position去找到下一个local header的位置,
// 并且position+=nextHeaderIndex
if (zipHeader.compressedSize === 0) {
let nextHeaderIndex = -1;
while (nextHeaderIndex < 0 && (tokenizer.position < tokenizer.fileInfo.size!)) {
await tokenizer.peekBuffer(buffer, {mayBeLess: true});
nextHeaderIndex = buffer.indexOf('504B0304', 0, 'hex');
// Move position to the next header if found, skip the whole buffer otherwise
await tokenizer.ignore(nextHeaderIndex >= 0 ? nextHeaderIndex : buffer.length);
}
} else {
// 如果当前local header标示的文件压缩体积不为0,则position+=compressedSize,进入下一个local header
await tokenizer.ignore(zipHeader.compressedSize);
}
}
return false;
} catch (error) {
if (!(error instanceof EndOfStreamError)) {
throw error;
}
}
}
这里大家可以借助我的 demo 手动进行调试更好理解。