前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

OLE2 的目录流就像一个"文件目录"——记录了每个流的名称、类型、起始扇区和大小。doc_text 需要从中找到 WordDocument 流(文本数据)和 1Table/0Table 流(格式信息)。这篇把 parseDirectoryEntry 和 findEntry 的每一行代码都过一遍。

一、目录条目的 128 字节布局

1.1 结构定义

目录条目(128 字节):
偏移  长度  字段
0x00  64    名称(UTF-16LE,最长 32 字符)
0x40  2     名称长度(字节数,包含终止符)
0x42  1     条目类型
0x43  1     颜色(红黑树:0=红,1=黑)
0x44  4     左兄弟 ID
0x48  4     右兄弟 ID
0x4C  4     子节点 ID
0x50  16    CLSID
0x60  4     状态位
0x64  8     创建时间
0x6C  8     修改时间
0x74  4     起始扇区
0x78  4     流大小
0x7C  4     保留

1.2 DirectoryEntry 接口

interface DirectoryEntry {
  name: string;
  type: number;
  startSector: number;
  size: number;
}

doc_text 只提取了四个字段——名称、类型、起始扇区、大小。其他字段(颜色、兄弟节点、时间戳等)对于文本提取不需要。

1.3 条目类型

type 值 含义 说明
0 Empty 空条目(目录结束标记)
1 Storage 存储(类似文件夹)
2 Stream 流(类似文件)
5 Root Entry 根条目(特殊)

二、parseDirectoryEntry 完整实现

2.1 源码

private parseDirectoryEntry(data: Uint8Array, index: number): DirectoryEntry {
  const offset = index * 128;

  if (offset + 128 > data.length) {
    return { name: "", type: 0, startSector: 0, size: 0 };
  }

  // 读取名称
  const nameLen = data[offset + 64] | (data[offset + 65] << 8);
  let name = "";
  if (nameLen > 0 && nameLen <= 64) {
    for (let i = 0; i < Math.min(nameLen - 2, 62); i += 2) {
      const ch = data[offset + i] | (data[offset + i + 1] << 8);
      if (ch > 0 && ch < 128) {
        name += String.fromCharCode(ch);
      }
    }
  }

  const type = data[offset + 66];
  const startSector = (data[offset + 116] |
                      (data[offset + 117] << 8) |
                      (data[offset + 118] << 16) |
                      (data[offset + 119] << 24)) >>> 0;
  const size = (data[offset + 120] |
               (data[offset + 121] << 8) |
               (data[offset + 122] << 16) |
               (data[offset + 123] << 24)) >>> 0;

  return { name, type, startSector, size };
}

2.2 逐段解析

段落 代码 说明
偏移计算 index * 128 每个条目 128 字节
边界检查 offset + 128 > data.length 防止越界
名称长度 data[offset + 64] | (data[offset + 65] << 8) 偏移 0x40,2 字节
名称解析 UTF-16LE 逐字符读取 只取 ASCII 部分
类型 data[offset + 66] 偏移 0x42,1 字节
起始扇区 偏移 0x74(116),4 字节 小端序
大小 偏移 0x78(120),4 字节 小端序

三、Unicode 名称解析

3.1 UTF-16LE 编码

"WordDocument" 在 UTF-16LE 中的存储:
W  o  r  d  D  o  c  u  m  e  n  t  \0
57 00 6F 00 72 00 64 00 44 00 6F 00 63 00 75 00 6D 00 65 00 6E 00 74 00 00 00

每个字符占 2 个字节,低字节在前(小端序)。

3.2 名称读取代码

const nameLen = data[offset + 64] | (data[offset + 65] << 8);
let name = "";
if (nameLen > 0 && nameLen <= 64) {
  for (let i = 0; i < Math.min(nameLen - 2, 62); i += 2) {
    const ch = data[offset + i] | (data[offset + i + 1] << 8);
    if (ch > 0 && ch < 128) {
      name += String.fromCharCode(ch);
    }
  }
}

3.3 关键细节

细节 代码 说明
nameLen - 2 Math.min(nameLen - 2, 62) 减去终止符的 2 字节
i += 2 步长为 2 UTF-16LE 每字符 2 字节
ch < 128 if (ch > 0 && ch < 128) 只取 ASCII 字符
62 上限 Math.min(..., 62) 名称区域最大 64 字节 - 2

3.4 只取 ASCII 的限制

if (ch > 0 && ch < 128) {
  name += String.fromCharCode(ch);
}

这意味着非 ASCII 名称(比如中文流名称)会被忽略。但 Word 文档中的关键流名称都是 ASCII 的:

流名称 全 ASCII 能否识别
WordDocument
1Table
0Table
Data
CompObj
Root Entry

📌 Word 文档的所有关键流名称都是 ASCII,所以只取 ASCII 字符不影响功能。如果需要支持其他 OLE2 文件(比如中文命名的 Excel 工作表),就需要完整的 UTF-16 解码。

四、findEntry 方法

4.1 完整实现

findEntry(name: string): DirectoryEntry | null {
  const dirData = this.readStream(this.firstDirSector, this.sectorSize * 10, false);
  if (!dirData) return null;

  for (let i = 0; i < 100; i++) {
    const entry = this.parseDirectoryEntry(dirData, i);
    if (entry.type === 0) break;
    if (entry.name === name) {
      return entry;
    }
  }
  return null;
}

4.2 执行流程

findEntry("WordDocument")
    ↓
readStream(firstDirSector, 5120, false)  → 读取目录流数据
    ↓
for i = 0 to 99:
    parseDirectoryEntry(dirData, i)  → 解析第 i 个条目
    if type === 0 → 空条目,结束遍历
    if name === "WordDocument" → 找到了!返回
    ↓
