前言

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

从 Word 文档中提取出来的"原始文本"并不干净——里面混着各种控制字符、空字节、Word 专用的特殊标记。doc_text 用了三个工具方法来处理这些问题:convertToChar 做字符映射,isValidTextChar 做字符过滤,cleanText 做最终清洗。这篇把这三个方法以及底层的 readU16/readU32 都讲透。

一、convertToChar:控制字符映射

1.1 完整实现

private convertToChar(codeUnit: number): string | null {
  if (codeUnit === 0x0D || codeUnit === 0x0B || codeUnit === 0x0C) {
    return "\n";
  }
  if (codeUnit === 0x09) {
    return "\t";
  }
  if (codeUnit < 0x20 || codeUnit === 0x7F) {
    return null;
  }
  return String.fromCharCode(codeUnit);
}

1.2 映射规则

codeUnit 十六进制 名称 映射结果
13 0x0D 回车 (CR) \n
11 0x0B 垂直制表符 (VT) \n
12 0x0C 换页符 (FF) \n
9 0x09 水平制表符 (HT) \t
0-8, 10, 14-31 0x00-0x1F 其他控制字符 null(丢弃)
127 0x7F DEL null(丢弃)
32+ 0x20+ 可打印字符 String.fromCharCode(codeUnit)

1.3 Word 中的特殊控制字符

Word 文档中常见的控制字符:
0x0D (CR) → 段落结束标记(Word 用 CR 而不是 LF)
0x0B (VT) → 软回车(Shift+Enter)
0x0C (FF) → 分页符
0x09 (HT) → 制表符
0x07      → 单元格结束标记(表格中)
0x01      → 图片/OLE 对象占位符
0x13      → 域代码开始
0x14      → 域代码分隔
0x15      → 域代码结束

💡 Word 用 0x0D(CR)作为段落结束标记,而不是 0x0A(LF)。这是 Word 二进制格式的历史遗留。convertToChar 把 CR、VT、FF 都映射成 \n,统一了换行表示。

1.4 为什么返回 null 而不是空字符串

if (codeUnit < 0x20 || codeUnit === 0x7F) {
  return null;  // 不是 ""
}
// 调用方的处理
const char = this.convertToChar(codeUnit);
if (char) {        // null 是 falsy,"" 也是 falsy
  result += char;  // 只有非空字符才拼接
}

返回 null 和返回 “” 在当前代码中效果一样(都是 falsy),但 null 语义更清晰——表示"这个字符应该被丢弃"。

二、isValidTextChar:Unicode 范围判断

2.1 完整实现

private isValidTextChar(codeUnit: number): boolean {
  if (codeUnit >= 0x20 && codeUnit < 0x7F) return true;
  if (codeUnit === 0x0D || codeUnit === 0x0A || codeUnit === 0x09 || codeUnit === 0x0B) return true;
  if (codeUnit >= 0x4E00 && codeUnit <= 0x9FFF) return true;
  if (codeUnit >= 0x3000 && codeUnit <= 0x303F) return true;
  if (codeUnit >= 0xFF00 && codeUnit <= 0xFFEF) return true;
  if (codeUnit >= 0x00A0 && codeUnit <= 0x00FF) return true;
  if (codeUnit >= 0x2000 && codeUnit <= 0x206F) return true;
  return false;
}

2.2 六个 Unicode 区间详解

在这里插入图片描述

2.3 未覆盖的 Unicode 区间

区间 范围 名称 是否覆盖
0x0100-0x024F Latin Extended
0x0400-0x04FF Cyrillic
0x0600-0x06FF Arabic
0x0E00-0x0E7F Thai
0xAC00-0xD7AF Hangul Syllables(韩文)
0x3040-0x309F Hiragana(日文平假名)
0x30A0-0x30FF Katakana(日文片假名)

📌 isValidTextChar 主要覆盖了中文和英文。日文假名、韩文、阿拉伯文等没有被覆盖。这意味着包含这些文字的文档在直接提取模式下可能会丢失内容。Piece Table 模式不受影响,因为它不依赖 isValidTextChar。

2.4 isValidTextChar vs convertToChar 的关系

isValidTextChar:用于直接提取模式,判断一个 codeUnit 是否"看起来像文本"
convertToChar:用于所有模式,把 codeUnit 转换成可读字符

isValidTextChar 的范围比 convertToChar 窄:
- isValidTextChar 只接受特定的 Unicode 区间
- convertToChar 接受所有 >= 0x20 的字符
方法 使用场景 判断标准
isValidTextChar 直接提取(暴力探测) 严格(6 个区间)
convertToChar Piece Table + 直接提取 宽松(>= 0x20)

