古文学生僻字绝版合集.doc方框乱码:OLE2解析与Unicode建库
2026/9/18 0:45:18 网站建设 项目流程

简介:《古文学生僻字绝版合集》是一份面向古文爱好者、中文系学生、语文教师及传统文化研习者的生僻字速查文档,针对阅读《楚辞》等先秦两汉文献时字音不明、字义难解、语境隔膜的问题,按字条整理注音、释义与用例,便于随身查阅和系统记忆。压缩包内仅含1个doc文档,约35KB,篇幅精炼,适合打印或导入电子笔记软件,在备课、论文写作、经典精读时快速定位。文档收录阰、汩、茝、謇謇、侘傺、顑颔、鞿羁、纚纚等百余条目,覆盖山川水流、草木香草、情态心理、服饰车马、礼仪动作等古汉语高频难字,并涉及通假、异体与楚地方言用法;每条以简短释文呈现,可帮助建立字词与篇章语境的对应关系。已有106人学习,适合作为《楚辞》选读、古代汉语课程和古文背诵的辅助材料,也可用来查漏补缺、提升训诂与鉴赏能力。

1. 古文学生僻字绝版合集.doc 打开全是方框,问题不在文件损坏

一份「古文学生僻字绝版合集.doc」双击打开,满屏方框和豆腐块,换一台装过大字库的机器又好了。这不是文件坏了,而是三层问题叠在一起:Word 97-2003 用的是 OLE2 复合文档,正文按 UTF-16LE 分片存放,同时允许把 8 位字节按 cp1252 压缩存储;早年古籍录入软件遇到字库里没有的字,常常直接扔进私用区(U+E000–U+F8FF),或者只留一个「造字」字形引用。同一份合集,在 A 机器显示正常,在 B 机器全是方框,丢进文本编辑器又变成乱码,原因就在这。

真正麻烦的不是「打开」,而是「可检索、可校对、可迁移」。要把几千个扩展 B 区的生僻字原样抽出来,知道每个字落在哪个码位、属于哪个 Unicode 区、有没有异体关系和 IDS 拆解,最后建一份能按字、按部首、按上下文查的知识库。做古籍数字化、语料清洗、中文信息处理的人,以及需要把这类老资料接进后端的 IT 从业者,踩的都是同一批坑。下面按抽字、定位、建索引、校验字体四步推进。

2. 从 .doc 二进制里把生僻字原样抽出来

2.1 先确认这份绝版合集是 OLE2 还是 OOXML

老 .doc 的头部魔数是D0 CF 11 E0 A1 B1 1A E1,而.docx是 ZIP 容器,以PK\x03\x04开头。名字叫 .doc、内容其实是 OOXML 的情况很常见,先分清楚再选工具,否则后面解析会全线跑偏。

file "古文学生僻字绝版合集.doc" # 输出含 Composite Document File V2 -> 走 OLE2 分支 xxd -l 16 "古文学生僻字绝版合集.doc" # 00000000: d0cf 11e0 a1b1 1ae1 0000 0000 0000 0000

两条命令的判断逻辑很简单:file靠魔数给类型,xxd直接看前 16 字节确认,避免扩展名骗人。

2.2 快速通道:LibreOffice 无头转换的两个关键参数

不想写解析代码时,LibreOffice 的无头模式是最省事的入口,但转换的 filter options 必须显式指定编码,否则落盘编码跟随系统 locale,生僻字会在这一步被替换成?

# 转纯文本,强制 UTF-8 输出 soffice --headless --convert-to "txt:Text (encoded):UTF8" \ --outdir out "古文学生僻字绝版合集.doc" # 再转一份 docx,后续用 XML 工具处理更容易 soffice --headless --convert-to docx \ --outdir out "古文学生僻字绝版合集.doc"

参数说明:--headless不开界面,适合批处理;--outdir指定输出目录,同名文件会被覆盖;txt:Text (encoded):UTF8中的Text (encoded)是写入器名称,UTF8是传给它的编码参数。转完先别急着用,统计一下有多少字被吃掉了:

