这次不是把别人提取的 JSON 修修补补后再转成 MDX,而是分成了两个独立阶段: > **PDF → 重新识别词条结构 → 新 JSON → 生成 HTML 词条 → 编译 MDX/MDD** 下面按实际代码执行过程说明。 ## 一、我怎样从原书重新提取词条 ### 1. 从 PDF 第 536 页重新开始读取 词典正文从 PDF 第 536 页开始,一直处理到第 4856 页,共 4321 页。 实际使用的是 PyMuPDF: ```python page.get_text("dict", sort=True) ``` `sort=True` 用来尽量按照页面阅读顺序排列文字。 提取时没有只保存纯文本,而是保留每个层级的信息: ```text 页面 文本块 block 文本行 line 文字片段 span 文字内容 字体 字号 颜色 坐标 bbox ``` 因此能知道某段文字: * 在哪一页; * 位于页面左侧还是右侧; * 是蓝色还是黑色; * 字号多大; * 是正文、词头还是缩进备注。 这也是新 JSON 文件明显大于旧 JSON 的主要原因。 --- ### 2. 先识别“新词条从哪里开始” 这是整个提取过程中最关键的一步。 原书不是用普通文本结构存储词条,而是通过视觉格式区分: * 蓝色大写:普通词条; * 蓝色小写:变位、阴阳性形式等话语形式; * 黑色大写:专有名词; * 蓝色罗马数字:大义项; * 蓝色阿拉伯数字:具体义项; * `▶`:重要词或重要义项; * 缩进小字:`REM.`、假朋友等备注; * 圈号:同形词编号。 所以程序不是简单地认为“看见蓝色就是新词条”,而是综合判断: ```text 颜色 字号 横坐标 是否位于一行开头 前面是否只有 ▶ 或圈号 后面是否有音标、词性 是否出现 nom propre ``` 例如普通词头通常: * 接近左边界; * 是蓝色; * 字号约为 15 或 16.875; * 后面跟音标或词性。 专名则通常: * 是黑色大写; * 位于左侧; * 后面出现 `nom propre`; * 或者是一个简短的专名跳转条目。 这解决了旧版无法识别黑色专名的问题。 --- ### 3. 区分义项编号和缩写词头 旧程序中有一条很危险的规则: ```python if span["text"].endswith("."): 标记为 ``` 这样会把以下真正的词条当成数字: ```text B. P. P.-V. S. D. F. T. G. V. W.-C. ``` 新程序不再只看末尾有没有句点,而是判断: * 当前是否已经处于一个词条正文中; * 该片段后面是否紧跟音标; * 是否位于词条起始位置; * 是否符合词头字号和颜色; * 是 `1.`、`2.`、`I.`,还是一个真正的缩写。 例如: ```text 1. + 正文 ``` 会识别为义项编号。 而: ```text B. P. [bepe] n. f. ``` 会识别为缩写词条。 --- ### 4. 提取完整词头,而不是只抓一个 span 一个词头不一定只对应一个 PDF span。 例如: ```text ABANDONNÉ, ABANDONNÉE AUX ABOIS P.-D. G. Mme Mlle ``` 可能被 PDF 拆成多个文字片段,甚至上标和主体字号不同。 程序会从词头开始位置继续读取,直到遇到: * 释义标志; * 义项编号; * 正文圆点; * 明确的正文开始。 然后把属于同一个词头的片段重新组合起来。 同时保留多个形式: ```json "headwords": [ { "text": "ABANDONNÉ", "normalized": "abandonné" }, { "text": "ABANDONNÉE", "normalized": "abandonnée" } ] ``` 对于同形的阴阳性形式,也不会因为拼写一样就随意删掉。 --- ### 5. 提取词头行中的基本信息 确定词条范围以后,程序从词头行提取: ```text 词头 同形词编号 重要度箭头 音标 词性 性别 是否不变 动词变位表编号 形容词位置 ``` 例如: ```text ABÎMER [abime] verbe [conjugaison 1a] ``` 会得到: ```json { "headwords": [ { "text": "ABÎMER", "normalized": "abîmer", "search_keys": ["abîmer", "abimer"] } ], "pronunciations": ["abime"], "grammar": { "parts_of_speech": ["verb"], "conjugation": "1a" } } ``` 这里的无重音形式 `abimer` 只作为检索别名,不会替代正确拼写 `abîmer`。 --- ### 6. 给条目分类 新版 JSON 不再把所有内容都当成同一种词条,而是自动分类为: | 类型 | 数量 | 含义 | | ---------------------- | -----: | ---------- | | `lexeme` | 20,072 | 普通词汇条目 | | `discourse_form` | 638 | 变位、屈折或话语形式 | | `abbreviation` | 59 | 缩写 | | `grammar_note` | 40 | 语法说明型条目 | | `redirect` | 196 | 普通跳转条目 | | `proper_name` | 293 | 专有名词 | | `proper_name_redirect` | 9 | 专名跳转 | 总计 **21,307 条**。 例如: ```text aboie ``` 可以识别为话语形式,而不是与普通词汇条目完全等同。 ```text ZAGREB ``` 会识别为专名。 ```text B. P. ``` 会识别为缩写。 --- ### 7. 将正文 span 标记成不同 token 完整版 JSON 中,每个文字片段会进一步获得标签,例如: ```text headword pronunciation grammar importance homograph_number sense_group_number sense_number definition_marker register cross_reference antonym_open antonym_close phrase_or_label remark_text text ``` 例如原文: ```text ▶ ABANDONNER [abɑ̃dɔne] verbe [conjugaison 1a] 1. ... ``` 大致会变成: ```json [ {"tag": "importance", "text": "▶"}, {"tag": "headword", "text": "ABANDONNER"}, {"tag": "pronunciation", "text": "[abɑ̃dɔne]"}, {"tag": "grammar", "text": "verbe"}, {"tag": "conjugation", "text": "[conjugaison 1a]"}, {"tag": "sense_number", "text": "1.", "value": 1} ] ``` 这些 token 是后续继续拆例句、释义和固定搭配的重要基础。 --- ### 8. 建立初步义项结构 程序按照: * 罗马数字:`I.`、`II.`; * 阿拉伯数字:`1.`、`2.`; * 重要度箭头; * 反义词括号; * `→` 参照; * 大写固定搭配标签; 建立了初步的: ```json "sense_groups": [], "phrases": [], "cross_references": [], "antonyms": [] ``` 但这里必须说明清楚: > **目前只拆到了“义项层”,还没有可靠地把每个义项内部的释义和例句完全分开。** 现在通常仍是: ```json { "number": 1, "text": "释义。例句一。例句二。" } ``` 而不是已经完成: ```json { "definition": "释义。", "examples": [ "例句一。", "例句二。" ] } ``` 所以新版适合作为继续拆例句的基础,但还不是最终的例句数据库。 --- ### 9. 保留原文和来源位置 每个条目都保存: ```json "source": { "page_start": 543, "page_end": 543, "bbox_start": [76.992, 296.0, 534.811, 312.0], "section": "A" } ``` 完整版还保留每一行: ```json { "page": 543, "bbox": [...], "kind": "header", "tokens": [...] } ``` 这样发现错误时,可以准确返回 PDF 第几页、哪个位置检查,而不是只能在整本书里重新搜索。 --- ### 10. 生成两个版本的 JSON #### 完整版 `robert_structured_v2.json` 包含: * 原始行; * token; * 页码; * 坐标; * 原始文本; * 结构化字段; * 预生成 HTML。 主要用于: * 校对; * 拆例句; * 修正规则; * 回查 PDF。 #### 核心版 `robert_dictionary_core_v2.json` 去掉了大量逐行原始数据,只保留制作词典需要的字段。 主要用于: * 生成 MDX; * 导入数据库; * 制作网页词典; * 后续制作 Anki。 --- ## 二、怎样从新 JSON 生成 MDX ### 1. 按标准化主词头分组 MDX 中同一个检索词通常只能对应一个主页面,因此先按主词头分组。 例如多个: ```text FAUX FAUX ``` 如果是不同同形词,不会让后一个覆盖前一个,而是合并到同一个页面中,分成多个子词条显示。 本次结果: * 源条目:21,307; * 标准化主词头组:20,517; * 可阅读文章记录:20,378。 --- ### 2. 处理纯跳转条目 对于只有: ```text 某词 → 另一个词 ``` 的条目,生成 MDict 原生跳转: ```text @@@LINK=目标词 ``` 共有: * 成功解析的直接跳转:139; * 无法安全确认目标的跳转:34。 那 34 条没有强行跳转,而是保留为可阅读词条,避免跳错。 --- ### 3. 建立检索别名 每个词头可能生成多个检索形式,例如: ```text P.-D. G. p.-d. g. p-d-g pdg ``` 或者: ```text ABÎMER abîmer abimer ``` 这些别名都跳到同一个主词条。 另外还处理了原书中的 `*h aspiré` 标记。 例如原始数据可能是: ```text *HACHE ``` 实际检索词会改为: ```text hache ``` 页面中另加: ```text h aspiré ``` 标签,不把星号当成单词的一部分。 本次共生成 **11,987 条额外检索别名**。 --- ### 4. 将结构化字段渲染成 HTML MDX 中实际存储的不是 JSON,而是每个词条对应的一段 HTML。 页面结构包括: ```html

