笔记:使用 JavaScript 检测文件 MIME TYPE

⚠️ update: 2018-02-24,有了自己域名,换一条博客链接:

笔记:使用 JavaScript 识别文件 MIME TYPE 类型moxo.io图标

后续问题整理和一些值得记录:

笔记:使用 JavaScript 识别文件 MIME TYPE 类型 - 后续问题和值得记录moxo.io

---

副标题:Typed Array and DataView 可以做什么?以及使用中会遇到的坑。

2017-02-02 updated,写完这篇笔记之后直接去请教了 @小爝 爝神关于字节乱序的问题,他贴在评论区的链接看了几遍反复测试了一下,在内容中加入我自己实践测试的内容。顺便在顶部给文章加了个大致的纲要。

笔记内容大概包含:

  1. 遇到的问题
  2. 解决思路和 demo
    1. 原理
    2. 步骤
    3. D is for Demonstration
  3. 解决问题过程中值得记录的坑:
    1. 关于 readAsArrayBuffer()
    2. FF D0 FF E1 是什么?
    3. Typed Array v.s DataView ?
      1. Typed Array 的字节对齐问题
      2. Typed Array 的字节序列问题
      3. DataView
    4. MIME TYPE sniffing
  4. 参考
  5. 用到的在线工具


---

遇到的问题

图片文件进行上传时,用户手动将文件的扩展名,比如说,从 .png 修改成 .jpg,使用 input[type="file"] 的 onchange 事件得到的 e.target.files[0].type 会是 image/jpg,可这个结果并不正确。那么,问题就来了:通过前端手段,如何才能获取文件真实的 MIME TYPE?在这里结果应该是 `image/png` 才对。

// 上文提到的,取得 file.type 的一般方法
input[type="file"].onchange = (e) => {
    console.log(`file type is ${e.target.files[0].type}`);
}

这是最近一周在项目中面对的问题。自此,有了这篇笔记,问题的答案也就理所当然的是肯定可以。

---

解决思路和 demo:

原理:

不复杂,可以概括成一句:使用不同类型文件本身独一无二的签名 File signature (也被称为 Magic Number),来对文件类型(MIME TYPE)进行匹配,得到结果。

步骤:

  1. 在 input[type="file"].onchange = func(e) 中,调用 readAsArrayBuffer() 方法,将获取到的文件转换成 ArrayBuffer。
  2. 使用 Typed Array 或者 DataView 将 ArrayBuffer 转化成可以查看、操作的对应对象。
  3. 查询相关文件类型签名,从 List of file signaturesAll File Signatures. 搜索我们需要的 signature。比如 JPG/JPEG,可能会是 FFD8FFE0FFD8FFE1FFD8FFE2FFD8FFE3 等。
  4. 从第 2 步中得到的对象中提取我们需要的部分,与第 3 步中得到的文件签名进行匹配,得到结果。

比如:假设需要对比一个图像文件是否是 JPG/JPEG 的文件,略过一系列事件监听声明,对比文件类型的操作集中于下:

FR.onload = (e) {
    let af = e.target.result
      , view = new DataView(af)
      , first4Byte = view.getUint32(0, false)
      , hexValue = Number(first4Byte).toString(16);
      
    switch (hexValue) {
        case 'FFD8FFE0':
        case 'FFD8FFE1':
        case 'FFD8FFE2':
        case 'FFD8FFE3':
            console.log('is JPEG/JPG'); 
            break;
        default:
            console.log('undefined');
            break;
    }
}
FR.readAsArrayBuffer(file)

在 CodePen 上了一个简单 demo:Read File MIME Type using JavaScript,可以去测试一下,截图如下:(截图中上传的文件为 original.fake.png,是一张后缀名从 .jpg 修改成 .png 的图片,第一部分是用传统的方法获取得到的文件类型,第二部分则是使用匹配文件 signature 的方式得到文件类型 )


---


解决问题过程中值得记录的坑:

1. 关于 `FileReader` 的 `readAsArrayBuffer()` 方法 :