import collections s = open('out/古文学生僻字绝版合集.txt', encoding='utf-8').read() pua = collections.Counter(ch for ch in s if '\ue000' <= ch <= '\uf8ff') print('私用区字符总数:', sum(pua.values())) print('最常见的私用区字:', pua.most_common(10)) print('替换符 U+FFFD 个数:', s.count('\ufffd'))

私用区数量大,说明原文档大量使用造字,光靠转换救不回来,必须回到二进制层面看字形引用;替换符多,说明转换环节的编码判断错了,换 filter options 重来。

2.3 精度通道:读 WordDocument 流与分片表

OLE2 里的.doc由多个流组成,正文在WordDocument流,分片表(piece table)在0Table1Table流。分片表告诉解析器:第几段文字存在哪个偏移,是 16 位还是 8 位编码。生僻字是否被正确读出,全看这张表。

偏移(WordDocument 流)字段含义
0x0000wIdent固定魔数 0xA5EC
0x0002nFib版本标识,老 Word 为 0x00C1
0x000Aflags第 9 位 fWhichTblStm,决定读哪个 Table 流
0x01A2fcClx分片表在 Table 流中的起始偏移
0x01A6lcbClx分片表长度,单位字节
import struct import olefile def extract_doc_text(path: str) -> str: ole = olefile.OleFileIO(path) wd = ole.openstream('WordDocument').read() flags = struct.unpack_from('<H', wd, 0x000A)[0] tbl_name = '1Table' if (flags >> 9) & 1 else '0Table' # fWhichTblStm tbl = ole.openstream(tbl_name).read() fc_clx, lcb_clx = struct.unpack_from('<II', wd, 0x01A2) clx = tbl[fc_clx: fc_clx + lcb_clx] # 跳过可选的 Prc 块(0x01 开头,后跟 2 字节长度) i = 0 while i < len(clx) and clx[i] == 0x01: cb = struct.unpack_from('<H', clx, i + 1)[0] i += 3 + cb assert clx[i] == 0x02, '没有 Pcdt,文件可能加密或结构异常' lcb = struct.unpack_from('<I', clx, i + 1)[0] plc = clx[i + 5: i + 5 + lcb] n = (len(plc) - 4) // 12 # n 个 PCD + (n+1) 个 CP cps = struct.unpack_from('<%dI' % (n + 1), plc, 0) out = [] for k in range(n): off = 4 * (n + 1) + 8 * k fc = struct.unpack_from('<I', plc, off + 2)[0] compressed = bool(fc & 0x40000000) # 第 30 位:8 位压缩存储 fc &= 0x3FFFFFFF if compressed: fc //= 2 raw = wd[fc: fc + (cps[k+1] - cps[k])] out.append(raw.decode('cp1252', errors='replace')) else: raw = wd[fc: fc + 2 * (cps[k+1] - cps[k])] out.append(raw.decode('utf-16-le', errors='replace')) text = ''.join(out) return (text.replace('\r', '\n').replace('\x07', '\t') .replace('\x0b', '\n'))

逻辑说明:先读出fWhichTblStm决定读哪个 Table 流;再从 CLX 里剥掉可选的 Prc 块,拿到 Pcdt;PlcPcd 的结构是 (n+1) 个 4 字节字符位置加 n 个 8 字节 PCD。PCD 里第 2 到第 5 字节是文件偏移,第 30 位是压缩标志,压缩时偏移要除以 2,因为 8 位字符两两挤在一个 16 位槽里。

参数上要注意的是errors='replace':先用替换符暴露问题,别一上来就ignore,否则丢字悄无声息。控制字符里\r是段落标记,\x07是表格单元格,\x0b是软换行,按上面这行统一映射后,才适合入库。

3. 生僻字的 Unicode 定位与规范化

3.1 一张表看清生僻字落在哪些码位

抽出来的字要先分区,才知道该用什么字体、用什么输入法、能不能被后端正确存储。

