JS通过读取二进制数据解析PSD文件

665 阅读5分钟

为什么要解析PSD文件

PS是目前世界上功能最全的图像处理工具,也正是因为他的强大所以才如此受设计师青睐。如果要制作非常炫酷的海报图PS绝对是不二之选,而Adobe也是深知这一点,所以使用PS制作出来的图像数据往往会留在PSD格式的文件里,并且在未来也会持续以这种文件形式保留。市面上大部分的编辑器都支持PSD文件的直接录入,那他们是怎么做到的呢? Adobe很贴心的为我们提供了psd文件格式描述

The Photoshop file format is divided into five major parts, as shown in the Photoshop file structure. The Photoshop file format has many length markers. Use these length markers to move from one section to the next. The length markers are usually padded with bytes to round to the nearest 2 or 4 byte interval.

从文档的描述中我们可以知道PSD文件主要分为五个部分,如下图所示。而每个部分都是用字节的长度来描述的,所以在解读PSD文件时必须先转换成二进制数据,然后依据着这份psd文件格式描述来一步一步地去读取字节里存储的信息。

image.png

Js如何读取二进制文件

在正式解析PSD之前我们先来了解一下js是怎么读取二进制数据的:

首先我们通过HTML input访问到用户所选择或者拖拽的文件,得到files属性。File 对象继承了Blob的所有属性,可以看作是是特殊类型的Blob,Blob代表的是二进制类文件大对象,用于操作二进制文件。但是Blob只能作为一整个对象去处理,并不能按照字节去操作,所以需要通过Blob的fileReader转成更加底层的ArrayBuffer,再通过创建视图的方式可以很方便地对内存进行读写。

image.png

Arraybuffer作为缓冲区存放实际的二进制数据,TypedArray和DataView作为视图访问ArrayBuffer

Arraybuffer

Arraybuffer是对固定长度连续内存空间的引用,本身不能直接操作内存,我们可以这样创建他

const buffer = new ArrayBuffer(16) 
console.log(buffer.byteLength); //16

TypedArray、DataView

Arraybuffer只是一段内存空间,我们要对Arraybuffer进行读写只能通过视图来操作。

TypedArray

TypedArray一共包含九种类型,每一种都是一个构造函数

  • Int8Array:8 位有符号整数,长度 1 个字节。
  • Uint8Array: 8位无符号整数, 1 个字节长度。
  • Int16Array:16位有符号整数, 2 个字节长度。
  • Uint16Array:16位无符号整数,2 个字节长度。
  • Int32Array:32位有符号整数, 4 个字节长度。
  • Uint32Array:32位无符号整数, 4 个字节长度。
  • Float32Array:32位浮点数, 4 个字节长度。
  • Float64Array:64位浮点数,8 个字节长度。

视图的构造函数可以接受三个参数:

  • 第一个参数(必选):视图对应的底层ArrayBuffer对象;
  • 第二个参数:视图开始的字节序号,默认从 0 开始;
  • 第三个参数:视图包含的数据个数,默认直到本段内存区域结束;
// 创建一个8字节的ArrayBuffer
const buffer = new ArrayBuffer(8);

// 创建一个指向a的Int32视图,开始于字节0,直到缓冲区的末尾
const buffer1 = new Int32Array(buffer);

// 创建一个指向a的Int8视图,开始于字节4,直到缓冲区的末尾
const buffer2 = new Int8Array(buffer, 4);

// 创建一个指向a的Int16视图,开始于字节4,长度为2
const buffer3 = new Int16Array(buffer, 4, 2);
const buffer = new ArrayBuffer(8);
 
const int16View = new Int16Array(buffer);
 
for (let i = 0; i < int16View.length; i++) {
  int16View[i] = i
}
console.log(int16View) // [0, 1, 2, 3]

可以看到TypedArray跟数组几乎一样,所以他有个特别的名字叫类型化数组

DataView

DataView 视图是一个可以从二进制 ArrayBuffer对象中读写多种数值类型的底层接口,而且使用它时不用考虑不同平台的字节序问题。

const view = new DataView(buffer,byteOffset,byteLength)
  • buffer:ArrayBuffer 对象;
  • byteOffset(可选) :此 DataView 对象的第一个字节在 buffer 中的字节偏移。如果未指定,则默认从第一个字节开始;
  • byteLength:此 DataView 对象的字节长度。如果未指定,这个视图的长度将匹配 buffer 的长度

DataView 提供了 8 种读取方式

  • getInt8(index, order):从第 index 个字节读取一个 8 位整数。
  • getUint8(index, order):从第 index 个字节开始读取一个无符号的 8 位整数。
  • getInt16(index, order):从第 index 个字节开始读取 2 个字节,返回一个 16 位整数。
  • getUint16(index, order):从第 index 个字节开始读取 2 个字节,返回一个无符号的 16 位整数。
  • getInt32(index, order):从第 index 个字节开始读取 4 个字节,返回一个32位的整数。
  • getUint32(index, order):从第 index 个字节开始读取 4 个字节,返回一个无符号的 32 位整数。
  • getFloat32(index, order):从第 index 个字节开始读取 4 个字节,返回一个 32 位 浮点数。
  • getFloat64(index, order):从第 index 个字节开始读取 8 个字节,返回一个 64 位的浮点数。

DataView支持设置字节序,在上面 8 中读取方式中,第一个字节是索引,第二个字节允许我们设置字节序,true 代表小端字节序读取,false 代表大端字节序读取,默认为 false。

const buffer = new ArrayBuffer(16);
const view = new DataView(buffer, 0);

