托业词汇PDF解析:PyMuPDF切分、SQLite FTS5查词与Anki制卡
简介这份托业TOEIC词汇汇总PDF面向准备托业考试、商务英语学习及职场英语提升的读者按真实商务场景整理高频词汇解决备考中词表零散、主题不清的问题。内容按办公室事宜、人事及管理、业务拓展、财务及投资等模块编排每个词条给出英文与中文释义如appointment、attendance、calendar、directory、memo、overtime、applicant、authorize、bonus、merger、prospect、account、balance、bond、credit等涵盖行政、招聘、晋升、收购、融资等常见语境便于按主题集中记忆和考前查漏补缺。压缩包内仅含1个PDF文件约42KB体积轻巧手机、平板或电脑均可随时打开翻阅适合通勤、课间等碎片时间背诵。目前已有422人学习下载可作为托业词汇速记与商务英语词汇自测的便携清单帮助读者在有限时间内建立场景化词汇框架。1. 「托业TOEIC词汇汇总.pdf」当成数据源比当成阅读材料更有价值很多人拿到这份词表的第一反应是翻页背第二反应才是拆。翻页背的问题不是不努力而是检索成本太高想确认 abandon 在托业里常考哪个义项得靠印象去翻翻完也未必找得到。把它当数据源则是另一条路——抽单词、音标、词性、中文释义落成本地词库查询、去重、打乱、增量更新全都变成指令级的操作。门槛没有想象中高一份排版规整的词表pdfplumber 加十几行正则就能还原成结构化记录再灌进 SQLite 就得到可复用的底座。这条链路我按体检、切分、入库、卡片化、清洗归并五步走每步都给能跑的代码和要调的参数。2. 给「托业TOEIC词汇汇总.pdf」做体检文本层、字号与栏位动手写解析之前先花两分钟确认这份 PDF 属于哪种类型。判断错了后面几百行正则全是白写——扫描件里根本不存在可选中的文字正则一条都匹配不上。2.1 用 PyMuPDF 三行代码判断文本型还是扫描型import fitz # 安装包名是 pymupdf导入名是 fitz doc fitz.open(托业TOEIC词汇汇总.pdf) for i in range(min(3, doc.page_count)): text doc[i].get_text(text) # text 是纯文本模式另有 blocks、words print(i, len(text), repr(text[:80]))这段输出只看两个东西每页字符数以及开头那 80 个字符长什么样。如果前几页都返回空串或长度小于 50基本可以判定是扫描件或纯图片 PDF直接跳到 2.4 走 OCR如果每页几百到几千字符说明文字带文本层可以选中复制直接抽取就行。参数上get_text(text)保留阅读顺序get_text(words)返回带坐标的词框后者在做分栏时更准。注意page_count是全文档页数别拿它当循环上限去一次性读几千页内存会顶上去。2.2 用 pdfplumber 看字符坐标和字号分布import pdfplumber with pdfplumber.open(托业TOEIC词汇汇总.pdf) as pdf: page pdf.pages[0] chars page.chars # 每个字符带 x0 / x1 / top / bottom / size / fontname print(字符数:, len(chars)) print(字号分布:, sorted({round(c[size], 1) for c in chars})) print(字体分布:, sorted({c[fontname] for c in chars})) print(首行:, (page.extract_text() or ).split(\n)[0])字号分布是这份词表最有价值的一条线索。词表类 PDF 通常只有 2 到 4 种字号词头最大音标次之释义和例句最小。拿到这组值之后切词条就不用死磕正则可以先按size把字符分组再决定哪一组是词头。fontname同理粗体加大的往往就是词头。pdfplumber 比 PyMuPDF 慢但保留了完整的字符级元数据做版面还原时更顺手我一般用 PyMuPDF 做快速体检用 pdfplumber 做精细抽取。2.3 三个必看版面指标的常见取值指标观察方式词表 PDF 的常见取值用途字号chars里 size 去重词头 11-14pt释义 8-10pt区分词头与释义行距相邻top差值12-18pt判断一行是否属于同一条目左边界x0出现频次每栏一个聚簇判断单栏还是双栏页面宽高page.width/page.heightA4 约 595×842pt分栏阈值按宽度比例算把左边界做成直方图如果出现两个明显聚簇且第二簇的x0大于页面宽度的 45%基本就是双栏排版。双栏 PDF 的extract_text()会把左右栏按行交错拼在一起读起来是乱的必须先物理分栏再切词条。2.4 扫描件的兜底300 dpi 渲染加 OCR如果确认是扫描件先用 PyMuPDF 把页面渲染成图再交给 OCR 引擎import fitz doc fitz.open(托业TOEIC词汇汇总.pdf) for i in range(doc.page_count): pix doc[i].get_pixmap(dpi300, colorspacefitz.csGRAY) # 300 dpi 是 OCR 的常用下限 pix.save(fpng/page_{i:04d}.png) # 再把 png 目录交给 OCR 引擎语言包选英文为主、中文为辅engchi_simdpi300是关键参数低于 200 时小字号音标里的ə、ɪ这类字符错识率明显上升高于 600 收益很小还拖慢速度。colorspacefitz.csGRAY转灰度去掉了彩色噪声多数 OCR 引擎在灰度图上的版面分析更稳。引擎层面PaddleOCR 对中英混排效果好Tesseract 更轻量但需要手动调--psm词表这种多栏文本用--psm 6假定为单一文本块通常比默认值稳。OCR 结果一定会有错字音标里的重音符号最容易丢别指望零修正。3. 词条切分把连续文本还原成单词-释义对体检通过之后核心工作就是把一串文本切成headword / ipa / pos / def四元组。这步决定了整条链路的成色错一个正则后面查词就是错的。3.1 三种典型排版与对应策略排版形态特征抽取方法主要风险单栏词条式一行一个词词头加粗音标紧跟按行正则切释义换行被当新词条双栏词条式左右两栏各自独立排序先按 x0 分栏再按行切左右栏错行表格型单词、音标、释义分列extract_table()跨页表格丢表头例句缩进式词条下一行缩进给例句按缩进量归并到上一词条例句里的生词被误判判断属于哪一种最省事的办法是把第一页的extract_text()打出来看十行。单栏和表格一眼就能分辨双栏的标志是同一行的内容在语义上不连续。3.2 用正则识别词条边界import re # 典型词条abandon /əˈbændən/ v. 放弃, 抛弃 ENTRY re.compile( r^(?Pword[A-Za-z][A-Za-z\- ]{0,30}?) # 词头允许连字符和短语空格 r\s*(?:/(?Pipa[^/]{2,40})/)? # 可选音标两个斜杠之间 r\s*(?Ppos(?:n|v|vt|vi|adj|adv|prep|conj|pron|num|int)\.) # 词性缩写加点 r\s*(?Pdef.)$ # 剩余部分全部算释义 ) def parse_line(line: str): line line.strip() if not line: return None m ENTRY.match(line) return m.groupdict() if m else None逐段说明参数意图。词头部分用非贪婪{0,30}?是为了让后面的音标或词性优先匹配否则abandon v.这种没有音标的形式会把abandon v.整段吞进词头。音标用[^/]{2,40}限定在两个斜杠之间长度下限 2 是为了排除误匹配的单个符号上限 40 防止跨行抓取。词性枚举里vt、vi要写在v前面正则的交替是短路匹配顺序反了vt.只会被识别成v.加一个多余字符。释义用.一把抓走中文标点一律不做限制因为不同版本词表的顿号、逗号混用很常见。对匹配失败的行不要直接丢先落盘成unparsed.txt等全部跑完再统计占比。失败率超过 5% 说明排版假设有问题回去看 2.3 的指标。3.3 双栏与表格坐标分栏和 extract_tablefrom collections import Counter def column_starts(page, tol8): 把词的左边界取整后统计频次返回出现最多的两个栏位起点 xs [round(w[x0] / tol) * tol for w in page.extract_words()] return [x for x, _ in Counter(xs).most_common(2)] def split_columns(page): starts sorted(column_starts(page)) if len(starts) 2 or starts[1] - starts[0] page.width * 0.3: return [page] # 间距不够大按单栏处理 mid (starts[0] starts[1]) / 2 left page.crop((0, 0, mid, page.height)) right page.crop((mid, 0, page.width, page.height)) return [left, right]tol8是把横坐标归并到 8pt 的网格里字号越小这个值可以调小到 4避免把缩进的例句算成新栏。page.width * 0.3是双栏的最低间距判据两栏间距小于页面宽度三成时多半是单栏加缩进强行分栏会把释义切断。page.crop()返回的是新的 Page 对象原页面不受影响可以放心链式调用。表格型排版直接用page.extract_table()它会返回二维列表跨页表格记得把上一页最后一行的表头记下来下一页如果第一行不含表头文字就手动补上否则第一行数据会被当成表头丢掉。3.4 四类脏数据的清洗规则import re def clean(text: str) - str: text re.sub(r(\w)-\n(\w), r\1\2, text) # 跨行断词词表里极常见 text re.sub(r^\s*\d{1,4}\s*$, , text, flagsre.M) # 独立成行的页码 text text.replace(\u3000, ).replace(\xa0, ) # 全角空格和不换行空格 text re.sub(r[ \t]{2,}, , text) # 合并多余空白 return text.strip()第一条规则针对连字符断行abandon-\nment这类跨行拆分在窄栏 PDF 里几乎每页都有不合并就会凭空多出半个单词。第二条把整行只有数字的行去掉但要注意释义里也可能出现「第 1 版」这种数字所以用^...$锚定整行而不是全局删除数字。全角空格\u3000在中文排版里很常见不换成半角后面的正则里的\s*匹配会出偏差——实际上\s能匹配全角空格但split( )这类字符串操作不行统一归一化更稳。页眉页脚另一种处理方式是按坐标过滤先统计所有页面里top 60和top page.height - 60的行出现频次高的就是页眉页脚直接从抽取结果里剔掉。这比正则匹配文字内容更通用换一份词表也不用改规则。4. 入库与检索用 SQLite FTS5 给托业词表做本地查词接口切出几千条记录之后直接丢进 CSV 也能用但查一个词要遍历全表。落到 SQLite 加 FTS5 全文索引查询变成毫秒级还能按相关度排序这是把词表变成工具的关键一步。4.1 用一张主表承载词条的全部字段CREATE TABLE word ( id INTEGER PRIMARY KEY, headword TEXT NOT NULL, -- 词头统一小写存储展示时再还原 ipa TEXT, -- 音标可能为空 pos TEXT, -- 词性多个词性用逗号连接 sense_cn TEXT, -- 中文释义 example TEXT, -- 例句可能为空 freq_rank INTEGER -- 出现频次或词表顺序用于默认排序 ); CREATE UNIQUE INDEX idx_word_head_pos ON word(headword, pos);唯一索引建在(headword, pos)而不是单独的headword上因为托业词表里contract这类词会同时出现名词和动词两个条目只锁词头会把其中一个挤掉。freq_rank用词表原始顺序就够了不必额外算频率它的作用是让同样匹配的几条记录有个稳定顺序。4.2 FTS5 虚拟表与分词器的选择CREATE VIRTUAL TABLE word_fts USING fts5( headword, sense_cn, example, contentword, -- 外部内容表索引不重复存原文省空间 content_rowidid, tokenizeunicode61 remove_diacritics 2 );contentword是外部内容模式FTS5 只存索引不存正文几万条记录也就几 MB。unicode61是英文场景的默认选择按词切分remove_diacritics 2会把音标和拉丁字母上的变音符号折叠掉这样搜索resume也能命中résumé。如果要按中文释义做子串搜索unicode61没用因为中文没有空格分词。换tokenizetrigram可以按三字符滑窗建索引MATCH 放弃能直接命中代价是索引体积大约涨到原来三倍。我的做法是给释义单独建一张 trigram 表只在中文检索时走它。注意FTS5 的 trigram 分词器需要 SQLite 3.34 以上版本先跑SELECT sqlite_version();确认版本不够就只能退回LIKE %释义%几千条数据也扛得住。4.3 bm25 排序与词头权重加权SELECT w.headword, w.pos, w.sense_cn FROM word_fts f JOIN word w ON w.id f.rowid WHERE word_fts MATCH ? ORDER BY bm25(word_fts, 8.0, 1.0, 1.0) LIMIT ?;bm25()的三个数字是列权重依次对应headword、sense_cn、example。把词头权重给到 8是因为查abandon时用户要的就是词条本身而不是例句里恰好出现 abandon 的其它词。这个权重按经验给8 到 10 之间差别不大低于 3 就会出现大量例句误命中。前缀匹配用星号MATCH aban*能匹配到 abandon、abandonment 等一整族词做词根浏览时很顺手。LIMIT一定要加FTS5 在宽泛前缀下的候选集可能上万条不带限制会把结果集全物化出来。4.4 封装成命令行查词工具import sqlite3, argparse def search(conn, query, limit10): sql SELECT w.headword, w.pos, w.sense_cn, w.ipa FROM word_fts f JOIN word w ON w.id f.rowid WHERE word_fts MATCH ? ORDER BY bm25(word_fts, 8.0, 1.0, 1.0) LIMIT ? return conn.execute(sql, (query, limit)).fetchall() if __name__ __main__: ap argparse.ArgumentParser() ap.add_argument(word) # 查询词支持前缀 * ap.add_argument(--db, defaulttoeic.db) ap.add_argument(-n, typeint, default10) # 返回条数 args ap.parse_args() conn sqlite3.connect(args.db) for head, pos, sense, ipa in search(conn, args.word, args.n): print(f{head:18}{pos or :8}{ipa or :20}{sense})-n默认给 10 是因为终端一屏放得下超过这个数还不如导出文件。连接对象不要加check_same_thread之类的参数单进程 CLI 用不上反而容易在并发脚本里踩坑。真要做成常驻服务把conn换成每次请求新建SQLite 的连接开销很低。5. 从词表到复习卡片字段映射、apkg 生成与间隔参数数据入库解决的是「查」复习解决的是「记」。这一步把数据库里的行重新排成卡片字段再交给间隔重复算法去排期。字段顺序错了几千张卡片会全部串位这是最常见的翻车点。5.1 导入 Anki 的 CSV 字段约定import csv, sqlite3 conn sqlite3.connect(toeic.db) rows conn.execute(SELECT headword, ipa, pos, sense_cn, example FROM word ORDER BY freq_rank) with open(toeic_words.txt, w, newline, encodingutf-8) as f: f.write(#separator:Tab\n) # 显式声明分隔符 f.write(#html:true\n) w csv.writer(f, delimiter\t) for head, ipa, pos, sense, example in rows: front fb{head}/b back f{pos or } {sense}bri{ipa or }/i w.writerow([front, back, example or ])用制表符而不是逗号原因是中文释义里逗号、顿号、括号乱飞只要有一处没转义整行字段就会错位。第一行的#separator:Tab是给 Anki 看的元信息不写它默认按逗号切。#html:true让b、br这类标签真正渲染否则卡片正面会显示成一堆尖括号。5.2 用 genanki 直接生成 apkgimport genanki MODEL genanki.Model( 1707399001, # 固定 ID重复生成不会新建模型 TOEIC Word, fields[{name: Word}, {name: Ipa}, {name: Sense}, {name: Example}], templates[{ name: word-to-meaning, qfmt: div classw{{Word}}/divdiv classi{{Ipa}}/div, afmt: {{FrontSide}}hr idanswerdiv classs{{Sense}}/divdiv classe{{Example}}/div, }], ) deck genanki.Deck(1707399002, TOEIC::词汇) for head, ipa, pos, sense, example in query_all(): deck.add_note(genanki.Note(modelMODEL, fields[head, ipa or , f{pos} {sense}, example or ])) genanki.Package(deck).write_to_file(toeic.apkg)两个 ID 必须固定且互不相同模型 ID 负责卡片模板牌组 ID 负责牌组归属。ID 每次运行都变的话导入 Anki 会生成一堆重复模板。字段顺序必须和fields声明完全一致Word对应第一个、Ipa第二个错一位就会出现「音标显示成释义」这种诡异现象。空字段给空串而不是Nonegenanki对None的处理不友好。5.3 发音音频与例句的本地化需要带发音的话用本地 TTS 批量生成 mp3再用genanki.Package的媒体列表打包进去media [fmp3/{head}.mp3 for head in words] pkg genanki.Package(deck) pkg.media_files media # apkg 是 zip 结构音频会被一并塞进去 pkg.write_to_file(toeic.apkg)卡片模板里用[sound:{{Word}}.mp3]引用文件名必须和词头完全一致大小写敏感。几千个音频会让 apkg 涨到几百 MB同步到移动端会很慢实际使用中我只给高频词做音频把freq_rank排在前 2000 的词挑出来就够。5.4 SM-2 与 FSRS 的参数怎么设参数SM-2 常见取值FSRS 常见取值说明初始间隔1 天 / 10 分钟由模型给出托业冲刺期可压到 10 分钟难度因子2.5 起步无此参数SM-2 里每次评价加减 0.15 左右期望保留率无0.9降到 0.85 复习量约减三成最大间隔365 天按备考周期设 60-90 天备考半年内不需要长间隔毕业间隔1 天4 天连续答对后进入正式排期准备周期短的话把最大间隔压到 60 天让所有词在考前来得及被翻一遍比设 365 天更实用。期望保留率从 0.9 调到 0.85每天要复习的卡片数量大概降三成代价是遗忘率上升这个取舍按自己的可用时间定。6. 词形还原与同义合并的四个进阶技巧解析跑通之后剩下的问题基本都在数据质量上。这几条是我处理同类词表时反复用到的。6.1 词形归并要先做同形异义保护import re from collections import defaultdict def norm(s: str) - str: return re.sub(r[^a-z], , s.lower()) # 去掉连字符、空格、大小写差异 groups defaultdict(list) for r in query_all(): groups[norm(r[headword])].append(r) multi {k: v for k, v in groups.items() if len(v) 1} print(疑似重复:, len(multi))norm()把e-mail和email、drop out和dropout归到同一组方便人工确认。但归并之前一定要看pos字段contract的名词义是「合同」动词义是「收缩、订约」语义完全不同硬合并会把两个考点揉成一个。我的规则是只有pos也相同才自动合并pos不同的全部转人工。6.2 用集合差做增量更新词表更新时不要全量重跑把新旧两次解析结果做集合差old {norm(w) for w in load_headwords(toeic.db)} new {norm(w) for w in load_headwords(toeic_v2.db)} print(新增:, len(new - old), 删除:, len(old - new))新增词直接插入删除词不要物理删——把freq_rank置为负数标记为下线Anki 那边对应的卡片停用即可直接删记录会让已经建立的复习历史断掉。常见问题表现检测方式处理策略音标错位音标串到释义字段检查 ipa 是否含中文字符回退正则用斜杠数量定位短语被拆drop和out分成两条统计单词数占比白名单补短语表释义截断只有前半句检查释义末尾是否以逗号结尾向下合并下一行重复词条同词同词性出现两次(headword, pos)分组计数保留 freq_rank 小的6.3 释义相似度去重的阈值经验同一个中文释义经常挂在不同英文词下用字符级相似度筛一遍能抓到不少重复录入。做法是把sense_cn两两算相似度阈值给 0.85 以上才提示人工确认。阈值低于 0.8 误报会明显增多因为「放弃」和「抛弃」这类近义词本身相似度就高但它们是不同的考点不该合并。6.4 用覆盖率反查解析漏洞最后一步是拿解析出的词条数去反推漏抽。做法很简单统计原始文本里所有符合^[a-z]{2,}开头、后面紧跟词性缩写的行数和数据库里的记录数对比差值就是漏抽量。差值集中在某几个页码范围说明那几页排版特殊回去单独处理。这个方法比逐页肉眼检查快得多也能给出一份可信的完成度数字。真正决定这份词表好不好用的从来不是解析代码写得多漂亮而是headword、pos、sense_cn三列能不能和原书逐条对上以及漏抽的那几十条是不是被及时发现。本文还有配套的精品资源点击获取