「ArrayBuffer」应用-以自动调整照片方向为例

3,350 阅读6分钟

作者: Cheiron

背景

从网页调起手机拍照时,很多相机程序会自动根据你拍照的方向旋转以调整照片显示,但是上传的照片却是原始的方向。于是常常造成拍好的照片在网页上面上下左右颠倒。

对此的解决办法就是,读取照片 EXIF 信息中的 Orientation 字段,以主动旋转照片。本文将详细解读如何使用javascript读取EXIF的信息。

ArrayBuffer, TypedArray 和 DataView

ArrayBuffer, TypedArray 和 DataView 共同为 javascript 操作二进制数据提供了便利的途径。

ArrayBuffer 是一块内存,或者说代表了一段存储着二进制数据的内容。他不能直接被读写,只能通过 TypedArray 或者 DataView 来读写。ArrayBuffer 是一个构造函数,接受一个整数作为参数,即表示分配多少字节的内存。如 const ab = new ArrayBuffer(32) 就分配了一段 16字节的连续内存区域,每个字节的默认值是0. 同时,一些 javascript API 的返回结果也是 ArrayBuffer, 比如本文将谈到的 FileReader API, 它的 readAsArrayBuffer 方法就会返回一个 ArrayBuffer 对象。

TypedArray 是一类构造函数的总称,包括 Int8Array, Uint8Array, Uint8ClampedArray, Int16Array, Uint16Array, Int32Array, Uint32Array, Float32Array, Float64Array 共 9 种。用这九个构造函数生成的 typed array,和数组具有类似的行为。如都有 length 属性,都可以通过 [] 访问元素,也可以使用数组大部分的方法。

比如上文创建的 ab 对象。可以用 const i8view = new Int8Array(ab) 创建一个8位有符号整数的视图。因为 ab 有 32 个字节,int8 占一个字节,所以 i8view 的每一项相当于 ab 的一个字节,因此 i8view.length = 32,每一项都是 0.

我们也可以用 const ui32view = new Uint32Array(ab) 创建一个32位无符号整数的视图。因为 ab 有 32 个字节,uint32 占四个字节,所以 ui32view 的每一项相当于 ab 的四个字节,因此 ui32view.length = 8, 因为 ab 的每个字节都是0, 4个字节一起作为 Uint32 计算还是0, 所以,ui32view 的每一项仍然都是 0.

可以看到,在这个过程中,ab 本身没有变化,创建不同视图的过程,只是把 ab 的数据作为 int8, Uint32 或其他格式的数据来处理而已。

Typed array 和 array 的区别在于 typed array 的所有成员都是同一类型(也就是 “typed” 的含义),且完全连续没有空位。如果传入数组长度来初始化,那么所以元素默认值都是 0. TypedArray 只是一种视图,本身不存储数据,数据存在 ArrayBuffer 中。TypedArray 适用于处理简单类型的二进制数据,复杂的就需要 DataView.

DataView 可以定义一个复合视图。比如 Uint8Array 定义的视图,所以元素都是 无符号8位整数,而 DataView 定义的视图,可以第一个字节是 Uint8, 第二个字节是 Int16 等,且可以自定义字节序。具体用法可以参考MDN,以及下面的例子。

JEPG 及 EXIF 的格式

JPEG 文件大体分为两个部分:标记码和压缩数据。

标记码由两个字节组成,前一个是固定值 0xFF,后一个是不同意义对应的数值。如 0xFFD8 表示 SOI (Start of Image),0xFFD9 表示 EOI,即 End of Image. 我们关注的 EXIF 信息与 0xFFE0 0xFFEF 范围的标记有关。这些区域叫做 应用程序保留区N(ApplicationN),如 0xFFE0 是 App0. 我们需要的 EXIF 由 App1 标记,即是位于 0xFFE1 到 下一个 0xFFE1 到 下一个 0xFF 标记之间的数据。

EXIF 的格式

可以看到紧邻 FFE1 标识的后两位,是 APP1 的数据大小,位于 TIFF header 之后的是 IFD0 即 Image File Directory. 它包含了图片信息数据。下面的表格描述了 IFD 的数据格式。

IFD 的格式

TTTT 的 2bytes 数据表示 Tag,ffff 这 2bytes 表示数据的类型。NNNNNNNN 这 4bytes 是组成元素的数量。DDDDDDDD 这 4bytes 是数据本身或数据的偏移量。