区段码位范围典型字例常见坑
基本区U+4E00–U+9FFF中、龘字库覆盖最全
扩展 AU+3400–U+4DBF㐀、㑳老输入法常打不出
扩展 BU+20000–U+2A6DF𠀀、𡃁需代理对存储,字体缺字形
扩展 C–FU+2A700–U+2EBEF各期新增字覆盖字体更少
扩展 G、HU+30000–U+323AF近年新增多数系统字体没有
兼容表意U+F900–U+FAFFNFKC 会被合并到基本区
私用区U+E000–U+F8FF造字无标准含义,必须人工确认

私用区那一行最要命。老合集里的造字字符没有标准码位,只能靠上下文和底本人工比对,或者查原排版软件的映射表,机器无法自动还原。

3.2 Python 判定与规范化:NFC 是安全的,NFKC 会吃掉兼容字

import unicodedata def block_of(ch: str) -> str: cp = ord(ch) for lo, hi, name in [ (0x3400, 0x4DBF, 'ExtA'), (0x4E00, 0x9FFF, 'BMP'), (0xF900, 0xFAFF, 'Compat'), (0x20000, 0x2A6DF, 'ExtB'), (0x2A700, 0x2EBEF, 'ExtC-F'), (0x30000, 0x323AF, 'ExtG-H'), (0xE000, 0xF8FF, 'PUA'), ]: if lo <= cp <= hi: return name return 'Other' s = '𠀀㑳中' print([(ch, hex(ord(ch)), block_of(ch)) for ch in s]) print(unicodedata.normalize('NFC', s) == s) # True,NFC 对绝大多数生僻字无副作用 print(unicodedata.normalize('NFKC', '﨎')) # 输出 '禸',兼容字被合并

判定逻辑就是按码位区间打标签,供后面统计和筛选使用。参数选择上有一个硬规矩:入库用 NFC,不要用 NFKC。NFKC 会把兼容表意文字合并成基本区字形、把全角转半角,对古籍对照是破坏性的。如果确实需要「同字归一」,单独建一张异体关系表,把合并规则显式写出来,可回滚。

另一个跨语言坑:JavaScript 里'𠀀'.length是 2,因为按 UTF-16 代理对计数。遍历生僻字要用for (const ch of s)Array.from(s),否则一个字会被切成两半,检索和统计全错。

3.3 从 Unihan 拿笔画、部首和异体关系

Unihan 数据库按字段拆成若干文本文件,解析后可以给每个字补上结构化属性。

字段所在文件用途
kTotalStrokesUnihan_IRGSources.txt笔画数,用于按笔画排序
kRSUnicodeUnihan_IRGSources.txt部首序号加剩余笔画
kSimplifiedVariant / kTraditionalVariantUnihan_Variants.txt简繁对应
kSemanticVariantUnihan_Variants.txt语义异体,古籍校勘常查
def load_total_strokes(path='Unihan_IRGSources.txt') -> dict: strokes = {} with open(path, encoding='utf-8') as f: for line in f: if line.startswith('#') or '\tkTotalStrokes\t' not in line: continue cp, _, val = line.rstrip('\n').split('\t') first = val.split(' ')[0] # 多值时取第一个 strokes[int(cp[2:], 16)] = int(first) return strokes st = load_total_strokes() print(st.get(0x20000), st.get(0x4E2D))

说明:Unihan 的字段值可能用空格分隔多个数字,取第一个是常见做法;int(cp[2:], 16)U+20000转成整数码位。有了笔画和部首,合集就能按「部首+笔画」排成传统字书顺序,也能让不会读音的生僻字按字形被找到。

3.4 用 IDS 描述序列给缺字留兜底

遇到字体完全没字形的字,只存码位等于存了个黑洞。IDS(表意文字描述序列)用 U+2FF0 到 U+2FFB 这十二个描述符把一个字拆成部件,例如左右结构用 ⿰、上下结构用 ⿱,在检索时可以按部件反查。

