使用JS API 解析复合二进制文档
系列文章分为上中下三篇,教你怎么使用纯js解析复合二进制文档(doc,ppt,msi)
关于复合二进制文档
前文已经大致讲解了符合二进制文档的背景已经二进制结构;根据其二进制结构,我们是能解析并提取一些我们感兴趣的数据的;
解析
准备工作1
假定我们在浏览器使用input标签上传了一个复合二进制文档dox,msi类型均可,此时拿到的是Blob数据格式,为了更好的读取二进制信息,需要一个工具函数将Blob转换成ArrayBuffer;
js
function readBlob(blob: Blob): Promise<ArrayBuffer> {
const fileReader = new FileReader();
fileReader.readAsArrayBuffer(blob);
return new Promise<ArrayBuffer>(resolve => {
fileReader.onload = (event) => {
if (!event.target || event.target.readyState !== FileReader.prototype.DONE) {
throw new Error("Read data failed");
}
const array = event.target.result as ArrayBuffer;
resolve(array);
};
});
}
函数逻辑就不细说了,前端程序员基本操作。
准备工作2
文档头和扇区都是小块二进制数据,这样避免了一次性读取整个文档;因此还需要一个读个扇区或者二进制块的工具函数
js
// 读取某个扇区的二进制数据
async function readSector(sectorNumber: number): Promise<ArrayBuffer> {
const offset = (sectorNumber + 1) * sectorSize;
const sector = await this.readPartOfFile(offset, sectorSize);
return sector;
}
// 读取某个二进制代码块
async function readPartOfFile(offset: number, count: number): Promise<ArrayBuffer> {
const blob = file.slice(offset, offset + count);
const result = await readBlob(blob);
return result;
}
读取文档头
文档头是固定512个字节,详细结构如下:
偏移量(Offset) | 大小(Size) | 内容(Contents) |
---|---|---|
0 | 8 | 复合文档文件标识:D0H CFH 11H E0H A1H B1H 1AH E1H |
8 | 16 | 此文件的唯一标识(不重要, 可全部为0) |
24 | 2 | 文件格式修订号 (一般为003EH) |
26 | 2 | 文件格式版本号(一般为0003H) |
28 | 2 | 字节顺序规则标识(大端与小端):FEH FFH = Little-Endian,FFH FEH = Big-Endian |
30 | 2 | 复合文档中sector的大小(ssz),以2的幂形式存储 |
32 | 2 | short-sector的大小,以2的幂形式存储 |
34 | 10 | Not used |
44 | 4 | 用于存放扇区配置表(SAT)的sector总数 |
48 | 4 | 用于存放目录流的第一个sector的SID |
52 | 4 | Not used |
56 | 4 | 标准流的最小大小(一般为4096 bytes),小于此值的流即为短流。 |
60 | 4 | 用于存放短扇区配置表(SSAT)的第一个sector的SID,或为--2 (End Of Chain SID)如不存在。 |
64 | 4 | 用于存放短扇区配置表(SSAT)的sector总数 |
68 | 4 | 用于存放主扇区配置表(MSAT)的第一个sector的SID,或为--2 (End Of Chain SID) 若无附加的sectors。 |
72 | 4 | 用于存放主扇区配置表(MSAT)的sector总数 |
76 | 436 | 存放主扇区配置表(MSAT)的第一部分,包含109个SID。 |
在读取文档头内容时,核心关注:扇区大小,短扇区大小,扇区配置表总数,存档目录的第一个扇区标识,标准流大小,用于存放短扇区配置表的总数,用于存放短扇区配置表的第一个扇区扇区标识,用于存放主扇区配置表的第一个扇区标识,以及主扇区配置表的第前109个扇区标识;
特别注意的是:扇区标识是一个32为的有符号整形,占据4个字节。
js
const NUMBER_OF_DIFAT_IN_HEADER = 109;
const SIGNATURE = [208, 207, 17, 224, 161, 177, 26, 225];
const SECTOR_SIZE_OFFSET = 30;
const MINI_SECTOR_SIZE_OFFSET = 32;
const NUMBER_OF_DIRECTORY_SECTORS_OFFSET = 40;
const NUMBER_OF_FAT_SECTORS_OFFSET = 44;
const FIRST_DIRECTORY_SECTOR_OFFSET = 48;
const MINI_STREAM_CUTOFF_SIZE_OFFSET = 56;
const FIRST_MINI_FATSECTOR_OFFSET = 60;
const NUMBER_OF_MINI_FATSECTORS_OFFSET = 64;
const FIRST_DIFAT_SECTOR_OFFSET = 68;
const NUMBER_OF_DIFAT_SECTORS_OFFSET = 72;
const DIFAT_TABLE_OFFSET = 76;
const HEADER_SIZE = DIFAT_TABLE_OFFSET + NUMBER_OF_DIFAT_IN_HEADER * 4;
async function readHeaders() {
const header = await readPartOfFile(0, HEADER_SIZE);
const signature = new Uint8Array(header.slice(0, 8));
SIGNATURE.forEach(
);
const data = new DataView(header, 0, header.byteLength);
const sectorSize= Math.pow(2, data.getUint16(SECTOR_SIZE_OFFSET, true)); // 扇区大小
const miniSectorSize= Math.pow(2, data.getUint16(MINI_SECTOR_SIZE_OFFSET, true)); // 短扇区大小
const numberOfDirectorySectors= data.getInt32(NUMBER_OF_DIRECTORY_SECTORS_OFFSET, true); // 目录扇区数量
const numberOfFatSectors= data.getInt32(NUMBER_OF_FAT_SECTORS_OFFSET, true); // 扇区配置扇区数量
const firstDirectorySector= data.getInt32(FIRST_DIRECTORY_SECTOR_OFFSET, true); // 第一个目录扇区标识
const miniStreamCutoffSize= data.getInt32(MINI_STREAM_CUTOFF_SIZE_OFFSET, true); // 短流的临界值
const firstMiniFatSector= data.getInt32(FIRST_MINI_FATSECTOR_OFFSET, true); // 第一个短扇区的标识
const numberOfMiniFatSectors= data.getInt32(NUMBER_OF_MINI_FATSECTORS_OFFSET, true); // 短扇区配置表的扇区数量
const firstDifatSector= data.getInt32(FIRST_DIFAT_SECTOR_OFFSET, true); // 第一个主扇区配置表的扇区标识
const numberOfDifatSectors= data.getInt32(NUMBER_OF_DIFAT_SECTORS_OFFSET, true); // 主扇区配置表的扇区数量
const countOfMiniSectorsInSector = sectorSize / miniSectorSize; // 一个标准扇区里有多少个短扇区
const difatTable = []; // 主扇区配置表
// 主扇区前109个扇区配置表表示
for (let i = 0; i < NUMBER_OF_DIFAT_IN_HEADER; i++) {
const offset = DIFAT_TABLE_OFFSET + i * 4;
const fatSectorNumber = data.getInt32(offset, true);
difatTable.push(fatSectorNumber);
}
}
读取非文档头里的主扇区配置表
文档头中已经指明了用于存放MSAT的第一个sector的SID --> firstDifatSector; 主扇区配置表扇区的结构如下:(假定扇区大小为 sectorSize 字节,则可以存放(n-4/4)个SID),那么:
offset | size | content |
---|---|---|
0 | sectorSize-4 | SID数组(数量为(sectorSize-4)/4) |
sectorSize-4 | 4 | 下一个扇区的SID |
实现代码:
js
async function readDifats() {
let difatSector =firstDifatSector; // 第一个存放主扇区配置表的扇区标识
for (let sectorsCount = 0; sectorsCount < numberOfDifatSectors; sectorsCount++) {
if (difatSector === END_OF_CHAIN) {
return; // 如果第一个扇区标识是 ND_OF_CHAIN ,则表示没主扇区配置存放在扇区里面(全部存放在文档头中)
}
const sector = await readSector(difatSector);
const data = new DataView(sector, 0, sector.byteLength);
const countOfEntriesInSector = (sectorSize - 4) / 4; // 最后四个字节记录扇区链下一个扇区的标识
for (let i = 0; i < countOfEntriesInSector; i++) {
const offset = i * 4;
const fatSectorNumber = data.getInt32(offset, true);
difatTable.push(fatSectorNumber);
}
difatSector = data.getInt32(sectorSize - 4, true);
}
if (difatSector !== END_OF_CHAIN) {
throw false // 最后一个扇区链的标识若不是结束标识,抛出异常
}
return difatTable;
}
构建扇区配置表(fatTable)
扇区配置表是一个扇区标识数组,数组下标i标识当前扇区的扇区标识,下标i对应的值标识扇区链的下一个扇区的扇区标识; 扇区配置表记录在不同扇区中,记录扇区配置表的扇区的扇区标识由主扇区配置表(difatTable)记录; 我们
js
async function getNextSectorInChain(sectorNumber: number): Promise<number> {
const countOfEntriesInSector = sectorSize / 4; // 每个扇区能存放扇区标识的数量
const fatPageNumber = Math.floor(sectorNumber / countOfEntriesInSector); // 标识位于第几个存放扇区
const offsetInFatPage = sectorNumber % countOfEntriesInSector; // 表示在存放扇区的偏移量
if (!fatTable[fatPageNumber]) { // 是否建立该扇区的配置表记录
const fatSector = await readFatSector(fatPageNumber);
fatTable[fatPageNumber] = fatSector;
}
return fatTable[fatPageNumber][offsetInFatPage]; // 扇区配置表数组中,index表示当前扇区,array[index]表示当前扇区的下一个扇区
}
// 获取某个保存了扇区配置表的扇区的内容
// 入参 fatPageNumber 表示第几个 保存扇区配置表的扇区
async function readFatSector(fatPageNumber: number): Promise<number[]> {
const fatSectorNumber = difatTable[fatPageNumber]; // 在主扇区配置表中拿到保存扇区配置表的扇区的扇区标识
const sector = await readSector(fatSectorNumber);
const data = new DataView(sector, 0, sector.byteLength);
const fatPage: number[] = [];
const countOfEntriesInSector = sectorSize / 4; // 计算改扇区保存了多少个扇区标识
for (let i = 0; i < countOfEntriesInSector; i++) {
const offset = i * 4;
const nextSectorNumber = data.getInt32(offset, true);
fatPage.push(nextSectorNumber);
}
return fatPage;
}
短扇区扇区配置表
逻辑跟读取扇区配置表几乎相同,需要注意的是,短扇区配置表获取下一个存放扇区时,使用通用的计算下一个扇区标识的方式(getNextSectorInChain),而扇区配置表是在主扇区配置中获取;
js
async function readMiniFats(): Promise<void> {
let miniFatSectorNumber = firstMiniFatSector;
for (let sectorsCount = 0; sectorsCount < numberOfMiniFatSectors; sectorsCount++) {
...
miniFatSectorNumber = await getNextSectorInChain(miniFatSectorNumber);
}
...
if (miniFatSectorNumber !== END_OF_CHAIN) {
return
}
}
目录配置
一个目录入口的大小严格地为128字节,二禁止结构为:
偏移量(字节) | 长度(字节) | 描述 |
---|---|---|
0 | 64 | 此入口的名字(字符数组),一般为16位的Unicode字符,以0结束。(最大长度为31个字符) |
64 | 2 | 用于存放名字的区域的大小,包括结尾的0。(如:一个名字有5个字符则此值为(5+1)∙2 = 12) |
66 | 1 | 入口类型:unknown = 0,storage = 1,stream = 2,rootStorage = 5, |
67 | 1 | 此入口的节点颜色:00H = Red,01H = Black |
68 | 4 | 其左节点的DID(若此入口为user storage或stream),若没有左节点就为-1。 |
72 | 4 | 其右节点的DID(若此入口为user storage或stream),若没有右节点就为-1。 |
76 | 4 | 其成员红黑树的根节点的DID(若此入口为storage),其他为-1。 |
80 | 16 | 唯一标识符(若为storage)(不重要,可能全为0) |
96 | 4 | 用户标记(不重要,可能全为0) |
100 | 8 | 创建此入口的时间标记。大多数情况都不写。 |
108 | 8 | 最后修改此入口的时间标记。大多数情况都不写。 |
116 | 4 | 若此为流的入口,指定流的第一个sector或short-sector的SID;若此为根仓库入口,指定短流存放流的第一个sector的SID;其他情况为0。 |
120 | 4 | 若此为流的入口,指定流的大小(字节);若此为根仓库入口,指定短流存放流的大小(字节);其他情况为0。 |
124 | 4 | ----- |
因此,读取目录配置的代码如下:
js
async function readDirectories(): Promise<void> {
let directorySectorNumber = firstDirectorySector; // 文档头中标识的第一个目录扇区的扇区标识
for (let sectorCount = 0; sectorCount < MAX_SECTORS_COUNT_IN_CHAIN; sectorCount++) {
if (directorySectorNumber === END_OF_CHAIN) {
break;
}
const sector = await readSector(directorySectorNumber);
const data = new DataView(sector, 0, sector.byteLength);
const countOfEntriesInSector = sectorSize / 128; // 一个扇区总目录入口数量
for (let i = 0; i < countOfEntriesInSector; i++) {
const offset = i * 128;
const nameLength = data.getUint16(offset + 64, true); // 目录名称保存在一个扇区的前0-64字节
const nameArray = sector.slice(offset, offset + nameLength);
const name = String.fromCharCode.apply(null, Array.from(new Uint16Array(nameArray)));
let streamSize = 0;
if (sectorSize <= 512) {
streamSize = data.getInt32(offset + 120, true);
} else {
streamSize = Number(data.getBigInt64(offset + 120, true));
}
const directory = {
nameArray: nameArray,
name: decodeMsiDirectoryName(name),
objectType: data.getInt8(offset + 66), // 目录类型: 流,短流,更目录
startSector: data.getInt32(offset + 116, true), // 流的其实扇区标识
streamSize: streamSize,
isMiniStream: streamSize < miniStreamCutoffSize, // 小于指定的大小,则是短流
};
if (directory.objectType === 5 || directory.objectType === 1 || directory.objectType === 2) {
directoryTable.push(directory);
}
}
directorySectorNumber = await getNextSectorInChain(directorySectorNumber);
}
if (directorySectorNumber !== END_OF_CHAIN) {
return
}
}
流的读取
流的读取,需要明确流的起始扇区标识和流大小,在目录结构中都有记录。 假设流的起始扇区标识为startSectorNumber,流大小为streamSize,那么流的读取逻辑如下:
js
async function readStream(startSectorNumber: number, streamSize: number): Promise<Uint8Array> {
const result = new Uint8Array(streamSize);
let sectorNumber = startSectorNumber;
for (let sectorCount = 0; sectorCount < MAX_SECTORS_COUNT_IN_CHAIN; sectorCount++) {
if (sectorNumber === END_OF_CHAIN) {
break;
}
const sector = await readSector(sectorNumber);
const offset = sectorCount * sectorSize; // 计算偏移量
const dataSize = Math.min(sectorSize, streamSize - offset);
result.set(new Uint8Array(sector.slice(0, dataSize)), offset); //按照偏移量,将每个扇区的二进制数据放入结果数组中
sectorNumber = await getNextSectorInChain(sectorNumber);
}
if (sectorNumber !== END_OF_CHAIN) {
return;
}
return result;
}
短流读取
短流的读取相对复杂。短流是小于标准大小的流,他们并不是使用常规数据扇区来存储,而是集中存放在一个特殊的短流存放容器中,这个容器本身是一个常规的扇区。
短流也拥有短流扇区配置表等类似于普通流的配置。
js
//用途:计算迷你扇区号对应的标准扇区索引
//示例:如果每个扇区有N个短扇区,短扇区号M对应标准扇区索引math.floor(M / N)
function getSectorCountByMiniSectorNumber(miniSectorNumber: number): number {
return Math.floor(miniSectorNumber / this.fileInfo.countOfMiniSectorsInSector);
}
//定位并读取包含目标迷你扇区的标准扇区。
async function readSectorByMiniSectorNumber(
startSectorNumber: number,
miniSectorNumber: number
): Promise<[ArrayBuffer, number]> {
let sectorNumber = startSectorNumber;
const desiredSectorCount = getSectorCountByMiniSectorNumber(miniSectorNumber);
for (let sectorCount = 0; sectorCount < desiredSectorCount; sectorCount++) {
if (sectorNumber === END_OF_CHAIN) {
...
}
sectorNumber = await getNextSectorInChain(sectorNumber);
}
const sector = await readSector(sectorNumber);
return [sector, desiredSectorCount];
}
async function readMiniStream(startMiniSectorNumber: number, streamSize: number): Promise<Uint8Array> {
//根目录的数据流就是所有迷你扇区的存储容器
//root.startSector 是迷你流数据的标准扇区起始位置
const root = directoryTable[0];
const result = new Uint8Array(streamSize);
const startSectorNumber = root.startSector;
let miniSectorNumber = startMiniSectorNumber;
//定位并读取包含目标迷你扇区的标准扇区。
let [sector, sectorCount] = await readSectorByMiniSectorNumber(startSectorNumber, miniSectorNumber);
for (let miniSectorCount = 0; miniSectorCount < MAX_SECTORS_COUNT_IN_CHAIN; miniSectorCount++) {
if (miniSectorNumber === END_OF_CHAIN) {
break;
}
const desiredSectorCount = getSectorCountByMiniSectorNumber(miniSectorNumber);
if (desiredSectorCount != sectorCount) {
[sector, sectorCount] = await readSectorByMiniSectorNumber(startSectorNumber, miniSectorNumber);
}
// 计算相对位置:当前迷你扇区在标准扇区内的相对索引
// 计算字节偏移:相对索引 × 迷你扇区大小(64字节)
// 确定数据大小:处理最后一个迷你扇区可能不满的情况
// 提取并复制数据:从标准扇区中切片并复制到结果数组
const miniSectorNumberFromStartOfSector =
miniSectorNumber - sectorCount * countOfMiniSectorsInSector;
const offset = miniSectorNumberFromStartOfSector * miniSectorSize;
const dataSize = Math.min(
miniSectorSize,
streamSize - miniSectorCount * miniSectorSize
);
const array = new Uint8Array(sector.slice(offset, offset + dataSize));
result.set(array, miniSectorCount * miniSectorSize);
miniSectorNumber = getNextMiniSectorInChain(miniSectorNumber);
}
return result;
}
本文介绍了从一个复合二进制文档的文档头开始,一步步解析各种扇区配置(主扇区配置,扇区配置,短扇区配置),再到解析文档的目录结构,最后是读取流与短流的二进制数据结束。
文中的方法,仅仅是读取文档结构的二进制数据,对文档内容还是知之甚少。