该方法是用来获取文件的二进制数据,如果搜索到一些相对久远之前的博客内容,可能还会提到一个方法:readAsBinaryString(),虽然不少浏览器中仍旧可以成功调用,不过已经基本被废弃。可以参考:the readAsBinaryString(blob) method should be considered deprecatedMDN 上相关的文档页

---

2. FF D8 FF E1 是什么?

首先,它是四组十六进制数,每一组两位,即 FF D8 FF E0。计算机里由二进制 0 和 1 表示的数据的另外一种展现形式。在这里也是 4 个 byte,每个 byte 由八个 bit 组成。更直观的二进制数 0 和 1 的表示如下:

// hex
FF D8 FF E0
// binary
11111111 11011000 11111111 11100000

其次,它是 JPE/JPEG 文件的开头一部分的固定字节下划线部分写错了,FF D8 FF E1 并不是固定字节,只有 FF D8 是 JPE/JPEG 开头固定的,即 SOI (start of image),接下来有可能是 FF E1(APP1),也有可能是 FF E0(APP0)。


Offset   Length   Contents
  0      1 byte   0xff
  1      1 byte   0xd8 (SOI) start-of-image
  2      1 byte   0xff
  3      1 byte   0xe0 (APP0)
  ...
         1 byte  0xff
         1 byte  0xd9 (EOI) end-of-image

在 CodePen 上做了一个简单的 demo,把图片文件的二进制、十六进制数据都打出来:链接(测试的图片文件如果很大,会造成浏览器卡顿),截图:



---

3. Typed Array v.s DataView ?

解决办法里提到,操作文件二进制数据,JavaScript 提供了 Typed Array 和 DataView 两个 API。但在实际中用中选择使用了 DataView 来处理,因为 Typed Array 来读取 ArrayBuffer 数据时候有两个坑需要面对:一是字节对齐,二是字节序

---

3.1 首先说一下字节对齐,举个例子:

let str = 'abcde' 
  , strBlob = new Blob([str], {type: 'text/plain'})
  , FR = new FileReader()

FR.onloadend = (e) => {
  let af = e.target.result
  let uint8 = new Uint8Array(af)
  console.log('Uint8Array: ' + uint8)
}

FR.readAsArrayBuffer(strBlob)

console 中得到的结果:

Uint8Array: 97,98,99,100,101

如果将以上 `FR.onloadend()` 方法内容替换成转换将 ArrayBuffer 包装成 Uint16Array:

FR.onloadend = (e) => {
  let af = e.target.result
  let uint16 = new Uint16Array(af)
  console.log('Uint16Array: ' + uint16)
}

就会报错:

Uncaught RangeError: byte length of Uint16Array should be a multiple of 2

解释一下:

  1. File 类直接继承于 Blob 类,所以 FileReader 的方法也可以作用于 Blob 的实例,所以首先构造了一个 strBlob,然后通过 FileReader 的 readAsArrayBuffer() 来获取我们想要用来构造 Typed Array 的 ArrayBuffer。
  2. 第一步构造的是 Uint8Array,Uint8Array 中的每一个元素就是一个八位无符号整数,比如说 `str` 中的 a 这个字幕,就被转换成了 97,对应的八位无符号整数就是 01100001,实际上就是由八个 bit 组成的一个 byte。
  3. 替换 FR.onloadend() 方法内容后,将 arrayBuffer 转换成 Uint16Array ,发生了什么以至于报错?做了两个表格,对照帮助理解:

Uint8Array from blob:

item:    0    1    2    3    4
char:    a    b    c    d    e
Uint8:   97   98   99   100  101

Uint16Array from blob:

item:        0        1        2    
char:        ab       cb       e?    
Uint16:      25185    25899    ????? 

Uint16Array 中的每个元素都是 2-bytes-wide 的长度,即十六位无符号整数,我们处理的字符串是 'abcde',每个字母对应一个八位整数,所以 Uint16Array 最后一个元素就缺少了一位。