IDC = {'⿰': '左右', '⿱': '上下', '⿲': '左中右', '⿳': '上中下', '⿴': '全包围', '⿵': '上三包围', '⿶': '下三包围', '⿷': '左三包围'} def describe(ids: str) -> str: if not ids: return '' return f"{IDC.get(ids[0], '其他')}结构: {ids[1:]}" print(describe('⿰米分')) # 左右结构: 米分

丢字时的兜底策略是:码位 + 部首笔画 + IDS 三样都存。码位保证机器可处理,部首笔画保证人工能按字形查,IDS 保证即使暂时没有字体也能描述结构。三者齐了,这份绝版合集才算真正有了可迁移的骨架。

4. 把合集做成可检索的知识库:SQLite FTS5 与 trigram 分词

4.1 建表:单字走 B-tree,上下文走 FTS5

生僻字检索有两类需求:按单字精确定位,以及按上下文词串反查出处。前者用普通索引就够,后者需要全文索引,而 SQLite 的 unicode61 分词器默认不切中文,必须换成 trigram。

PRAGMA journal_mode = WAL; CREATE TABLE IF NOT EXISTS guzi ( id INTEGER PRIMARY KEY, ch TEXT NOT NULL, -- 单个生僻字 cp TEXT NOT NULL, -- 形如 U+20000 block TEXT NOT NULL, -- Unicode 区名 strokes INTEGER, -- 笔画数 radical TEXT, -- 部首 source TEXT, -- 出自合集哪一篇 context TEXT -- 上下文片段 ); CREATE INDEX idx_guzi_ch ON guzi(ch); CREATE INDEX idx_guzi_cp ON guzi(cp); CREATE UNIQUE INDEX idx_guzi_uniq ON guzi(ch, source, context); CREATE VIRTUAL TABLE guzi_fts USING fts5( context, tokenize = "trigram" ); CREATE TRIGGER guzi_ai AFTER INSERT ON guzi BEGIN INSERT INTO guzi_fts(rowid, context) VALUES (new.id, new.context); END;

参数说明:journal_mode = WAL让批量导入时读写不互锁;tokenize = "trigram"把文本切成三字符滑窗,是 SQLite 里处理 CJK 子串检索最省事的方案。代价是查询词至少要三个字符,两个字的词得退回LIKE或另建前缀索引。

4.2 导入脚本:批量插入加上去重

import re import sqlite3 PAT = re.compile(r'[\u3400-\u4dbf\u4e00-\u9fff\uf900-\ufaff' r'\U00020000-\U0003ffff\ue000-\uf8ff]') def build_rows(text, source): rows = [] for m in PAT.finditer(text): ch = m.group() lo, hi = max(0, m.start() - 10), min(len(text), m.end() + 10) rows.append((ch, f'U+{ord(ch):04X}', source, text[lo:hi])) return rows conn = sqlite3.connect('guwen.db') cur = conn.cursor() text = open('out/古文学生僻字绝版合集.txt', encoding='utf-8').read() rows = build_rows(text, '绝版合集·卷第一') cur.executemany( 'INSERT OR IGNORE INTO guzi(ch, cp, source, context) VALUES (?,?,?,?)', rows) conn.commit() print('写入行数:', cur.rowcount)

逻辑说明:正则字符类只匹配目标码位段,避免把标点和拉丁字母也收进来;finditer配合前后各 10 个字符切片,是为了保留可读上下文,方便人工核对。INSERT OR IGNORE依赖前面的唯一索引,重复条目直接跳过,批量导入时可以放心重跑。

4.3 三种查询写法

-- 1) 单字精确查询,走 B-tree 索引 SELECT ch, cp, source, context FROM guzi WHERE ch = '𠀀'; -- 2) 上下文词串查询,走 FTS5 trigram,查询串需 >= 3 个字符 SELECT g.ch, g.source, snippet(guzi_fts, 0, '[', ']', '…', 12) AS 片段 FROM guzi_fts JOIN guzi g ON g.id = guzi_fts.rowid WHERE guzi_fts MATCH '祭祀' ORDER BY rank LIMIT 20; -- 3) 按 Unicode 区统计分布,判断字体需求 SELECT block, COUNT(*) AS 字数 FROM guzi GROUP BY block ORDER BY 字数 DESC;

