使用Python解析PDF提取《中华字经》数据:从文本提取到OCR纠错
简介《中华字经》全文及注释版汇编以单个PDF文件打包共一个文件大小约1.05MB整体精简、携带方便。内容为经典识字文本《中华字经》的完整原文和注释按序涵盖自然、人伦、历史、地理、科技、医学等众多领域四字一句、节奏规整适合识字教学、诵读积累及传统文化入门。全文分部分编排以“乾坤有序”开篇既有自然万象、家庭伦理也有朝代更迭、科技发明与医学常识重字少、韵脚密便于集中识字和记忆扩展。注释部分针对生僻字词和典故作了必要说明帮助读者理解字义与背景降低阅读难度PDF版式清晰可跨设备阅读或打印使用。目前已有370人浏览学习对希望借助韵文扩充词汇量、了解传统蒙学体系的教师、家长和自学者来说这份汇编是一份轻量实用的参考资料。1. 拿到《中华字经》PDF我的第一件事是把它拆成数据假设你手里有一份《中华字经》全文及注释版[汇编].pdf可能是从教育资源站下载的也可能是同事甩过来的。你不是要读它而是要把它变成程序能用的数据提取4000个不重复汉字对齐拼音、释义甚至拆成一张字卡表。这事听起来简单但PDF里全是坑——有的是扫描图片有的是字体嵌入缺失还有的注释和正文字号混在一起。这篇文章就讲我怎么用Python把这类PDF解析成结构化数据再验证它的完整性。如果你在做识字类应用、汉字NLP数据集或者只是被一堆PDF折腾过这篇能省你一下午排错时间。2. 先摸清PDF底细用PyMuPDF提取文本层与字体动手前得先确认PDF是文本型还是扫描型。我用PyMuPDF打开文件看页数和每页文本量。如果每页能抽出文本说明有文本层否则就是图片得走OCR。这一节从最基础的提取开始逐步处理字符错乱和字体映射。2.1 用fitz读取文件并统计基本信息import fitz # PyMuPDF doc fitz.open(中华字经全文及注释版汇编.pdf) print(页数:, doc.page_count) for page_num in range(min(3, doc.page_count)): page doc[page_num] text page.get_text(text) print(f--- Page {page_num1} ---) print(text[:200]) print(字符数:, len(text))逻辑doc是Document对象page_count给出总页数。get_text(text)提取纯文本不带排版信息。先看前三页确认是否有文本层。如果text为空说明该页是扫描图后面要走OCR。如果文本是乱码或者一串空格说明字体映射有问题同样需要考虑OCR分支。参数说明get_text的mode参数text返回纯文本blocks返回文本块带坐标dict返回详细结构。这里用text快速判断后面精细化处理会切到dict。2.2 字体缺失导致的全空白怎么定位有些PDF为了控制体积把中文字体子集化但没有嵌入ToUnicode映射。这时提取的文本可能是一堆空格或者乱码。我一般会检查页面字体信息from collections import Counter font_counter Counter() for page_num in range(doc.page_count): page doc[page_num] for block in page.get_text(dict)[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: font_counter[span[font]] len(span[text]) for font_name, count in font_counter.most_common(10): print(font_name, count)如果字体名一看就是内置的song或ming之类而提取不出汉字说明编码映射坏了。此时再折腾PyMuPDF意义不大直接转OCR来得快。这个判断可以放进自动化流程文本提取失败率超过5%就自动切换OCR处理。2.3 提取带坐标的文本过滤注释与正文《中华字经》正文是四字一句注释通常用小字或放在括号里。用get_text(dict)可以拿到每个span的字号和坐标按字号过滤出正文部分。比如正文14pt注释9pt那么设置阈值13page doc[0] for block in page.get_text(dict)[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: size span[size] if size 13: # 阈值按实际调整 print(span[text], size, span[bbox])这里span[size]是字体大小span[bbox]是坐标。过滤阈值多试几次。注意有些PDF的正文用了特殊缩放字体大小不是绝对的最好先输出几行人工看一眼再定阈值。2.4 按页拼接时容易被忽略的换行和标点问题提取出的文本经常因为分栏或竖排被拆成碎片。我习惯先把所有文本块按坐标排序再按y坐标聚类成行。下面这段代码把每页的文本块按y坐标聚集成行然后还原阅读顺序def extract_lines(page): blocks page.get_text(dict)[blocks] lines [] for block in blocks: if block[type] ! 0: continue for line in block[lines]: text .join(span[text] for span in line[spans]).strip() if text: lines.append((line[bbox][1], text)) # y坐标 lines.sort(keylambda x: x[0]) return [line[1] for line in lines]line[bbox][1]是线条左上角的y坐标排序后就能得到从上到下的顺序。遇到双栏页面这个简单排序会乱。我在实际处理中遇到过《中华字经》注释版是左右双栏的排版这时需要先统计所有x坐标的直方图找到栏间空档把文本分成左右两列再分别排序。如果忽略分栏拼接出来的句子会是穿插错乱的。2.4.1 分栏检测的简单实现统计这一页所有span的x中心点画出分布找到没有字分布的区间import numpy as np x_centers [] for block in page.get_text(dict)[blocks]: if block[type] ! 0: continue for line in block[lines]: for span in line[spans]: x0, y0, x1, y1 span[bbox] x_centers.append((x0 x1) / 2) hist, edges np.histogram(x_centers, bins20) print(hist, edges)如果直方图出现明显的两个峰中间有空的bin说明是双栏。分栏后再按y排序就能正确获取每栏内容。这套逻辑同样适用于四栏甚至三栏排版。3. 扫描版处理OCR识别《中华字经》的专属纠错思路如果PDF是扫描图片或者第2章的文本提取失败就轮到OCR上场了。通用OCR对生僻字和形近字识别率低而《中华字经》的特点是4000字不重复包含大量生僻字直接跑PaddleOCR会出不少错。我一般用双模型加规则纠错。3.1 先用PaddleOCR跑一遍输出带置信度的结果安装和基本调用from paddleocr import PaddleOCR ocr PaddleOCR(use_angle_clsTrue, langch) result ocr.ocr(page_101.png, clsTrue) for line in result: print(line)use_angle_clsTrue开启方向分类处理扫描倾斜langch使用中英文模型。第一次运行会下载模型注意paddlepaddle版本要与paddleocr匹配。输出结果里每个元素包含文本框坐标、识别文本和置信度。我先保存所有置信度低于0.9的行集中人工核对。实际处理时我经常遇到前几页是封面和目录所以OCR前要先裁剪出有效正文区域。3.2 利用四字一句的结构做后纠正《中华字经》全文是四字一句PDF排版也基本遵循。OCR结果如果一行不是4个字说明有错。我写了一个后处理函数结合常见形近字映射做纠正import re def correct_line(text): text re.sub(r\s, , text) if len(text) ! 4: return text # 留给其他规则 # 常见OCR误认映射只列举普通字形差异 mapping {未: 末, 戊: 戌, 土: 士} return .join(mapping.get(ch, ch) for ch in text)注意这个映射只能处理高频错误。更好的做法是拿《中华字经》的候选字表做约束。因为我们知道全文4000字不重复可以先OCR完所有页得到候选集合再结合字频统计。如果某个字在OCR结果中出现频率异常高而其他位置又缺字就很可能发生了替换。3.3 通用模型认不出生僻字时用模板匹配补一刀对于通用模型认不出的生僻字我见过两种常见做法。一是用chinese_cht模型识别繁体再转换为简体但《中华字经》用的是规范简化字生僻字很多是字形接近的常见字所以这种方法帮助有限。二是裁剪小图用KNN匹配模板库。具体做法是把每个汉字渲染成固定尺寸的灰度图用cv2.matchTemplate做相似度比较。这里给关键参数图片归一化为64x64使用cv2.THRESH_BINARY二值化阈值取OTSU匹配用cv2.TM_CCOEFF_NORMED阈值0.75以上才采纳这样做的问题是速度慢一页几十个字还好几百页就不行了。所以我把这条路设计成人机协同先用规则过滤大部分只有置信度低于阈值且不是常见形状组合的字才送模板匹配。3.4 OCR参数对照表参数推荐值说明use_angle_clsTrue处理扫描倾斜识别率提升明显langch简体中文若遇到繁体切换chinese_chtdet_db_thresh0.3文本检测阈值低一点能检测到淡字rec_batch_num6批量识别行数越高越快但显存占用大调det_db_thresh时注意调太低会把噪点当文本太高会漏掉浅色扫描。建议先跑10页看漏检率再定。所谓漏检率就是和人工数出的行数对比差多少行。3.5 用置信度筛选和人工复核的流程拿到OCR结果后我不会直接进入下一步而是先跑一个筛选脚本把所有置信度低于0.9的行抽出来输出到一个Excel文件同时附上对应的裁剪图片路径。这些行里大部分是生僻字或图片太脏。对于这些行人工处理速度远比调模型快。一本《中华字经》注释版通常几百页真正需要人工看的行数可能只有二三十行。这样做的收益是大规模重OCR很费时而人工精修少、见效快。4. 把全文与注释结构化正则切分拼音、释义和字形结构PDF解析和OCR只是第一步。标题里的“注释版”意味着每一句后面跟着拼音、释义甚至字源。《中华字经》的注释格式各家不一样常见的有三种句后括号内给拼音字下小字给释义或者是单独注释行。我用正则和坐标结合的方式把它们拆开。4.1 根据版式坐标分离正文和注释从第2章提取到的文本块已经有了每个span的坐标。一本典型的《中华字经》注释版版式是大字正文一句4字下面小字注释拼音和字义。用y坐标分层def split_main_and_annotation(page): lines extract_lines(page) # 第2章的函数 main_lines [] annotation_lines [] for y, text in lines: if text and len(text) 8: # 正文一般一行8字 main_lines.append((y, text)) else: annotation_lines.append((y, text)) return main_lines, annotation_lines这里len(text) 8是个粗判断因为正文一句4字两句连排就是8字。注释行通常包含拼音字母或括号也可以据此过滤。遇到一行4字的时候这个判断就不能依赖长度了得结合字号或字体。如果前面用get_text(dict)拿到了字号这里直接用字号分类更可靠。4.2 用正则解析拼音和释义假设解析出的一行正文是“乾坤有序”注释行是“乾坤(qián kūn) 天地”。我写正则提取import re pattern r([\u4e00-\u9fff])\s*[(]([a-züāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜ](?:\s[a-züāáǎàōóǒòēéěèīíǐìūúǔùǖǘǚǜ])*)[)]\s*(.*) match re.match(pattern, 乾坤(qián kūn) 天地) if match: word, pinyin, meaning match.groups() print(word, pinyin, meaning)这个正则需要连缀拼音字母并且支持多音节和重音符号。注意输入法生成的拼音可能用数字声调比如qian2 kun1那正则需要扩展成[a-zü][1-5]?。两套格式我都遇到过建议先统计一下注释里的拼音格式再选正则。统计方式很简单读几行注释打印出所有ASCII字母段看有没有数字1-5。如果拼音之间用空格那分组的(?:\s...)*就能用上如果是“qián,kūn”这种逗号分隔还得在分隔符里加[,]。4.3 建立汉字与拼音、释义的JSON数据集解析完成后我通常把结果整理成一个JSON数组每项包含[ { text: 乾坤有序, chars: [ {char: 乾, pinyin: qián, meaning: 乾坤天地}, {char: 坤, pinyin: kūn, meaning: } ] } ]生成代码import json def build_dataset(main_lines, annotation_map): dataset [] for text in main_lines: # 这里省略按字切分和查找注释的逻辑 dataset.append({text: text, chars: []}) return dataset with open(zhonghuazijing.json, w, encodingutf-8) as f: json.dump(build_dataset(main_lines, annotation_map), f, ensure_asciiFalse, indent2)ensure_asciiFalse是关键否则中文字符会被转成\u转义序列不利于后续直接查看和检索。如果数据集很大可以用indent4格式化否则占空间但我要做对比和人工复查所以保留可读性。4.4 注释解析失败时怎么找原因常见情况是正则写得不全。我一般把解析失败的行单独收集起来统计失败原因占比。比如有的拼音是“qián kūn”带空格有的却是“qian2kun1”不带空格。这时可以给正则加一个|分支而不是写一个无比复杂的模式。另外注释里可能混入句读符号比如“qián kūn天地也”那要先把中文逗号、句号过滤掉。我在项目里会先把所有注释行做一个预处理删除中文标点、全角空格再做正则匹配。4.5 不同注释格式的差异有些版本把释义放在每个字下面而不是整词后面。这种格式在PDF里表现为小字分散在正文下方。提取时需要把每个小字span的坐标与大字span的坐标匹配。方法是找一个“乾”字大字的bbox然后找所有y坐标大于大字底边且x坐标落在同一范围内的span这些就是对应注释。实现时注意PDF坐标原点在左上角还是左下角会影响判断PyMuPDF默认是左上角原点bbox的y值越大越靠下。5. 最后再补一步验证4000字完整性与导出数据全文解析完之后最要紧的是验证是不是真的覆盖了4000个不重复汉字有没有缺页、错字这个验证逻辑不复杂但不少刚做的人会忽略。我用三种方式交叉检查然后导出成SQLite。5.1 去重统计与常用字覆盖率把解析结果里所有汉字拼成一个字符串用set去重看数量是否接近4000。同时和《现代汉语常用字表》对比看覆盖了多少常用字all_chars .join(item[text] for item in dataset) unique_chars set(all_chars) print(总字数:, len(all_chars), 去重后:, len(unique_chars)) # 假设有个文件 common_chars.txt每行一个常用字 with open(common_chars.txt, encodingutf-8) as f: common set(f.read().strip()) print(常用字覆盖:, len(unique_chars common), /, len(common))如果去重后不是4000先别急着调算法。很多《中华字经》版本标注了“收录4000字”但实际可能有重复或异体字。比如“峰”和“峯”会被当成两个但数据里也许只用了其中一个。这时需要把“目标4000”和“实际解析数”的差异列出来人工确认差异字符。5.2 用字频和上下文做抽检抽出几个出现次数异常的字看它们在原文里的上下文from collections import Counter counter Counter(all_chars) hot_chars [ch for ch, count in counter.most_common() if count 10] print(高频字:, hot_chars[:20])《中华字经》每个字理论上只出现一次所以高频字就是OCR错误或注释没过滤干净。我遇到过“的”字出现几十次的情况一查是注释里的“目的”等词也被并进去了。解决办法是把注释行从正文中剔除或者在做字频统计前先只取正文部分。5.3 把数据导入SQLite供应用查询最后导出成SQLite表字段就三列id、char、pinyin。应用层按汉字查询拼音非常方便CREATE TABLE IF NOT EXISTS zijing ( id INTEGER PRIMARY KEY, char TEXT NOT NULL, pinyin TEXT, meaning TEXT );插入用sqlite3的executemany批量执行比逐条插入快很多。这样生成的文件可以拿去做背单词App、教学工具或者聊天机器人语料。你还可以把“原文句子”单独建表用外键关联到每个字方便做上下文检索。5.4 一个容易被忽略的坑PDF页序与阅读顺序不一致某些“汇编”PDF把目录、封面和正文混排甚至页码范围不连续。解析时如果只按页码顺序拼接出来的文本可能完全颠倒。我的办法是先解析出文本后做一次“是否以‘天地’或‘乾坤’开头”的检查——《中华字经》开头通常是“乾坤有序”如果第一章第一句不是这个就说明顺序错了需要按句子内容重排。这一步放到验证里能提前发现一大批问题。具体做法也很简单从解析结果里搜索“乾坤有序”的位置如果它不在第一个条目就把后续条目挪到前面。这样处理之后后续的链式解析比如字频统计才可信。本文还有配套的精品资源点击获取