JavaScript解析二进制Exif和图片矫正

529 阅读13分钟

背景

近期一个项目中,需要对上传图片做预览、压缩等操作,实现思路:

  • 二进制(Blob)对象转base64的DataURL,然后使用img或者background-image实现预览;
  • 基于DataURL生成的img标签,借助canvasdrawImage方法,可实现裁切;
  • 借助canvastoDataURL或者toBlob方法,在转为jpeg时更改质量参数,实现体积压缩。

但真机操作出现问题:图片经常会在预览时意外翻转,尤其是手机直接拍摄的照片使用,出现概率几乎百分百。

这个问题,在七、八年前那波h5移动端营销刚开始的时候遇到过,定位问题与图片的EXIF信息相关。因为当时借助canvas对图片进行了重度处理,推测是部分EXIF信息丢失导致的。虽然当时已经有了EXIF读取相关的js库,但是限于当时的个人技术能力,及项目进度问题,最后就搁置了。

时隔多年,又去npm搜了一下exif,得到如下结果:

image.png

前三的exif库比我家猫的岁数都大……不能忍,动手写个自己的工具。

操作二进制对象的抓手

File/Blob这种二进制对象,都是无法直接操作的。在进行解析之前,我们需要创建一个可以直接读取的对象:Uint8Array

const convertBlobToBin = async (blob: Blob) => {
    const buff = await blob.arrayBuffer()
    const bin = new Uint8Array(buff)
    return bin
}

关于Uint8ArrayByte

Uint8Array这个内置对象是TypedArray的一个具体实现,这里我们把Uint8Array拆解成u int 8 array来逐一解释一下,做一下知识拉平:

  • u表示无符号。众所周知,有符号数字的高位第一个bit符号位01负。无符号声明表示第一位仍然作为计数位,也就是这个数字是不小于0的。
  • int表示整数,即所有字节都用来表示整数部分。
  • 8表示使用8个bit(位)来记录这个整数。
  • array表示数组,但并不是Array,是TypedArray标准。

综上,Uint8Array无符号8位整数数组

同时,我们知道文件都是以byte(字节)来存储,一个byte其实就是一个uint8。所以当我们把Blob/File(文件)转为Uint8Array数组后,数组的每个元素,与文件的字节一一对应。这样我们就可以通过读取数组,来读取文件的内容。

符号

回头来再来看符号问题。上面提到了,有符号的数据类型,会占用一个bit来表示符号,这样就会导致数据表达少一位。比如uint8有8个bit表达数据,而int8只有7个位表达数据。

无符号

uint8是一个无符号8位的2进制,无符号表示8位全部用来表示数字,其范围是:

// 二进制表达
00000000 ~ 11111111
// 10进制表达
0 ~ 255
// 16进制表达
0x00 ~ 0xFF

有符号

int8是一个无符号8位的2进制,无符号表示8位全部用来表示数字,其范围是:

// 二进制表达
10000000 ~ 01111111
// 10进制表达的实际值
-128 ~ 127
// 16进制字节表达 这里与二进制对应
0x80 ~ 0x7F

有符号的字节表达看起来违背直觉,需要从二进制角度分析。推荐一个工具:

使用DataView查看数据二进制

如果直接使用(-128).toString(2)来查看二进制,会得到-10000000这样一个玩笑。我们需要真正把数据类型做转换,这就用到DataView对象:

new DataView(new Int8Array([-128]).buffer).getUint8(0)
// 输出128
new DataView(new Int8Array([-128]).buffer).getUint8(0).toString(2)
// 输出 '10000000'

等脑子能转过来了,也可以直接:

new Uint8Array([-127])[0]
// 129
new Int8Array([129])[0]
// -127

铺垫了这么多,不知道我是否表达清楚了。如果你对二进制和字节的知识感觉没那么清晰,建议先补一下相关知识。毕竟前端能操作二进制就是这两年的事,这块是前端开发者的弱项也不奇怪。后面我们将直接操作Uint8Array,使用16进制0xNN来表示一个字节,过程不再赘述。

EXIF解析

本文中关于如何读取EXIF信息,是我将AI对话与搜索结果进行综合得出的结论。建议有能力的同学抽空读一下EXIF标准文档。

PDF: www.cipa.jp/std/documen…

1. 读取EXIF概览(schema)