return null  → 没找到

4.3 为什么读 sectorSize * 10

const dirData = this.readStream(this.firstDirSector, this.sectorSize * 10, false);
// 512 * 10 = 5120 字节
// 5120 / 128 = 40 个目录条目

大多数 Word 文档的目录条目不超过 40 个。读 10 个扇区是一个安全的估算。

4.4 为什么遍历上限是 100

for (let i = 0; i < 100; i++) {

这是另一个安全上限。即使目录数据很大,也不会遍历超过 100 个条目。实际上 type === 0 的检查会更早终止循环。

4.5 线性遍历 vs 红黑树

OLE2 规范中,目录条目组织成红黑树结构:
每个条目有 leftSibling、rightSibling、childID 字段

doc_text 的做法:忽略树结构,线性遍历所有条目
方案 优点 缺点
线性遍历 简单、代码少 O(n) 复杂度
红黑树遍历 O(log n) 复杂度 实现复杂

💡 对于 Word 文档来说,目录条目通常不超过 20 个。线性遍历的 O(n) 和红黑树的 O(log n) 差异可以忽略不计。简单实现更重要。

五、readEntryData 方法

5.1 实现

readEntryData(entry: DirectoryEntry): Uint8Array | null {
  const useMini = entry.size < this.miniStreamCutoff;
  return this.readStream(entry.startSector, entry.size, useMini);
}

5.2 自动选择读取模式

entry.size < 4096 → useMini = true → 从 Mini Stream 读取
entry.size >= 4096 → useMini = false → 从普通扇区读取

5.3 使用示例

// 读取 WordDocument 流(通常很大,用普通扇区)
const wordEntry = ole.findEntry("WordDocument");
const wordData = ole.readEntryData(wordEntry);
// useMini = false(WordDocument 通常 > 4096 字节)

// 读取 CompObj 流(通常很小,用迷你扇区)
const compEntry = ole.findEntry("CompObj");
const compData = ole.readEntryData(compEntry);
// useMini = true(CompObj 通常 < 4096 字节)

六、Word 文档中的关键流

6.1 doc_text 需要的流

// 1. WordDocument 流 — 必须
const wordEntry = ole.findEntry("WordDocument");
const wordData = ole.readEntryData(wordEntry);

// 2. 1Table 或 0Table 流 — 需要(Piece Table 在这里)
let tableEntry = ole.findEntry("1Table");
if (!tableEntry) {
  tableEntry = ole.findEntry("0Table");
}
const tableData = ole.readEntryData(tableEntry);

6.2 流的用途

流名称 内容 doc_text 是否使用
WordDocument FIB + 文本数据 ✅ 必须
1Table 格式信息(Piece Table) ✅ 需要
0Table 备用格式信息 ✅ 备选
Data 嵌入对象
CompObj 复合对象信息
SummaryInformation 文档属性

6.3 1Table vs 0Table

// 优先使用 1Table
let tableEntry = ole.findEntry("1Table");
if (!tableEntry) {
  tableEntry = ole.findEntry("0Table");
}
使用场景
1Table Word 97+ 默认使用
0Table 旧版本或特殊情况

Word 文档的 FIB 中有一个标志位指示应该使用哪个 Table 流。doc_text 简化了这个逻辑——先试 1Table,不行再试 0Table。

七、边界检查与错误处理

7.1 parseDirectoryEntry 的边界检查

if (offset + 128 > data.length) {
  return { name: "", type: 0, startSector: 0, size: 0 };
}

如果数据不够 128 字节,返回一个空条目(type=0)。findEntry 中的 if (entry.type === 0) break 会终止遍历。

7.2 findEntry 的空值处理

const dirData = this.readStream(this.firstDirSector, this.sectorSize * 10, false);
if (!dirData) return null;

如果目录流读取失败,直接返回 null。

7.3 调用链中的空值传播

const wordEntry = ole.findEntry("WordDocument");
if (!wordEntry) {
  return null;  // 找不到 WordDocument 流
}

const wordData = ole.readEntryData(wordEntry);
if (!wordData) {
  return null;  // 读取流数据失败
}

每一步都检查 null,确保不会在后续操作中崩溃。

八、完整的流定位流程

8.1 从文件到文本数据

.doc 文件(Uint8Array)
    ↓
OLE2Parser(bytes) → 解析头部、构建 FAT
    ↓
findEntry("WordDocument") → 遍历目录,找到条目
    ↓
readEntryData(wordEntry) → 根据 FAT 链读取流数据
    ↓
WordDocument 流数据(Uint8Array)
    ↓
extractWordText(wordData, ole) → 解析 FIB + Piece Table
    ↓
纯文本字符串

8.2 涉及的数据结构

数据结构 类型 作用
bytes Uint8Array 原始文件
fat number[] 扇区链表
miniFat number[] 迷你扇区链表
miniStream Uint8Array 迷你流数据
dirData Uint8Array 目录流数据
DirectoryEntry interface 目录条目信息
wordData Uint8Array WordDocument 流
tableData Uint8Array Table 流

总结

目录条目解析是连接 OLE2 底层结构和 Word 文档内容的桥梁:

  1. 128 字节条目:名称(UTF-16LE)+ 类型 + 起始扇区 + 大小
  2. 线性遍历:忽略红黑树结构,简单有效
  3. 自动模式选择:readEntryData 根据大小决定普通/迷你扇区
  4. 关键流:WordDocument(文本)+ 1Table/0Table(格式)
  5. 防御性编程:每一步都检查 null 和边界

下一篇我们进入 Word 文档的核心——FIB 解析与 Piece Table 文本提取。

如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!


相关资源:

目录结构
OLE2 目录流与流定位

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