第一条查询能命中索引,返回是毫秒级;第二条用snippet把命中位置标出来,rank是 FTS5 内置的相关度排序;第三条用于评估字体覆盖压力——如果扩展 B 占比过半,就必须准备扩展区字库,不能指望系统默认字体。

4.4 字体与显示:让扩展 B 的字别是豆腐块

页面上的方框是字体缺字形造成的,和数据库无关。解法是给生僻字单独指定一条回退链,并用unicode-range把回退字体限制在扩展区,避免整页字形风格被拉偏。

字体大致覆盖适合场景注意事项
思源宋体 / Noto Serif CJK基本区、扩展 A、部分扩展 B正文排版扩展 B 有缺口,必须配回退
花园明朝体扩展 B 及之后较全缺字补字形风格与宋体差异明显
BabelStone Han扩展区覆盖较广校对对照笔画偏细,放大看更清楚
全字库正宋体繁体与扩展区繁体古籍商用前确认授权
@font-face { font-family: "RareFallback"; src: local("Hanazono"), local("BabelStone Han"); unicode-range: U+20000-2FA1F; /* 只在扩展区生效 */ } .guwen-rare { font-family: "Source Han Serif SC", "Noto Serif CJK SC", "RareFallback", serif; line-height: 1.9; }

unicode-range是关键参数:没有它,浏览器会把整段文字交给回退字体渲染,正文风格立刻变味。另外别给生僻字加粗体,很多扩展区字体没有对应字重,浏览器会做伪粗体,笔画糊成一团,校对时看不清结构。

5. 进阶校验:用 fontTools 批量查缺字并出可用性报告

合集迁移的最后一步是确认「每个字真的能显示」。靠肉眼翻几千行不现实,直接读字体文件的 cmap 表比对最可靠。

from fontTools.ttLib import TTFont def covered(font_path: str) -> set: font = TTFont(font_path, lazy=True) # lazy=True 只加载需要的表,省内存 return set(font.getBestCmap().keys()) # 键是整数码位 chars = {ord(c) for c in open('rare_chars.txt', encoding='utf-8').read().strip()} cov = covered('/path/to/Hanazono.ttf') missing = sorted(chars - cov) print(f'待检 {len(chars)} 字,缺字形 {len(missing)} 字') print(''.join(chr(c) for c in missing[:80])) # 顺带报告私用区占比,这些字无法自动映射 pua = [c for c in chars if 0xE000 <= c <= 0xF8FF] print(f'私用区 {len(pua)} 字,需人工比对底本')

参数说明:lazy=True避免一次性解析全部表,几千字的字体文件加载只要几百毫秒;getBestCmap()会自动挑选平台和编码都合适的 cmap 子表,比手动翻font['cmap'].tables稳妥。缺少字形时不要只看总数,把前 80 个码位打出来,往往能看出规律——集中在某几个 Unicode 区,说明换一款字库就能补上一大片。

报告建议固定四个字段,方便多款字体横向比:

字段含义判读方式
待检字数合集去重后的字符数基准值,多份报告要一致
缺字形数字体 cmap 未覆盖的数量大于 0 就要配回退字体
私用区字数落在 U+E000–U+F8FF 的数量无法自动映射,必须人工确认
替换符数量文本里 U+FFFD 的个数大于 0 说明抽取环节已经丢字

替换符那一栏如果非零,别急着换字体,回到第 2 章重新走一遍分片表解析,问题出在抽取阶段而不是显示阶段。同一份合集换三款字库各跑一遍,取缺字形数的并集和交集,交集就是任何环境都显示不出来的硬骨头,优先人工处理;并集减去交集的部分,说明只是字库选择问题,配好回退链即可。最后把 missing 列表按码位排序生成一份 HTML 核对页,逐字比对底本字形结构,确认无误再合并进正式语料库。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询