EXIF的头部信息很像一个硬盘分区表索引,记录和规划了EXIF区块的数据规格和存储结构。下面会分7个步骤,解析这个索引信息。

1.1 EXIF的起始标记

EXIF信息以[0xFF, 0xE1]两个字节开头。第一步就是遍历整个文件,查找这两个字节:

const findExifStart = (bin: Uint8Array) => {
    return bin.findIndex((ch, i, arr) => {
        return i < arr.length - 1 && arr[i] === 0xFF && arr[i + 1] === 0xE1
    })
}

1.2 EXIF的数据块尺寸

紧跟在[0xFF, 0xE1]之后2个字节,是EXIF数据区块的大小,也就是一个uint16数值:

const bin = await convertBlobToBin(file)
const start = findExifStart(bin)
// 未找到 0xFF 0xE1 标记
if(start < 0) {
    return file
}
// 注意这里start是开始标记2字节的起始位置,后面需要加上2字节偏移
const sizeBytes = bin.slice(start + 2, start + 4)

现在拿到这两个字节的数据了,如何将其转换为一个数字呢?简单写一个函数:

const parseBinNumber = (bin: Uint8Array) => {
    if (bin.length < 1) {
        return NaN
    }
    let n = 0
    bin.forEach(c => {
        n = (n << 8) | c
    })
    return n
}

继续解析

// ...
const sizeBytes = bin.slice(start + 2, start + 4)
const size = parseBinNumber(sizeBytes)

我们得到[0x02, 0x8F]两个表示exif块大小的字节,转为10进制数字为655

这个数据,不包含1.1头部的2字节,但是包含1.2数据的2字节,也就是说:整个EXIF区块一共655 + 2 = 657个字节。

1.3 EXIF签名

按exif标准,在区块大小后面4个字节,应当是Exif四个字符(区分大小写)。如果不是这4个字符,可以认定不是正常的EXIF信息,放弃后续解析。

在这之前我们来写一个简单的字节转字符串的函数:

const parseBinString = (bin: Uint8Array) => {
    const arr: string[] = []
    bin.some(c => {
        if (c === 0) {
            return true
        }
        arr.push(c === 0 ? '' : String.fromCharCode(c))
        return false
    })
    return arr.join('')
}

注意:遇到字节0应当中断字符串读取。

继续解析

// ...
const signBytes = bin.slice(start + 4, start + 8)
const sign = parseBinString(signBytes)
if(sign !== 'Exif') {
    return null
}

得到字节[0x45, 0x78, 0x69, 0x66, 0x00, 0x00],即Exif四个字符。

1.4 识别字节大小端

在签名后隔2个字符,读两个字节,是字节大小端。这里也是用字符串来表示的。

  • MM(0x4D4D): Big Endian
  • II(0x4949): Little Endian

有什么用呢?

假如我们读取2个字节为[0x02, 0x8F],需要转为数字(2个8位字节共16位,不考虑符号,应为uint16),大端序解析:

(0x02 << 8) | 0x8F // 655

小端序解析:

(0x8F << 8) | 0x02 // 36610

大小端的原始定义可能不是很好理解。在实际操作中,大端序按实际字节存储序读取就可以,小端序需要将一个单位的字节(16位2字节/32位4字节)翻转读取。

也可以使用DataView对象直接进行转换:

const dv = new DataView(new Uint8Array([0x02, 0x8F]).buffer)
// 默认大端序 返回655
console.log(dv.getUint16(0))
// 小端序 返回36610
console.log(dv.getUint16(0, true))

继续解析:

// 获取端序标记 MM / II
const endian = parseBinString(bin.slice(start + 10, start + 12))
// 是否是小端序
const littleEndian = endian === 'II'

1.5 TIFF标记

const tiffMagicNumber = parseBinNumber(bin.slice(start + 12, start + 14), littleEndian)

固定值为42,也就是[0x2A 0x00],表示TIFF数据解析开始,同时也是TIFF文件的魔数。

1.6 IFD偏移

IFD可以看作一个信息簇,集中存放同一类信息。比如GPS定位信息,会单独存放在一个IFD区块中。后续4字节存储第一个IFD的偏移位置,即start + 2后面第ifdOffset开始作为IFD计算起点。后面的IFD链都以此为基准