三、cleanText:多步清洗管道

3.1 完整实现

private cleanText(text: string): string {
  return text
    .replace(/\r\n/g, "\n")
    .replace(/\r/g, "\n")
    .replace(/\u0000/g, "")
    .replace(/\u0001/g, "")
    .replace(/\u0007/g, "")
    .replace(/\u0013/g, "")
    .replace(/\u0014/g, "")
    .replace(/\u0015/g, "")
    .split("\n")
    .map(line => line.trim())
    .filter(line => line.length > 0)
    .join("\n");
}

3.2 清洗步骤

步骤 操作 说明
1 \r\n\n Windows 换行统一
2 \r\n Mac 旧式换行统一
3 删除 \u0000 空字节(NULL)
4 删除 \u0001 图片/OLE 占位符
5 删除 \u0007 表格单元格结束标记
6 删除 \u0013 域代码开始
7 删除 \u0014 域代码分隔
8 删除 \u0015 域代码结束
9 \n 分割 拆成行数组
10 每行 trim 去除首尾空白
11 过滤空行 删除空行
12 \n 拼接 重新组合

3.3 Word 特殊字符的清除

\u0001 — 图片占位符
  Word 中插入图片时,文本流中会有一个 0x01 字符标记位置。
  提取纯文本时应该删除。

\u0007 — 单元格结束
  表格中每个单元格的文本末尾有一个 0x07 字符。
  提取纯文本时应该删除。

\u0013 \u0014 \u0015 — 域代码
  Word 的域(Field)用这三个字符包裹:
  \u0013 域代码 \u0014 域结果 \u0015
  比如页码、目录、超链接等。
  提取纯文本时删除这些标记字符。

3.4 清洗前后对比

清洗前:
"  Hello\u0001World\r\n\r\n  \u0007\n  第一段  \n\n  第二段\u0013PAGE\u0014 3 \u0015  \n"

步骤 1-2:换行统一
"  Hello\u0001World\n\n  \u0007\n  第一段  \n\n  第二段\u0013PAGE\u0014 3 \u0015  \n"

步骤 3-8:删除特殊字符
"  HelloWorld\n\n  \n  第一段  \n\n  第二段PAGE 3   \n"

步骤 9-12:分割、trim、过滤、拼接
"HelloWorld\n第一段\n第二段PAGE 3"

💡 cleanText 是 .doc 和 .docx 共用的——无论哪种格式提取出来的文本,都经过同样的清洗流程。

四、readU16 和 readU32(DocTextPlugin 版本)

4.1 实现

private readU16(bytes: Uint8Array, offset: number): number {
  if (offset + 1 >= bytes.length) return 0;
  return bytes[offset] | (bytes[offset + 1] << 8);
}

private readU32(bytes: Uint8Array, offset: number): number {
  if (offset + 3 >= bytes.length) return 0;
  return bytes[offset] |
         (bytes[offset + 1] << 8) |
         (bytes[offset + 2] << 16) |
         (bytes[offset + 3] << 24);
}

4.2 与 OLE2Parser 版本的区别

区别 DocTextPlugin OLE2Parser
数据源 参数传入 bytes this.bytes
readU32 >>> 0 >>> 0
使用场景 FIB、Piece Table FAT、目录

4.3 缺少 >>> 0 的影响

// DocTextPlugin 的 readU32:没有 >>> 0
return bytes[offset] |
       (bytes[offset + 1] << 8) |
       (bytes[offset + 2] << 16) |
       (bytes[offset + 3] << 24);
// 如果最高位为 1,结果可能是负数

// OLE2Parser 的 readU32:有 >>> 0
return (...) >>> 0;
// 结果总是无符号的
场景 无 >>> 0 有 >>> 0
值 < 0x80000000 正确 正确
值 >= 0x80000000 负数 正确的正数

📌 对于 FIB 中的 ccpText 和 fcClx,值通常不会超过 0x80000000,所以缺少 >>> 0 在实际使用中不会出问题。但如果追求严谨,应该加上。

五、字符编码踩坑实录

5.1 GBK 文档的问题

场景:一个用 Word 2003 中文版创建的 .doc 文件
编码:ANSI 部分使用 GBK(而不是 UTF-16)

问题:
extractAnsiChars 把每个字节当作单独的字符处理
但 GBK 中文字符是双字节的
→ 中文字符被拆成两个乱码字符

5.2 示例

"你好" 在 GBK 中:[0xC4, 0xE3, 0xBA, 0xC3]