在这里关于 a: 97 和 b: 98 而 ab 等于 25185 的问题,并不是计算机专业出身的我挺困扰于是去请教了 zchan0 姐 ,结合她的回答和小爝贴的参考文章:TypedArray or DataView: Understanding byte order 中的内容,可以这么理解:

16 位的话有一个字节要移 8 位相当于乘 2^8 = 256,比如 ab 它这里是把 b 放在了高位,就是 98*256 + 97 = 25185。
char:        a            b
decimal:     97           98
hex:         61           62
binary:      01100001     01100010

// 这里设定 b 在高位的情况
ab: b(01 10 00 10) + a(01 10 00 01) 
=  (01 10 00 10 00 00 00 00)+ 01 10 00 01
=   binary: 01 10 00 10 01 10 00 01
=   hex: 6261
=   decimal: 25185

JavaScript 提供了 Bitwise operators ,其中 Left Shift(<<)可以验证以上计算:

// console 中直接输入 
(98 << 8) + 97
// 得到
25185

---

3.2 接着是字节序,先来复现一下遇到的坑:

首先假设我们拿到了一张 jpg 文件的 ArrayBuffer:

let af = GetArrayBufferSomehow()

通过 Uint8Array 来构造数据并查看文件头信息,得到的结果:ffd0ffe8,没问题:

let uint8 = new Uint8Array(af)
   , bytes = uint8.subarray(0, 4);
console.log('uint8: ', bytes.reduce((hex, decimal) => hex + Number(decimal).toString(16) + ' ', ' '))
// 输出 ffd0ffe8

接着看 Uint16Array,输出 d8ffe0ff :

let uint16 = new Uint16Array(af)
  , bytes = uint16.subarray(0, 2);
console.log('uint16: ', bytes.reduce((hex, decimal) => hex + Number(decimal).toString(16) + ' ', ' '))
// 输出 d8ffe0ff

最后 Uint32Array,得到的结果:e0ffd8ff:

let uint32 = new Uint32Array(af)
  , bytes = uint32.subarray(0, 1);
console.log('uint32: ', bytes.reduce((hex, decimal) => hex + Number(decimal).toString(16) + ' ', ' '))
// 输出 e0ffd8ff

Uint8Array 构造输出的完全没问题,因为数组里一个无符号整数元素正好对应一个八 bit 的字节,但是 Uint16Array 和 Uint3Array 都分别出现乱序,因为前者一个元素代表了两个字节,而后者一个元素代表了四个字节。那么为什么多于一个字节就会出现乱序?这就是字节序问题(Endian)。

那么这个字节序问题到底是什么?从 TypedArray or DataView: Understanding byte order 引用一句:

the order of bytes in a value that is longer than one byte differs depending on the endianness of the system.
  • for long,可以参考维基百科:ENZH 和这篇 Binary World:ArrayBuffer、Blob以及他们的应用
  • for short,不同国家的人使用不同的语言以及语言书写方式,有从左往右,也有从右往左,计算机业和它的语言也是一样,对于数据(字节 byte)的存储有小端序(little-endian)、大端序(big-endian)等之分。

比如我们有四个字节:0xAABBCCDD,以小端序(little-endian)排列就是:


memory address:    01    02    03    04
byte:              DD    CC    BB    AA

以大端序(big-endian):

memory address:    01    02    03    04
byte:              AA    BB    CC    DD

Javascript Typed Arrays and Endianness 上找到一个片段可以帮助理解:

function checkEndian() {
    var arrayBuffer = new ArrayBuffer(2);
    var uint8Array = new Uint8Array(arrayBuffer);
    var uint16array = new Uint16Array(arrayBuffer);
    uint8Array[0] = 0xAA; // set first byte
    uint8Array[1] = 0xBB; // set second byte
    if(uint16array[0] === 0xBBAA) return "little endian";
    if(uint16array[0] === 0xAABB) return "big endian";
    else throw new Error("Something crazy just happened");
}

在浏览器运行以上代码片段得到的结果是「little endian」。

