前言

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

Piece Table 是精确提取文本的"正道",但它不是万能的——有些文档的 CLX 结构损坏、Table 流缺失、或者格式太旧。这时候 doc_text 会启用回退策略:直接在 WordDocument 流中暴力搜索文本。三个偏移量、两种编码、取最长结果。粗暴但有效。

一、extractTextDirect 完整代码

1.1 源码

private extractTextDirect(wordBytes: Uint8Array, ccpText: number): string | null {
  let bestResult = "";
  const startOffsets = [0x200, 0x400, 0x800];

  for (const startOffset of startOffsets) {
    // 尝试 Unicode
    let unicodeText = "";
    let validUnicodeChars = 0;
    let i = startOffset;

    while (i + 1 < wordBytes.length && unicodeText.length < ccpText) {
      const codeUnit = wordBytes[i] | (wordBytes[i + 1] << 8);
      i += 2;

      if (this.isValidTextChar(codeUnit)) {
        const char = this.convertToChar(codeUnit);
        if (char) {
          unicodeText += char;
          if ((codeUnit >= 0x4E00 && codeUnit <= 0x9FFF) ||
              (codeUnit >= 0x20 && codeUnit < 0x7F)) {
            validUnicodeChars++;
          }
        }
      } else if (codeUnit === 0) {
        continue;
      } else if (validUnicodeChars < 10) {
        break;
      }
    }

    // 尝试 ANSI
    let ansiText = "";
    let validAnsiChars = 0;
    i = startOffset;

    while (i < wordBytes.length && ansiText.length < ccpText) {
      const byte = wordBytes[i];
      i++;

      if (byte >= 0x20 && byte < 0x7F) {
        ansiText += String.fromCharCode(byte);
        validAnsiChars++;
      } else if (byte === 0x0D || byte === 0x0A) {
        ansiText += "\n";
      } else if (byte === 0x09) {
        ansiText += "\t";
      }
    }

    let result = "";
    if (validUnicodeChars > validAnsiChars && unicodeText.length > 10) {
      result = unicodeText;
    } else if (ansiText.length > 10) {
      result = ansiText;
    }

    if (result.length > bestResult.length) {
      bestResult = result;
    }
  }

  return bestResult.length > 0 ? this.cleanText(bestResult) : null;
}

二、三个起始偏移量

2.1 为什么是 0x200、0x400、0x800

const startOffsets = [0x200, 0x400, 0x800];
// 0x200 = 512
// 0x400 = 1024
// 0x800 = 2048

在这里插入图片描述

2.2 为什么不从偏移 0 开始

WordDocument 流的布局:
偏移 0x000: ┌──────────────────┐
            │ FIB(文件信息块)  │  ← 二进制元数据,不是文本
            │ 长度可变           │
            │ (通常 512-2048B)  │
偏移 0x200: ├──────────────────┤
            │ 可能是文本数据     │  ← 从这里开始探测
            │                  │
偏移 0x400: ├──────────────────┤
            │ 可能是文本数据     │
            │                  │

FIB 占据了流的开头部分,里面是二进制元数据。文本数据在 FIB 之后。但 FIB 的长度不固定,所以用三个偏移量来"猜"。

2.3 多偏移量探测的策略

对每个偏移量:
1. 从该偏移开始,尝试 Unicode 解码
2. 从该偏移开始,尝试 ANSI 解码
3. 比较两种解码的有效字符数
4. 取更好的那个

三个偏移量各产生一个结果
→ 取最长的那个作为最终结果

💡 这是一种"暴力探测"策略——不知道文本从哪里开始,就多试几个位置。不够优雅,但在 Piece Table 失败的情况下,这是最实用的方法。

三、Unicode 探测通道

3.1 代码

let unicodeText = "";
let validUnicodeChars = 0;
let i = startOffset;