extractAnsiChars 的处理:
0xC4 → byte >= 0x80 → String.fromCharCode(0xC4) → 'Ä'
0xE3 → byte >= 0x80 → String.fromCharCode(0xE3) → 'ã'
0xBA → byte >= 0x80 → String.fromCharCode(0xBA) → 'º'
0xC3 → byte >= 0x80 → String.fromCharCode(0xC3) → 'Ã'

结果:"Äãºà" 而不是 "你好"

5.3 为什么 Piece Table 模式不受影响

Piece Table 模式下:
1. fc 位 30 = 0 → Unicode → extractUnicodeChars
   → UTF-16LE 解码,中文字符正确

2. fc 位 30 = 1 → ANSI → extractAnsiChars
   → 但现代 Word 文档中,中文通常存储为 Unicode
   → ANSI 部分通常只有纯 ASCII 文本

5.4 可能的改进

// 改进方案:检测 GBK 双字节字符
private extractAnsiChars(bytes: Uint8Array, offset: number, count: number): string {
  // ...
  if (byte >= 0x81 && byte <= 0xFE && i < bytes.length) {
    const nextByte = bytes[i];
    if (nextByte >= 0x40 && nextByte <= 0xFE) {
      // GBK 双字节字符
      // 需要 GBK → Unicode 转换表
    }
  }
  // ...
}

但这需要一个 GBK 到 Unicode 的映射表(几千个条目),会显著增加代码量。当前的实现选择了简单性。

六、String.fromCharCode 的使用

6.1 基本用法

String.fromCharCode(0x0048)  // → 'H'
String.fromCharCode(0x4F60)  // → '你'
String.fromCharCode(0x0041)  // → 'A'

6.2 BMP 范围限制

// String.fromCharCode 只能处理 BMP(基本多语言平面)字符
// 即 U+0000 到 U+FFFF

// 对于 U+10000 以上的字符(如 emoji),需要代理对
// doc_text 没有处理这种情况
// 但 Word 97-2003 文档中很少有 emoji

6.3 与 String.fromCodePoint 的区别

方法 范围 示例
fromCharCode U+0000 - U+FFFF 基本够用
fromCodePoint U+0000 - U+10FFFF 支持 emoji 等

七、性能考虑

7.1 字符串拼接

// 当前实现:逐字符拼接
result += char;

// 潜在问题:大量字符串拼接可能导致性能问题
// 每次 += 都会创建新的字符串对象

7.2 可能的优化

// 优化方案:使用数组收集,最后 join
const chars: string[] = [];
// ...
chars.push(char);
// ...
return chars.join("");
方案 优点 缺点
+= 拼接 简单 大文本可能慢
数组 + join 大文本更快 多一步

实际上对于大多数 Word 文档(几千到几万字符),+= 拼接的性能是可以接受的。

7.3 cleanText 的正则性能

// 7 个 replace 调用,每个都遍历整个字符串
text
  .replace(/\r\n/g, "\n")
  .replace(/\r/g, "\n")
  .replace(/\u0000/g, "")
  // ...

可以合并成一个正则:

// 优化:一次正则替换
text.replace(/\r\n|\r|\u0000|\u0001|\u0007|\u0013|\u0014|\u0015/g, (match) => {
  if (match === '\r\n' || match === '\r') return '\n';
  return '';
});

但可读性会降低。当前的链式调用更清晰。

八、三个方法的协作关系

8.1 调用关系

extractTextFromDocx
    └── parseDocxXml
            └── cleanText

extractTextFromOldDoc
    └── extractWordText
            ├── extractTextWithPieceTable
            │       ├── extractUnicodeChars → convertToChar
            │       └── extractAnsiChars
            ├── extractTextDirect
            │       ├── isValidTextChar
            │       └── convertToChar
            └── cleanText

8.2 方法职责

方法 职责 输入 输出
isValidTextChar 判断是否有效文本字符 codeUnit boolean
convertToChar 转换为可读字符 codeUnit string | null
cleanText 清洗最终文本 原始文本 干净文本

总结

字符处理是 doc_text 文本提取的最后一环:

  1. convertToChar:CR/VT/FF → \n,HT → \t,控制字符 → null
  2. isValidTextChar:6 个 Unicode 区间,主要覆盖中英文
  3. cleanText:换行统一 + 特殊字符删除 + 空行过滤
  4. readU16/readU32:小端序读取,边界保护
  5. GBK 问题:ANSI 模式下中文可能乱码,Piece Table 模式不受影响

下一篇我们看临时文件管理——docx 解压产生的临时目录如何创建和清理。

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


相关资源:

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