在本例中,图像方向 Orientation 的 Tag Number 是 0x0112;数据类型是 unsigned short, 对应的 ffff 是 0x0003, 组成元素只有一个,所以 NNNNNNNN 是 00000001. DDDDDDDD比较麻烦,有两种情况。如果 数据类型 * 组成元素数量 < 4bytes, 那么,DDDDDDDD 就是改标签的值,反之则是数据存储地址的偏移量。Unsigned short 类型的一个组成元素占 2bytes, 只有一个,所以 2bytes * 1 < 4bytes, 因此对于 Orientation 标签来说,DDDDDDDD 就是该标签的值。(有关细节请参考参考文档中的 1

Orientation 的取值和含义。

一般手机转一圈拍出来的是 1 6 3 8 四个值。

图片处理

先使用 FileReader API 把 input 标签输入的图片读取成 ArrayBuffer

const reader = new FileReader()
reader.onload = async function () {
	const buffer  = reader.result
	const orientation = getOrientation(buffer)
	const image = await rotateImage(buffer, orientation)
}
reader.readAsArrayBuffer(file)

再看 getOrientation 函数的实现。

function getOrientation(buffer) {
	// 建立一个 DataView
	const dv = new DataView(buffer)
	// 设置一个位置指针
	let idx = 0
	// 设置一个默认结果
	let value = 1
	// 检测是否是 JPEG
	if (buffer.length < 2 || dv.getUint16(idx) !== 0xFFD8 {
		return false
	}
	idx += 2
	let maxBytes = dv.byteLength
	// 遍历文件内容,找到 APP1, 即 EXIF 所在的标识
	while (idx < maxBytes - 2) {
		const uint16 = dv.getUint16(idx)
		idx += 2
		switch (uint16) {
			case 0xFFE1:
				// 找到 EXIF 后,在 EXIF 数据内遍历,寻找 Orientation 标识
				const exifLength = dv.getUint16(idx)
				maxBytes = exifLength - 2
				idx += 2
				break
			case 0x0112:
				// 找到 Orientation 标识后,读取 DDDDDDDD 部分的内容,并把 maxBytes 设为 0, 结束循环。
				value = dv.getUint16(idx + 6, false)
				maxBytes = 0
				break
		}
	}
	return value
}

在来看 rotateImage 的实现:

function rotateImage (buffer, orientation) {
	// 利用 canvas 来旋转
	const canvas = document.createElement('canvas')
	const ctx = canvas.getContext('2d')
	// 利用 image 对象来把图片画到 canvas 上
	const image = new Image()
	// 根据 arrayBuffer 生成图片的 base64 url
	const url = arrayBufferToBase64Url(buffer)
	return new Promise((resolve, reject) => {
		image.onload = function () {
			const w = image.naturalWidth
			const h = image.naturalHeight
			switch (orientation) {
				case 8:
					canvas.width = h
					canvas.height = w
					ctx.translate(h / 2, w / 2)
					ctx.rotate(270 * Math.PI / 180)
					ctx.drawImage(image, -w / 2, -h / 2)
					break
				case 3:
					canvas.width = w
					canvas.height = h
					ctx.translate(w / 2, h / 2)
					ctx.rotate(180 * Math.PI / 180)
					ctx.drawImage(image, -w / 2, -h / 2)
					break
				case 6:
					canvas.width = h
					canvas.height = w
					ctx.translate(h / 2, w / 2)
					ctx.rotate(90 * Math.PI / 180)
					ctx.drawImage(image, -w / 2, -h / 2)
					break
				default:
					canvas.width = w
					canvas.height = h
					ctx.drawImage(image, 0, 0)
					break
			}
			// 也可以使用其他 API 导出 canvas
			const data = canvas.toDataURL('image/jpeg', 1)
			resolve(data)
			
		}
		image.src = url	
	})
}

arrayBufferToBase64Url 的实现:

function arrayBufferToBase64 (buffer) {
    let binary = ''
	// 这里用到了 TypedArray
    const bytes = new Uint8Array(buffer)
    const len = bytes.byteLength
    for (let i = 0; i < len; i++) {
		// fromCharCode 方法从指定的 Unicode 值序列创建字符串
	    binary += String.fromCharCode(bytes[ i ])
    }
    // 使用 btoa 方法从 String 对象创建 base-64 编码的 ASCII 字符串
    return window.btoa(binary)
}

参考:

  1. Description of Exif file format
  2. ArrayBuffer

原文链接: tech.meicai.cn/detail/59, 也可微信搜索小程序「美菜产品技术团队」,干货满满且每周更新,想学习技术的你不要错过哦。