while (i + 1 < wordBytes.length && unicodeText.length < ccpText) {
  const codeUnit = wordBytes[i] | (wordBytes[i + 1] << 8);
  i += 2;

  if (this.isValidTextChar(codeUnit)) {
    const char = this.convertToChar(codeUnit);
    if (char) {
      unicodeText += char;
      if ((codeUnit >= 0x4E00 && codeUnit <= 0x9FFF) ||
          (codeUnit >= 0x20 && codeUnit < 0x7F)) {
        validUnicodeChars++;
      }
    }
  } else if (codeUnit === 0) {
    continue;  // 跳过空字符
  } else if (validUnicodeChars < 10) {
    break;  // 还没找到足够的有效字符就遇到无效字符,放弃
  }
}

3.2 有效字符计数

if ((codeUnit >= 0x4E00 && codeUnit <= 0x9FFF) ||  // CJK 统一汉字
    (codeUnit >= 0x20 && codeUnit < 0x7F)) {        // ASCII 可打印
  validUnicodeChars++;
}
范围 含义 示例
0x4E00-0x9FFF CJK 统一汉字 你、好、世、界
0x20-0x7E ASCII 可打印字符 A-Z, a-z, 0-9, 标点

只有这两个范围的字符才算"有效"。其他 Unicode 字符(如全角符号、特殊标点)虽然也会被提取,但不计入有效字符数。

3.3 提前终止条件

} else if (validUnicodeChars < 10) {
  break;  // 有效字符不足 10 个就遇到无效数据,说明这个偏移不对
}
场景 validUnicodeChars 行为
刚开始就遇到无效数据 < 10 放弃这个偏移
已经找到很多有效字符后遇到无效数据 ≥ 10 继续(可能是嵌入对象)

📌 10 是一个经验阈值——如果连 10 个有效字符都找不到,说明这个偏移位置不是文本数据的起始点。

四、ANSI 探测通道

4.1 代码

let ansiText = "";
let validAnsiChars = 0;
i = startOffset;

while (i < wordBytes.length && ansiText.length < ccpText) {
  const byte = wordBytes[i];
  i++;

  if (byte >= 0x20 && byte < 0x7F) {
    ansiText += String.fromCharCode(byte);
    validAnsiChars++;
  } else if (byte === 0x0D || byte === 0x0A) {
    ansiText += "\n";
  } else if (byte === 0x09) {
    ansiText += "\t";
  }
}

4.2 ANSI 通道的特点

特点 说明
单字节读取 每次读 1 个字节
只取 ASCII 0x20-0x7E 范围
处理控制字符 0x0D/0x0A → 换行,0x09 → 制表符
忽略高位字节 0x80+ 的字节被跳过
不会提前终止 一直读到文件末尾或 ccpText

4.3 ANSI 通道的局限

ANSI 通道只能提取 ASCII 文本。
如果文档包含中文、日文、韩文等非 ASCII 字符,
ANSI 通道会丢失这些内容。

这就是为什么需要同时尝试 Unicode 和 ANSI 两种方式。

五、编码选择:validChars 计数比较

5.1 代码

let result = "";
if (validUnicodeChars > validAnsiChars && unicodeText.length > 10) {
  result = unicodeText;
} else if (ansiText.length > 10) {
  result = ansiText;
}

5.2 决策逻辑

条件 选择 原因
Unicode 有效字符多 且 长度 > 10 Unicode Unicode 解码更成功
否则,ANSI 长度 > 10 ANSI ANSI 作为备选
都不满足 空字符串 这个偏移没有有效文本

5.3 为什么 Unicode 优先

Unicode 文本:
字节 [0x48, 0x00, 0x65, 0x00, 0x6C, 0x00, 0x6C, 0x00, 0x6F, 0x00]
Unicode 解码:H e l l o(5 个有效字符)
ANSI 解码:H \0 e \0 l \0 l \0 o \0(5 个有效 + 5 个被跳过)

ANSI 文本:
字节 [0x48, 0x65, 0x6C, 0x6C, 0x6F]
Unicode 解码:0x6548 0x6C6C 0x006F(可能是乱码)
ANSI 解码:H e l l o(5 个有效字符)