const ifdOffset = parseBinNumber(bin.slice(start + 14, start + 18), littleEndian)
const ifdStart = start + 2 + ifdOffset

1.7 标签(tag)数量

在一个IFD簇中,由多个tag构成一个索引。后面的两字节告知一共有多少个tag,然后按12字节一个tag循环读取即可。

const tagCount = parseBinNumber(bin.slice(start + 18, start + 20), littleEndian)

到此,我们已经把Exif的概要信息读取完成:

序号位置类型说明
1.1 起始标记-uint16搜索[0xFF, 0xE1],得起始索引值i
1.2 数据长度[i + 2, i + 4]uint16-
1.3 签名[i + 4, i + 10]string值应当为Exif
1.4 大小端[i + 10, i + 12]string值为MMII(小端)
1.5 TIFF标记[i + 12, i + 14]uint16固定为42(0x2A 0x00)
1.6 IFD偏移[i + 14, i + 18]uint32i + 2处算起,后续IFD偏移从该处算起。
1.7 Tag数量[i + 18, i + 20]uint16每个tag占用12字节

几个关键数据:

ifdStart: 12
littleEndian: false
tagCount: 8

2. 读取Tag

start + 20开始,是tag的存储位置,每个tag占12个字节,结构如下

位置类型说明
0 ~ 2hex stringtag名称
2 ~ 4hex string数据类型
4 ~ 8uint32数据个数
8 ~ 12number数据本身或数据索引

2.1 循环读取Tag

我们解析一组,先看下原始数据:

// bin转hex
const parseBinHex = (bin: Uint8Array, fmt: boolean = false, spliter = '') => {
    return Array.from(bin).map(c => {
        let s = c.toString(16).toUpperCase()
        if(s.length < 2) {
            s = '0' + s
        }
        if(fmt) {
            s = '0x' + s
        }
        return s
    }).join(spliter)
}

// 解析单条tag
const parseTagRaw = (bin: Uint8Array, littleEndian: boolean) => {
    const name = parseBinHex(bin.slice(0, 2))
    const type = parseBinHex(bin.slice(2, 4))
    const count = parseBinNumber(bin.slice(4, 8), littleEndian)
    const data = bin.slice(8)
    return { name, type, count, data }
}

// tag起始位置
let tagStart = start + 20
const tags: { name: string; type: string; count: number; data: Uint8Array }[] = []
for(let i = 0; i < tagCount; i++) {
    tags.push(parseTagRaw(bin.slice(tagStart, tagStart + 12), littleEndian))
    tagStart += 12
}

得到以下tag数据

[
  {
    "name": "011B",
    "type": "0005",
    "count": 1,
    "data": Uint8Array
  },
  {
    "name": "011A",
    "type": "0005",
    "count": 1,
    "data": Uint8Array
  },
  {
    "name": "0100",
    "type": "0004",
    "count": 1,
    "data": Uint8Array
  },
  // ...
]

2.2 Tag的名称

查表:exiv2.org/tags.html 可指上面name标签对应的名称。比如第一条011B对应Exif.Image.YResolution

image.png

2.3 Tag的类型

根据AI问答,整理了一份数据类型对照表如下:

export const TYPE_SIZE = {
    /** 用于表示字符串数据,通常以 null 终止(NUL 0x00)表示字符串的结束。例:相机型号(Model)。 */
    Ascii: 'Variable',
    /** 表示一个字节的单个整数(通常是 8 位)。例:图像的颜色空间(ColorSpace)。 */
    Byte: 1,
    /** 1 字节有符号整数 */
    SByte: 1,
    /**  16 位的无符号整数。例:图像方向(Orientation)。 */
    Short: 2,
    // ...
}

export type TExifDataType = keyof typeof TYPE_SIZE

export const TYPE_MAP: Record<string, TExifDataType> = {
    '0x0001': 'Byte',
    '0x0002': 'Ascii',
    '0x0003': 'Short',
    '0x0004': 'Long',
    '0x0005': 'Rational',
    // ...
}

完整数据:github.com/imnull/imgk…

类型字段与名称对应类型的权衡

exiv2.org/tags.html 中,可以看到每个标准的tag名称,都对应一个明确的类型;同时tag的2~4字节,又声明了一个数据类型。应该以哪个为准?AI的意见是以标准为准,而不是tag中声明的类型。我采纳了这个建议。

