从Exifjs的原理入手,搞定js二进制数据的操作

2,187 阅读10分钟

前言

上一章我们大概了解如何通过js库来获取Exif信息。那么这些库是怎么实现的呢。我们用比较流行的Exifjs来做分析和学习。
通过学习Exifjs,我们大概可以发现,我们需要了解以下知识:
1、Exif标识
2、base64的由来和作用,以及常用api
3、js如何操作二进制流,这也是js实现读取exif信息的核心

知识点梳理

1、Exif信息标识

当照片拍摄的时候,会把Exif参数信息存放到jpeg格式文件的原始数据内部。
通过查看Exifjs源码,会发现它有一个映射关系,如下图:
image.png
那么这个映射关系是如何来的呢,通过查看Exif官方标准,发现exifjs的映射表与下图的tagId、tagName的映射关系相同。
知道这些关系以后,后面我们会根据tagId来进行exif信息的操作。
image.png

2、base64相关

base64大家可能比较熟悉,其实它不算Exif信息获取的一个核心功能,在实现中的作用也只是为了兼容图片数据格式。这里我们单独罗列出来是因为它与我们前端开发有着紧密的联系。那么了解了它的由来、作用以及相关的api对我们日常的工作还是会有一定作用的。
对于base64的由来、作用,建议大家去看看base64笔记,这里阮一峰老师做了专业、详细的讲解。
我这里主要说一下相关api的应用:
1、window.atob和window.btoa,用法很简单,作用也很简单就是有关base64的解码和编码

const str = "Man";
const base64 = window.btoa(str); // 字符串编码成base64
const result = window.atob(base64) // base64解码成字符串
console.log(base64) //TWFu
console.log(result) // Man

3、js中有关二进制数据的操作

js中关于二进制的操作主要有两大块,blob和ArrayBuffer。

3.1 Blob

Blob是一个大类,我们常用的File对象,继承自Blob对象并扩展支持用户上传。所以Blob具有的功能,基本上File都会具有。

3.1.1Blob构造函数Blob( array, options )

array,数组里面的元素共同组合成Blob的数据源。
options 可能会指定如下两个属性:

  • type,默认值为 "",它代表了将会被放入到blob中的数组内容的MIME类型。
  • endings,默认值为"transparent",用于指定包含行结束符\n的字符串如何被写入。 它是以下两个值中的一个: "native",代表行结束符会被更改为适合宿主操作系统文件系统的换行符,或者 "transparent",代表会保持blob中保存的结束符不变。

下面我们对于不同类型的数据调用一下Blob,来看看都会返回什么。

var data1 = 1;
var data2 = true;
var data3 = 'a';
var data4 = [1];
var data5 = { value: "test" };
var blob1 = new Blob([data1]);
var blob2 = new Blob([data2])
var blob3 = new Blob([data3])
var blob4 = new Blob([data4])
var blob5 = new Blob([data5])
var blob6 = new Blob([data1, data3])
var blob7 = new Blob([JSON.stringify(data5)])
var blob8 = new Blob([JSON.stringify(data5)], {type : 'application/json'})
var blob9 = new Blob([data5],{type : 'application/json'})
console.log(blob1, 'data1')
console.log(blob2, 'data2')
console.log(blob3, 'data3')
console.log(blob4, 'data4')
console.log(blob5, 'data5')
console.log(blob6, 'data6(data1,data3)')
console.log(blob7, 'data7(data5)')
console.log(blob8, 'data8(data5)')
console.log(blob9, 'data9(data5)')

1-9对应的数据返回如下:
image.png

可见Blob有两个属性 size表示包含数据的字节大小,type的值刚才所讲的options.type。
需要注意的一点是 data5, data7这两个创建Blob以后size不一样。而区别就在于是否进行了JSON.stringify。
data5.toString()是[object Object],length 正好是15。JSON.stringify(data5).length是16。看来是跟toString有关系。
我们创建一个新的data10来验证一下。

data10.toString()是test,length正好是4,JSON.stringify(data10).length是2。
这是因为在创建Blob的时候,会调用传入数据的toString方法。
image.png

3.1.2 slice方法

这个方法返回一个新的 Blob 对象,包含了源 Blob 对象中指定范围内的数据。用法跟数组的是一样的。

var sliceData = 'abcdef123';
var dataBlob = new Blob([sliceData]);
var dataBlob1 = dataBlob.slice(0, 1);
console.log(dataBlob, dataBlob1) // Blob {size: 9, type: ""} Blob {size: 1, type: ""}
async function getTextFromBlob() {
  var result1 = await new Response(dataBlob).text(); // 以字符串的方式提取blob的内容
  var result2 = await new Response(dataBlob1).text();
  console.log(result1, result2) //abcdef123 a
}
getTextFromBlob();

