Flutter三方库适配OpenHarmony【doc_text】— 直接提取回退策略与多偏移量探测
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.netPiece Table 是精确提取文本的"正道",但它不是万能的——有些文档的 CLX 结构损坏、Table 流缺失、或者格式太旧。这时候 doc_text 会启用回退策略:直接在 WordDocument 流中暴力搜索文本。三个偏移量、两种编码、取最长结果。粗暴但有效。三个偏移量:0x2
前言
欢迎加入开源鸿蒙跨平台社区: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 的最后一道防线:
- 三个偏移量:0x200 / 0x400 / 0x800,覆盖不同的 FIB 长度
- 双通道探测:Unicode(2字节)和 ANSI(1字节)并行尝试
- 有效字符计数:通过比较 validChars 自动判断编码
- 取最长结果:三个偏移量中选最长的文本
- 启发式方法:不精确但实用,是 Piece Table 的有效补充
下一篇我们看字符转换和文本清洗——convertToChar、isValidTextChar、cleanText 的完整实现。
如果这篇文章对你有帮助,欢迎点赞👍、收藏⭐、关注🔔,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)