ABÎMER

[abime]
词性、变位、语域标签
...
``` `→` 参照会生成可点击链接: ```html → 目标词 ``` 同义参照和反义词也尽量生成内部跳转。 --- ### 5. CSS 单独打包到 MDD MDX 保存词条正文,MDD 保存资源文件。 CSS 设计包括: * 深蓝和红色主色; * 暖白正文背景; * 词头大字; * 音标、词性、性别、变位标签; * 圆形义项编号; * 重要词标识; * 备注和假朋友卡片; * 可点击参照; * 手机窄屏适配; * 深色模式; * 不依赖 JavaScript。 同时每个词条中保留一小段 fallback CSS。即使某些软件没有正确读取 MDD,基本排版也不会完全失效。 --- ### 6. 写入 MDict 源格式并编译 MDict 源文本的基本格式是: ```text 词头 HTML 内容 ``` 跳转条目则是: ```text 别名 @@@LINK=主词头 ``` 最后使用 `mdict-utils`: * 将词条源文本编译成 `.mdx`; * 将 CSS 资源打包成 `.mdd`。 最终 MDX 共包含: | 记录类型 | 数量 | | ----- | -----: | | 可阅读词条 | 20,378 | | 直接跳转 | 139 | | 检索别名 | 11,987 | | 总记录 | 32,504 | 这里的 32,504 不是 32,504 个不同单词,因为其中包含大量检索别名和跳转。 --- # 三、和别人之前提取的 JSON 有什么区别 最根本的区别是: > **旧 JSON 是“显示型数据”,新 JSON 是“词典型数据”。** ## 结构对比 | 项目 | 之前的 JSON | 重新提取的 JSON | | ---------- | ---------------- | ----------------------- | | 条目数 | 20,980 | 21,307 | | 基本结构 | `head` + `xml` | 多层结构化字段 | | 提取基础 | 主要依靠 block、颜色和字号 | 行、span、颜色、字号、位置、上下文联合判断 | | 条目类型 | 不区分 | 7种类型 | | 专名 | 黑色专名容易漏掉或粘连 | 单独识别 | | 缩写 | 以句点结尾时容易当成编号 | 根据位置和上下文识别 | | 同形词 | 只表现为同名记录 | 有编号、稳定 ID,可合并显示 | | 义项 | 全部混在 XML 中 | 已按罗马数字和阿拉伯数字初步分层 | | 音标 | 混在 XML 文字里 | 独立字段 | | 词性和性别 | 混在 XML 里 | 独立字段 | | 语域 | 混在正文里 | 独立字段加 token | | 跳转 | 只是正文中的箭头 | 可生成 MDX 原生跳转 | | 反义词和参照 | 混在字符串中 | 有初步独立列表 | | 页码和坐标 | 没有 | 有 | | 原始行和 span | 没有 | 完整版保留 | | 搜索别名 | 没有 | 自动生成 | | 适合直接显示 | 可以 | 可以 | | 适合拆例句和建数据库 | 很困难 | 明显更适合 | --- ## 四、旧 JSON 的实际工作方式 旧版每条大致只有: ```json { "head": [ "ABANDON" ], "xml": "ABANDON [abɑ̃dɔ̃] n. m. ..." } ``` 它的优点是简单: * 文件只有约 9.3 MB; * 很容易直接拼成一个粗略 MDX; * 大部分正文顺序得到保留; * 人直接阅读没有太大问题。 但是其中的 XML 标签非常有限: ```text ``` 问题在于这些标签并不对应稳定的语义结构。 例如 `` 既可能是: ```text 1. 2. I. II. ``` 也可能错误地包住: ```text B. P. W.-C. P.-V. ``` `` 既可能是真词头,也可能是备注中的蓝色标签: ```text FAUX-AMI ``` 所以旧 JSON 更接近: > “按照 PDF 颜色给文字加了几个标签的文本转储”。 而不是严格意义上的词典数据库。 --- ## 五、新版具体修复了哪些旧问题 ### 1. 恢复黑色专名 旧程序主要依靠: ```python span["color"] == 0xff ``` 识别蓝色词头。 但专名是黑色的,所以像: ```text ZAGREB ZAMBIE ``` 可能没有成为独立词条,而是接在前一个词条正文后面。 新版增加了黑色专名识别规则,因此得到: * 专名:293; * 专名跳转:9。 --- ### 2. 修复缩写被当成数字 旧版凡是以句点结束的 span,都可能成为 ``。 新版能够把: ```text B. P. A. Z. T. P.-V. S. D. F. T. G. V. W.-C. ``` 识别成缩写词条。 总共得到 59 条缩写类记录。 --- ### 3. 去掉错误的假朋友词头 旧版中有 10 条类似: ```json { "head": ["FAUX-AMI "], "xml": "FAUX-AMI espagnol ..." } ``` 这些不是实际词条,而是前一个词条下方的假朋友备注。 新版根据缩进、字号和备注块位置,把它们附加到所属词条的: ```json "false_friends": [] ``` 而不再建立名为 `FAUX-AMI` 的词条。 --- ### 4. 保留可追溯信息 旧版发现错误时,只能看到: ```json "head": ["ABÎMER"] ``` 但不知道来自 PDF 哪一页。 新版可以直接看到: ```text 第543页 起始坐标 所在字母区 逐行 token ``` 这对后续拆例句尤其重要,因为自动规则出现疑问时,可以准确回到原版核对。 --- ### 5. 为 MDX 和 Anki 提供了真正可用的字段 旧版要制作 Anki,只能从整段 XML 中重新猜: * 哪个是词头; * 哪个是音标; * 哪个是释义; * 哪个是例句; * 哪个是备注。 新版已经明确提供: ```text headwords pronunciations grammar registers sense_groups remarks false_friends phrases cross_references antonyms source ``` 虽然释义和例句仍需要进一步拆分,但至少已经知道它们属于: * 哪个词条; * 哪个词性; * 哪个义项; * 哪一页; * 哪个语域。 --- # 六、新版也不是“完美还原原始词典数据库” 这点需要明确。 PDF 中没有保存出版社原始的 XML 或数据库结构,因此这次仍然是基于版式和规则进行的逆向恢复,不可能保证每一个语义标签都百分之百正确。 目前较可靠的是: * 词条边界; * 词头; * 音标; * 词性; * 性别; * 变位编号; * 专名与缩写分类; * 罗马数字和阿拉伯数字义项; * 页码和位置; * 明确的备注及跳转。 相对需要继续校对的是: * 大写片段究竟是固定搭配还是普通强调; * 某个 `→` 是严格同义词还是泛参照; * 一个义项内哪里结束释义、哪里开始例句; * 例句后的解释性改写; * 个别跨栏、跨页或 PDF 断词造成的空格问题。 所以二者的关系可以概括为: > 旧版适合“把书里的文字大致显示出来”;新版适合“继续建设真正的数字词典、MDX、数据库和 Anki 例句库”。