view.setInt8(1, 68);
view.getInt8(1); // 68

PSD解析

让我们回到PSD格式文件中来,我们了解了二进制数据的基本操作,也知道PSD的这五个部分都是用字节的长度来描述的。那么他其实就是将整个内存区域划分成了五个部分。

所以我们第一步先将用户上传的PSD文件转换成Arraybuffer

image.png

可以看到读取到了文件的二进制,byteLength则代表总的字节长度。因为Arraybuffer是一段连续的内存引用,所以我们也必须严格按照顺序一个一个属性地去读,我们需要一个全局的offset来标记当前读到第几个字节,即使某些属性你不需要解析也要记得给当前的offset加上你跳过了多少个字节。

export interface PsdReader {
  offset: number;
  view: DataView;
}

export function readUint8(reader: PsdReader) {
  reader.offset += 1;
  return reader.view.getUint8(reader.offset - 1);
}

export function readUint16(reader: PsdReader) {
	reader.offset += 2;
	return reader.view.getUint16(reader.offset - 2, false);
}

export function readUint32(reader: PsdReader) {
	reader.offset += 4;
	return reader.view.getUint32(reader.offset - 4, false);
}
export function readPsd(reader: PsdReader, options: ReadOptions = {}) {
  const fileHeader = readFileHeader(reader,options)
  const imageReources = readImageResources(reader,options)
  const colorMode = readColorModeData(reader,options)
  const layerAndMask = readLayerAndMask(reader,options)
  const imageData = readImageData(reader,options)
  ...
}

头文件

首先是头文件部分,可以看到头文件存储的是PSD文件的一些基本信息,版本,宽高,使用的颜色模式等。文件中的Length就代表当前信息所占的字节数,而且不同的信息所占的字节数并不相同,所以这里选用DataView来读取会更加方便。

image.png

export function readFileHeader(reader: PsdReader, options: ReadOptions = {}) {
  checkSignature(reader, '8BPS');
  const version = readUint16(reader);
  if (version !== 1) throw new Error(`仅支持PSD文件类型`);
  
  skipBytes(reader, 6);
  const channels = readUint16(reader);
  const height = readUint32(reader);
  const width = readUint32(reader);
  const bitsPerChannel = readUint16(reader);
  const colorMode = readUint16(reader);
  ...
}

图层信息(Layer and Mask Infomation)

我们直接来看录入比较关心的图层信息

image.png

Length为Variable代表所占用的字节数不确定,因为图层的数量是不确定的。所以他在表格第一行用4个字节来告诉了我们这个部分总的字节数。在读完一次完整layer的属性之后对比当前的offset是否等于end,不等于则继续执行,直到offset等于end位为止。

export function readSection<T>(
	reader: PsdReader,  func: (leftOffsetFn: () => number) => T
): T | undefined {
  
	let length = readUint32(reader);
  
  if (length <= 0) return undefined;
  
	let end = reader.offset + length;

	const result = func(() => end - reader.offset);
  
	reader.offset = end;

	return result;
}

export function layerAndMask{
  return readSection<LayerMaskData>(reader,getLayerAndMask())
}

字符串以及图片的读取

字符串

刚刚读的都是一些简单的信息,比如图层宽高,颜色模式都是用数字来标示的。那PSD是如何存储字符串以及图片的,例如我们的图层名字可能是中文的。这里他们用的是Unicode编码来存储字符串的,先用4个字节读出该字符串所占用的字节数,通过一个标示的key来判断当前UniCode中一个UniCode编码占多少个字节(但似乎都是4),通过TypedArray生成一个类型化数组,遍历数组对每一项执行String.fromcharCode转成字符串最后进行拼接即可。

image.png

function readString(reader: PsdReader, length: number) {
	const buffer = readBytes(reader, length);
	let result = '';

	for (let i = 0; i < buffer.length; i++) {
		result += String.fromCharCode(buffer[i]);
	}

	return result;
}

图片

image.png

PSD保存了图片每个像素点的颜色通道矩阵信息,在读取该图层的图片时我们已经知道图层的宽高,所以我们就可以创建一个跟图层同样大小的canvas画布,然后将读取到的颜色矩阵通过putImageData方法直接填入canvas中,若有需要还可以直接转成base64形式。

如果该图层有蒙版的话同样记录了蒙版的颜色通道矩阵信息,我们可以看到在ps里蒙版是黑白图片,黑色表示需要裁剪的区域,所以我们可以将两个canvas叠在一起,然后判断当前蒙版上的像素点是否是黑色的,是黑色则置成透明即可,生成一张新的被蒙版裁剪完之后的位图。

image.png

扩展

读取奇数位字节数

从上面的内容可以看到使用我们使用js已经能够比较方便地进行二进制数据的读写了。但也仅包含了 8、16、32、64 位(1、2、4、8 字节)的读写,其它位的操作还得经过运算。在多媒体协议解析中我们还需要对 3、5、7 字节(24、30、56 位进行读取。

这里有一个公式,前四位数字 * ((2 ** 8) ** 偏差值)) + 后偏差位数字

const combined = left*((2**8)**byteLength) + right

以offset为0为例子

const buffer = new ArrayBuffer(8);
const dataview = new DataView(buffer);

(dataview.getUint32(0) * ((2 ** 8) ** 1)) + dataview.getUint8(4)   // 5 字节
(dataview.getUint32(0) * ((2 ** 8) ** 2)) + dataview.getUint16(4)  // 6 字节
(dataview.getUint32(0) * ((2 ** 8) ** 3)) + dataview.getUint24(4)  // 7 字节