3.1.3 window.URL.createObjectURL

这个方法可以把blob转换成一个Blob URL,我们常用的就是在用户上传的时候把File转换成一个Blob URL进行访问。Blob URL的优点就是比base64生成的图片要短小。但是缺点就是它并没有保存图片信息,而是浏览器根据一定的规则生成一个标识来指向真实资源的。所以它是强依于赖浏览器的。当创建Blob URL的环境销毁的时候(比如调用createObjectURL的页签关闭、刷新)这个Blob URL也就无法访问了。

3.2 ArrayBuffer相关

ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer不能直接操作,需要通过类型数组对象或者DataView进行操作。

3.2.1 ArrayBuffer

ArrayBuffer只有一个参数,那就是要创建内存的大小,也就是字节数。创建出来的ArrayBuffer内容初始化是0。
具有的属性有byteLength,为字节大小,不可改变。
具有isView静态方法,判断参数是否为ArrayBuffer视图;还有slice方法类似数组的用法。

const buffer = new ArrayBuffer(10)
console.log(buffer.byteLength); // 10
console.log(ArrayBuffer.isView(buffer)); // false
const view = new Int8Array(10);
console.log(ArrayBuffer.isView(view)); // true
const buffer1 = buffer.slice(0, 1);
console.log(buffer, buffer1) 
/**
ArrayBuffer {
  [Uint8Contents]: <00 00 00 00 00 00 00 00 00 00>,
  byteLength: 10
}
ArrayBuffer { [Uint8Contents]: <00>, byteLength: 1 }
*/

3.2.2 类型数组对象TypedArray

TypedArray是一个类数组视图,用于操作ArrayBuffer创造的二进制缓冲区的数据,包括9个视图Int8Array、Uint8Array等(每位占用的字节数不同)。TypedArray使用方法类似于普通的数组,但是还是有一些差异:
1、TypeArray是视图不是一种数据存储结构,它要操作的数据是ArrayBuffer中的数据。而Array本身就是用来存储数据的。
2、TypedArray中所有成员的类型是一样的,数组对成员的类型没有限制。
3、TypedArray初始值是0,数组是empty

 const view1 = new Int8Array(10);// 在内存中生成一个缓冲区
 const arr = new Array(10);
 console.log(view1, arr)
/*
Int8Array(10) [
  0, 0, 0, 0, 0,
  0, 0, 0, 0, 0
] 
[ <10 empty items> ]
*/

4、typedArray不同视图单个元素对应容纳的数值会有范围,比如int8Array是有符号的8位二进制数值范围[-128 ,127]
image.png
TypedArray实例化
有四种传参方式分别是:length,TypedArray、object,buffer(byteOffet,length)

const buffer = new ArrayBuffer(10);
const view1 = new Int8Array(buffer);  // buffer , 可选byteoffset,length未传
const view2 = new Int8Array(5);
const buffer1 = view2.map((v, i) => i)
const view3 = new Int8Array(buffer1.buffer, 1, 2); // buffer, byteoffset偏移坐标,length视图长度
const view4 = new Int8Array(view2) // typedArray
const view5 = new Int8Array({ value: 1, length: 3 }) // object的时候会调用typedArray.from方法创建一个新的类数组
const view6 = new Int8Array([1, 2, 3, 4]) // object
const view7 = new Int16Array(buffer)  // int16 每位占用两个字节,所以10个字节的ArrayBuffer的视图长度为5,也就是分成了5段
console.log(view1, view2, view3, view4, view5)
/**
view1:
Int8Array(10) [
  0, 0, 0, 0, 0,
  0, 0, 0, 0, 0
]
view2:
Int8Array(5) [ 0, 0, 0, 0, 0 ] 
view3:
Int8Array(2) [ 1, 2 ] 
view4:
Int8Array(5) [ 0, 0, 0, 0, 0 ] 
view5:
Int8Array(3) [ 0, 0, 0 ]
view6: 
Int8Array(4) [ 1, 2, 3, 4 ]
view7:
Int16Array(5) [ 0, 0, 0, 0, 0 ]
**/

3.2.3 DateView视图

DataView 视图是一个可以从ArrayBuffer对象中读写多种数值类型的底层接口,使用它时,不用考虑不同平台的字节序问题。字节序此处就不展开了,有兴趣的同学可以传送了解字节序。大部分的计算机(所有英特尔处理器)都采用小字节序。
DateView的构造函数接受参数new DataView(buffer [, byteOffset [, byteLength]]),类似于我们上面讲的TypedArray中的buffer传参模式。