其实到了这里,我在制作 demo 的过程中仍旧不能十分确定 Uint16Array 和 Uint32Array 乱序是因为大小端序问题,直到找到了 DataView,并对其进行了验证。

---

DataView:

JavaScript 针对二进制数据处理不仅提供了以上 Typed Array 的 view,同时还提供了一个 DataView,主要的区别在于 DataView 本身的方法可以直接避免遇到以上的坑。比如:

dataview.getUint32(byteOffset [, littleEndian])

这个方法,可以直接传入第二个参数来对数据的读取的字节序做处规定。

以下写了一个非常简单的例子,结合了 Typed Array 和 DataView 以及遇到的 FFD8FFE0,想要验证以上的 Uint32Array 问题确实是由于浏览器的字节序引起:

let af = new ArrayBuffer(4)
  , uint8 = new Uint8Array(af);

uint8[0] = '0xFF';
uint8[1] = '0xD8';
uint8[2] = '0xFF';
uint8[3] = '0xE0';

console.log(uint8.reduce((hex, decimal) => hex + Number(decimal).toString(16) + ' ', ' ')) 
// 输出:ffd8ffe0

let view = new DataView(af);
console.log(view.getUint32(0, false).toString(16))
// 输出:ffd8ffe0
console.log(view.getUint32(0, true).toString(16))
// 输出:e0ffd8ff
  • 这里首先新建了一个四个字节长度的空 ArrayBuffer,首先用 Uint8Array 作为 view 对它进行操作,依次写入四个字节:0xFF、0xD8、0xFF、0xE0。
  • 接着使用 DataView 对已经被修改了的 ArrayBuffer 进行读取。


(之所以介绍并且使用 DataView.getUint32() 这个方法来做验证,是为了对应上文中 Uint32Array 出现的乱序。强调一下 DataView 还有 getUint8()、getUint16() 等方法,对应到 Uint8Array 和 Uint16Array 等的验证,可以自己参考 MDN 相关文档 DataView 尝试一下)

从输出的结果来看,当指定为 isLittleEndian 为 true 的时候,即以小端序的方式获取数据,得到的结果和上文中 Uint32Array 获取到的数值一致:e0ffd8ff。结合 checkEndian() 方法得到的结果,浏览器环境中默认确实是以小端序存储、读取数据,而遇到的 Typed Array 的字节乱序问题也确实是由于这个引起。同时也可以得出结论,虽然 wiki 上有,JEP/JEPG 文件是以 big-endian 方式进行数据(byte)写入。

而在 demo 中使用 DataView 作为 ArrayBuffer 的 view 来对其进行读取操作正是因为以上这个原因。

---

MIME TYPE SNIFFING

我的笔记标题是《使用 JavaScript 识别文件 MIME TYPE 类型》,但我在 demo 中所做的虽然可以在一定程度上检测出图片是否是 JPEG 格式,可实际上并不是非常严谨的处理方式。这里贴一下 MIME Sniffing Standard 作为更严谨验证 MIME TYPE 的参考。

---

参考:


---

用到的在线工具

---

结语:

抛开 AJAX.send(binary) 之外,这是第一次接触前端在浏览器环境里处理二进制数据,尽可能从头到位写了遍过程,以及处理问题遇到的坑,特别是字节对齐、字节序那一块,希望多多少少对搜索到这文章的人有所帮助。

评论区关于前端检查文件类型的有无必要,at least 我觉得在移动端盛行的今天,如果在前台就可以检查出文件类型问题,则可以直接避免上传到服务器的带宽、流量浪费。但是这篇笔记的处发点:一是在于对一个势在必行的需求的处理过程的总结,希望可以自我梳理一遍涉及到的知识点,分享出来 for anyone's future reference;二是我并不是 CS 专业,笔记中很多内容除了阅读参考文档、别人的 blog 文章,以及自己做测试之外,并没有更多的依据,所以并不自信每一行每个词都用的正确无误,所以写出来希望了解这块处理的人如果看到任何错误的地方可以加以指正。