我理解tag内部声明的类型属于数据自洽,或脱离tag查表,或出现标准之外的自定义tag时,能确保能结构正常读取。

2.4 Tag的数据长度

count标签表示当前数据有几个单位。比如类型为Rationalcount为3,查表得知Rational占用8个字节,则数据块为8 * 3 = 24字节。

2.5 Tag数据

根据2.3得到数据字节数byteCount后:

  • 如果byteCount > 4,则tag的8~12字节为数据偏移量offset,真实数据从ifdStart + offset开始读取byteCount个字节
  • 如果byteCount <= 4,则tag的8~8+byteCount字节为数据本身,直接按类型解析

2.6 特殊Tag

以下tag作为IFD的指针读取:

  • 0x8769: Exif.Image.ExifTag A pointer to the Exif IFD
  • 0x8825: Exif.Image.GPSTag A pointer to the GPS Info IFD

每个指针对应的区块,根据ifdStart做偏移;每个ifd区块前两个字符表示tag数量,随后按照2.1的方式读取即可。

根据Exif矫正图像

在文章开头提到的问题中,与Exif相关的信息有三条:

  • 水平像素数: PixelXDimension / ImageWidth
  • 垂直像素数: PixelYDimension / ImageLength
  • 旋转调整: Orientation

3.1 图片像素信息

这里面 PixelXDimension / PixelYDimensionImageWidth / ImageLength 会成对出现,但不一定都存在,所以需要动态读取,适当降级。另外ImageHeight是个非标tag,解析exif期间不要使用。

3.2 旋转调整

Orientation取值为undefined | 0 ~ 8,需要处理的为以下值:

  • 2: 水平翻转
  • 3: 旋转180度
  • 4: 垂直翻转
  • 5: 逆时针旋转90度并水平翻转
  • 6: 顺时针旋转90度
  • 7: 逆时针旋转90度并垂直翻转
  • 8: 顺时针旋转270度

3.3 开始矫正旋转

假设一个File对象,读取Exif后,得到以下必要数据:

PixelXDimension: 4032
PixelYDimension: 3024
Orientation: 6

从Exif看,横轴大于纵轴的像素数,实际查看这张图片时,图片是纵向,正是因为图片显示时,根据Orientation做了旋转调整:

6: 顺时针旋转90度

删除Exif信息

理论上讲,如果删掉Exif信息,图片在展示时,就不会再有Orientation干预,所以应该是横向的:

const binHead = file.slice(0, start)
const binTail = file.slice(start + 2 + size)
const noExifFile = new File([binHead, binTail], file.name, { type: file.type })

image.png

上图的左图是去掉了Exif信息的数据,与我们预想的一样,在没有Orientation干预的情况下,图片按实际像素展示。

借助canvas实现Orientation

由于Orientation=6旋转90,相当于长宽交换,我们定义一个对应的canvas:

const image = await createImageByBlob(noExifFile)
const canvas = document.createElement('canvas')
canvas.width = 3024
canvas.height = 4032
const context = canvas.getContext('2d')!
// 移动到中心点,方便操作旋转和缩放
context.translate(canvas.width / 2, canvas.height / 2)
// 顺时针旋转90度
context.rotate(Math.PI / 2)
// 此时context已经translate到画布中央
context.drawImage(image
    , 0, 0, image.width, image.height
    // 按画布中央作为原点绘制
    , image.width / -2, image.height / -2, image.width, image.height
)
const dataUrl = canvas.toDataURL(file.type)

即可得到右图。

Orientation的其他操作

先操作画布原点移动到画布中心,只是为了方便,不是必须的,否则可能会导致每一种操作会有不同的绘制算法。

  • 水平翻转操作context.scale(-1, 1)
  • 垂直翻转操作context.scale(1, -1)
  • 如果先旋转一个90度的倍数,再进行轴向翻转的时候,应当是翻转另一个轴。比如Orientation=5: 逆时针旋转90度并水平翻转,在context.rotate(Math.PI / -2)之后,应当进行垂直翻转操作context.scale(1, -1)

3.4 总结

  • 读取Exif信息后
  • 将去掉Exif信息的图片,写入canvas,通过canvas操作实现方向调整
  • 输出没有Exif信息、且自然方向正确的图片

去掉Exif对保护用户上传图片隐私或有帮助

相关资料