const buffer = new ArrayBuffer(10);
const view1 = new Int8Array(buffer);
view1[1] = 2;
const view = new DataView(buffer, 1, 2);
console.log(view)
console.log(view.getInt8(0))
console.log(view.getInt16(0)) //一次获取两个字节
console.log(view.getInt16(1)) //Offset is outside the bounds of the DataView
/**
view:
DataView {
  byteLength: 2,
  byteOffset: 1,
  buffer: ArrayBuffer {
    [Uint8Contents]: <00 02 00 00 00 00 00 00 00 00>,
    byteLength: 10
  }
}
view.getInt8(0)
2
view.getInt16(0)
512
view.getInt16(1)
Offset is outside the bounds of the DataView
**/

DateView不用考虑平台的字节序,指的是它制定字节序。两个字节及以上的getXXX存在两个参数“偏移量和字节序”,字节序默认是高字节序。getInt8只有一个参数,getInt16有两个参数。

const buffer = new ArrayBuffer(10);
const view1 = new Int8Array(buffer);
view1[1] = 2;
const view = new DataView(buffer, 1, 2);
console.log(view.getInt8(0)) // 2
console.log(view.getInt8(1)) // 0
console.log(view.getInt16(0)) // 512
console.log(view.getInt16(0, true)) // 2 字节序为true代表小字节序

以下是写入字节,超过两个字节也涉及到字节位的问题,同以上的get获取类似。

const buffer = new ArrayBuffer(10);
const view1 = new Int8Array(buffer);
view1[1] = 2;
const view = new DataView(buffer, 1, 2);
console.log(view.getInt8(0)) // 2
console.log(view.getInt8(1)) // 0
view.setInt8(1, 5)
console.log(view.getInt8(0)) // 2
console.log(view.getInt8(1)) // 5
view.setInt16(0, 5)
console.log(view.getInt8(0)) // 0
console.log(view.getInt8(1)) // 5
view.setInt16(0, 5, true)
console.log(view.getInt8(0)) // 5
console.log(view.getInt8(1)) // 0

4、获取Exif信息

主要思想就是,通过JPEG的格式和标志来判断信息。
关于Exif信息的含义,参考资料为文章:“EXIF信息及含义”。
1、JPEG文件都是以十六进制 '0xFFD8’开始,以’0xFFD9’结束。在JPEG数据中有像’0xFF**'这样的数据,这些被称为“标志”,它表示JPEG信息数据段。0xFFD8 表示SOI(Start of image 图像开始),0xFFD9表示EOI(End of image 图像结束)。这两个特殊的标志没有附加的数据,而其他的标志在标志后都带有附加的数据。
2、从0xFFE0 ~ 0xFFEF 的标志是“应用程序标志”,Exif使用0xFFE1标志Exif信息,Exif数据是从ASCII字符"Exif"和2个字节的0x00开始,后面就是Exif的数据了。
3、TIFF头指的是TIFF格式的前8个字节。前两个字节定义了TIFF数据采用何种字节顺序。如果是0x4949 ,表示采用"Intel"的小字节序,如果为0x4d4d ,表示采用高字节序。
4、TIFF头的最后4个字节是第一个IFD(Image File Directory, described in next chapter 图像文件目录,描述下一个字符)的偏移量。在TIFF格式中所有的偏移量都是从TIFF头的第一个字节(0x4949或者0x4d4d)开始计算的到所在位置的字节数目,这个偏移量也不例外。通常第一个IFD是紧跟在TIFF头后面的,所以它的偏移量为’0x00000008’。
5、接着TIFF头的是第一个IFD。它包含了图像信息数据。在下表中,开始的两个字节(‘EEEE’)表示这个IFD所包含的目录实体数量。然后紧跟着就是实体对象(每个实体12个字节)。在最后一个目录实体后面有一个4字节大小的数据(表中的是’LLLLLLLL’),它表示下一个IFD的偏移量。如果这个偏移量的值是’0x00000000’,就表示这个IFD是最后一个IFD。
完整代码如下:

