背景
近期一个项目中,需要对上传图片做预览、压缩等操作,实现思路:
- 二进制(
Blob)对象转base64的DataURL,然后使用img或者background-image实现预览; - 基于
DataURL生成的img标签,借助canvas的drawImage方法,可实现裁切; - 借助
canvas的toDataURL或者toBlob方法,在转为jpeg时更改质量参数,实现体积压缩。
但真机操作出现问题:图片经常会在预览时意外翻转,尤其是手机直接拍摄的照片使用,出现概率几乎百分百。
这个问题,在七、八年前那波h5移动端营销刚开始的时候遇到过,定位问题与图片的EXIF信息相关。因为当时借助canvas对图片进行了重度处理,推测是部分EXIF信息丢失导致的。虽然当时已经有了EXIF读取相关的js库,但是限于当时的个人技术能力,及项目进度问题,最后就搁置了。
时隔多年,又去npm搜了一下exif,得到如下结果:
前三的exif库比我家猫的岁数都大……不能忍,动手写个自己的工具。
操作二进制对象的抓手
像File/Blob这种二进制对象,都是无法直接操作的。在进行解析之前,我们需要创建一个可以直接读取的对象:Uint8Array:
const convertBlobToBin = async (blob: Blob) => {
const buff = await blob.arrayBuffer()
const bin = new Uint8Array(buff)
return bin
}
关于Uint8Array和Byte
Uint8Array这个内置对象是TypedArray的一个具体实现,这里我们把Uint8Array拆解成u int 8 array来逐一解释一下,做一下知识拉平:
u表示无符号。众所周知,有符号数字的高位第一个bit是符号位,0正1负。无符号声明表示第一位仍然作为计数位,也就是这个数字是不小于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标准文档。
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 EndianII(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 | 值为MM或II(小端) |
| 1.5 TIFF标记 | [i + 12, i + 14] | uint16 | 固定为42(0x2A 0x00) |
| 1.6 IFD偏移 | [i + 14, i + 18] | uint32 | 从i + 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 ~ 2 | hex string | tag名称 |
| 2 ~ 4 | hex string | 数据类型 |
| 4 ~ 8 | uint32 | 数据个数 |
| 8 ~ 12 | number | 数据本身或数据索引 |
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
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',
// ...
}
类型字段与名称对应类型的权衡
在 exiv2.org/tags.html 中,可以看到每个标准的tag名称,都对应一个明确的类型;同时tag的2~4字节,又声明了一个数据类型。应该以哪个为准?AI的意见是以标准为准,而不是tag中声明的类型。我采纳了这个建议。
我理解tag内部声明的类型属于数据自洽,或脱离tag查表,或出现标准之外的自定义tag时,能确保能结构正常读取。
2.4 Tag的数据长度
count标签表示当前数据有几个单位。比如类型为Rational,count为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.ExifTagA pointer to the Exif IFD0x8825:Exif.Image.GPSTagA pointer to the GPS Info IFD
每个指针对应的区块,根据ifdStart做偏移;每个ifd区块前两个字符表示tag数量,随后按照2.1的方式读取即可。
根据Exif矫正图像
在文章开头提到的问题中,与Exif相关的信息有三条:
- 水平像素数:
PixelXDimension/ImageWidth - 垂直像素数:
PixelYDimension/ImageLength - 旋转调整:
Orientation
3.1 图片像素信息
这里面 PixelXDimension / PixelYDimension 和 ImageWidth / 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 })
上图的左图是去掉了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对保护用户上传图片隐私或有帮助