如果文本是 Unicode 编码,Unicode 通道的有效字符数会更多。如果是 ANSI 编码,ANSI 通道的有效字符数会更多。通过比较有效字符数,可以自动判断编码。

六、bestResult 选择策略

6.1 代码

if (result.length > bestResult.length) {
  bestResult = result;
}

6.2 三个偏移量的结果比较

偏移 0x200 → result_1(可能是空的,也可能有文本)
偏移 0x400 → result_2
偏移 0x800 → result_3

bestResult = 最长的那个

6.3 为什么取最长

策略 优点 缺点
取最长 最可能包含完整文本 可能包含垃圾数据
取第一个非空 简单 可能不是最好的
取有效字符比例最高 最准确 实现复杂

💡 取最长是一个合理的启发式——正确的偏移位置通常能提取出最多的有效文本。错误的偏移位置要么提取不到文本(长度为 0),要么很快遇到无效数据而终止。

七、isValidTextChar 的角色

7.1 在直接提取中的使用

if (this.isValidTextChar(codeUnit)) {
  const char = this.convertToChar(codeUnit);
  // ...
}

7.2 覆盖的 Unicode 范围

private isValidTextChar(codeUnit: number): boolean {
  if (codeUnit >= 0x20 && codeUnit < 0x7F) return true;     // ASCII 可打印
  if (codeUnit === 0x0D || codeUnit === 0x0A || 
      codeUnit === 0x09 || codeUnit === 0x0B) return true;   // 控制字符
  if (codeUnit >= 0x4E00 && codeUnit <= 0x9FFF) return true; // CJK 统一汉字
  if (codeUnit >= 0x3000 && codeUnit <= 0x303F) return true; // CJK 标点
  if (codeUnit >= 0xFF00 && codeUnit <= 0xFFEF) return true; // 全角字符
  if (codeUnit >= 0x00A0 && codeUnit <= 0x00FF) return true; // Latin-1 补充
  if (codeUnit >= 0x2000 && codeUnit <= 0x206F) return true; // 通用标点
  return false;
}
范围 名称 示例
0x0020-0x007E ASCII 可打印 A-Z, 0-9
0x000D, 0x000A, 0x0009, 0x000B 控制字符 回车、换行、制表
0x4E00-0x9FFF CJK 统一汉字 你、好
0x3000-0x303F CJK 标点 、。「」
0xFF00-0xFFEF 全角字符 A、1
0x00A0-0x00FF Latin-1 补充 é、ñ
0x2000-0x206F 通用标点 —、…

八、直接提取 vs Piece Table 的对比

8.1 对比表

维度 Piece Table 直接提取
准确性 中低
编码判断 精确(fc 位 30) 启发式(字符计数)
文本定位 精确(CP 数组) 暴力(多偏移量)
处理混合编码 ✅ 每个 piece 独立 ❌ 整体判断
代码复杂度
适用场景 正常文档 异常/损坏文档

8.2 回退策略的价值

没有回退策略:
Piece Table 失败 → 返回 null → 用户看不到任何内容

有回退策略:
Piece Table 失败 → 直接提取 → 可能提取到部分文本
→ 总比什么都没有强

8.3 实际效果

文档类型 Piece Table 直接提取
正常 Word 97-2003 ✅ 完美 ✅ 也能工作
损坏的 .doc ❌ 失败 ⚠️ 可能部分提取
非常旧的 .doc ❌ CLX 缺失 ⚠️ 可能工作
加密的 .doc ❌ 返回提示 ❌ 乱码

总结

直接提取是 doc_text 的最后一道防线:

  1. 三个偏移量:0x200 / 0x400 / 0x800,覆盖不同的 FIB 长度
  2. 双通道探测:Unicode(2字节)和 ANSI(1字节)并行尝试
  3. 有效字符计数:通过比较 validChars 自动判断编码
  4. 取最长结果:三个偏移量中选最长的文本
  5. 启发式方法:不精确但实用,是 Piece Table 的有效补充

下一篇我们看字符转换和文本清洗——convertToChar、isValidTextChar、cleanText 的完整实现。

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


相关资源:

Logo

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

更多推荐