class MyExif {
    constructor(image) {
        return this.getImageExif(image)
    }
    getImageExif(image){
        if(image.src) {
            return this.domImageExif(image.src)
        } else if(image instanceof Blob){
            return this.blobImageExif(image)
        }
    }
    async blobImageExif(file) {
        var _t = this
        return new Promise(res => {
            var fileReader = new FileReader();
            fileReader.onload = function(e) {
                res(_t.findExifInJPEG(e.target.result));
            };
            fileReader.readAsArrayBuffer(file);
        })
    }
    async domImageExif(src){
        let result;
        if (/^data\:/i.test(src)) { // Data URI
            result = this.base64Url(src);
        } else {
            result = await this.blobUrl(src);
        }
        return Promise.resolve(result)
    }
    base64Url(base64){
        base64 = base64.replace(/^data\:([^\;]+)\;base64,/gmi, '');
        const binary = atob(base64);
        const len = binary.length;
        const buffer = new ArrayBuffer(len); // 根据长度创建二进制缓冲区
        const view = new Uint8Array(buffer); // 创建视图操作
        for (let i = 0; i < len; i++) {
            view[i] = binary.charCodeAt(i); //获取字符串的Unicode 
        }
        return this.findExifInJPEG(buffer)
    }
    async blobUrl(src){
        const buffer = await this.httpToBlob(src);
        return this.findExifInJPEG(buffer)
    }
    httpToBlob(url){
        return new Promise(res => {
            var http = new XMLHttpRequest();
            http.open("GET", url, true);
            http.responseType = "arraybuffer";
            http.onload = function(e) {
                if (this.status == 200 || this.status === 0) {
                    res(this.response);
                }
            };
            http.send();
        })
        
    }
    findExifInJPEG(buffer){
        var dataView = new DataView(buffer);
        // JPEG文件都是以十六进制 '0xFFD8’开始
        if ((dataView.getUint16(0) != 0xFFD8)) {
            console.log("Not a valid JPEG");
            return false; // not a valid jpeg
        }
        
        let length = buffer.byteLength;
        let exifStartIndex = this.getExifPosition(dataView, length);
        if(exifStartIndex) {
            // 根据上一次获取的索引,然后根据索引后4位判断后续是否是Exif信息
            if(this.getStringFromDB(dataView, exifStartIndex, 4) !== 'Exif') {
                console.log('Not valid EXIF data!');
                return ;
            }
            // 这里exifStartIndex + 6的原因是Exif数据是从ASCII字符"Exif"和2个字节的0x00开始,后面就是Exif的数据了。EXif+2个字节 正好是6
            return this.getTIFFInfo(dataView, exifStartIndex + 6)
        } else {
            return undefined
        }
        
    }
    getExifPosition(dataView, byteLength) {
        let offset = 2; //因为前两位是0xFFD8,ArrayBuffer 使用的视图是Uint8Array所以从2(0xFFD8是两位)开始遍历
        while(offset < byteLength) {
            // Exif使用APP1(0xFFE1)标志
            if(dataView.getUint8(offset) === 0xFF && dataView.getUint8(offset + 1) === 0xE1) {
                //APP1的数据从"SSSS"后开始 所以加4
                return offset + 4;
            }
            offset++;
        }
    }
    getStringFromDB(dataView, start, length){
        let max = start + length;
        let result = "";
        for(let i = start; i < max; i++) {
            result += String.fromCharCode(dataView.getUint8(i))
        }
        return result;
    }
    getTIFFInfo(dataView, tiffOffset) {
        //前两个字节定义了TIFF数据采用何种字节顺序
        let bigEndian = dataView.getUint16(tiffOffset) === 0x4D4D;
        // 然后的两个字节总是2个字节长度的0x002A
        if (dataView.getUint16(tiffOffset + 2, !bigEndian) != 0x002A) {
            console.log("Not valid TIFF data! (no 0x002A)");
            return false;
        }
        //TIFF头的最后4个字节是第一个IFD的偏移量。
        const firstIFDOffset = dataView.getUint32(tiffOffset + 4, !bigEndian);
        //通常第一个IFD是紧跟在TIFF头后面的,所以它的偏移量为’0x00000008’
        if (firstIFDOffset < 0x00000008) {
            console.log("Not valid TIFF data! (First offset less than 8)", dataView.getUint32(tiffOffset+4, !bigEndian));
            return false;
        }
        // 0x0112 代表Orientation 
        return this.getTag(dataView, tiffOffset + firstIFDOffset, bigEndian, 0x0112)
    }
    getTag(dataView, dirStart, bigEndian, tag){
        const length = dataView.getUint16(dirStart, !bigEndian);
        for(let i = 0; i < length; i++) {
            //开始的两个字节(‘EEEE’)表示这个IFD所包含的目录实体数量。然后紧跟着就是实体对象(每个实体12个字节)
            let offset = dirStart + i * 12 + 2;
            if(dataView.getUint16(offset, !bigEndian) === tag) {
                // 此处只获取Orientation的值,需要偏移8位
                offset += 8;
                return dataView.getUint16(offset, !bigEndian);
            }

        }

    }
}

5、最后

1、js二进制操作的主要场景有:文件的切割、下载、内容获取,内容处理等。
2、blob相对于ArrayBuffer的可操作性还是比较小的。
3、base64、blob、ArrayBuffer的互相转换。
遇见好几次exif相关的问题了一直用exifjs。想着深究一下原理,没想到Exif标准真烧脑。。。
image.png