feat: generalize mapped line joining
This commit is contained in:
@@ -14,7 +14,7 @@
|
|||||||
| 精确编辑执行器 | 校验快照、范围、原文、重复和冲突后原子应用一个批次 | 不判断项目业务语义 |
|
| 精确编辑执行器 | 校验快照、范围、原文、重复和冲突后原子应用一个批次 | 不判断项目业务语义 |
|
||||||
| `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 |
|
| `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 |
|
||||||
| `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 |
|
| `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 |
|
||||||
| `mapped_line_join()` | 按调用方提供的映射合并跨行片段 | 库内没有默认词表,不猜测未知词 |
|
| `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 |
|
||||||
| HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 |
|
| HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 |
|
||||||
|
|
||||||
一次运行会返回 `success`、`failed` 或 `unstable`:
|
一次运行会返回 `success`、`failed` 或 `unstable`:
|
||||||
@@ -40,7 +40,14 @@ python -m venv .venv
|
|||||||
.venv/bin/python -m pip install -e '.[dev]'
|
.venv/bin/python -m pip install -e '.[dev]'
|
||||||
```
|
```
|
||||||
|
|
||||||
运行时只依赖 Python 标准库,支持 Python 3.11 及以上版本。
|
核心运行时只依赖 Python 标准库,支持 Python 3.11 及以上版本。自动词典规则需要调用方明确安装并选择对应 extra:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python -m pip install '/path/to/mdpolish[lexical]' # pyspellchecker + Pyphen
|
||||||
|
python -m pip install '/path/to/mdpolish[frequency]' # wordfreq,体积和传递依赖更大
|
||||||
|
```
|
||||||
|
|
||||||
|
安装 extra 不会自动启用规则,也不会触发在线下载或改变精确/正则规则行为。
|
||||||
|
|
||||||
## 组装自己的流水线
|
## 组装自己的流水线
|
||||||
|
|
||||||
@@ -142,8 +149,16 @@ remove_marker = Modifier(
|
|||||||
|
|
||||||
## 通用修改器的严格边界
|
## 通用修改器的严格边界
|
||||||
|
|
||||||
`mapped_line_join()` 只使用调用方显式传入的三元组:左片段、右片段和最终文本。它只处理相邻物理行或中间恰好一个
|
`mapped_line_join()` 保留 `(left, right, replacement)` 三元组,也接受模块
|
||||||
同风格空行的情况,并检查 ASCII 词边界。
|
`mdpolish.modifiers.mapped_line_join` 中的不可变规则值。规则可以使用精确片段、两侧命名正则、只约束右侧的行尾正则,
|
||||||
|
或从行尾与行首自动生成 `JOINED` / `HYPHENATED` / `SPACED` 候选并查询显式选择的本地词典。自动规则不要求逐词维护
|
||||||
|
映射;精确规则和 `KeepLineJoinRule` 用于项目词、例外与否决。
|
||||||
|
|
||||||
|
调用方还要显式决定块范围、换行处理、冲突策略、大小写和 Unicode 规范化。默认区分大小写且不规范化;支持相邻行或
|
||||||
|
中间最多一个同风格空行,并可在一次提议内完成多行链式合并。保守词法扫描只正向识别段落、ATX 标题 continuation、
|
||||||
|
列表 continuation 和同深度引用;代码块、GFM pipe table、raw HTML table、混合候选行尾及无法确认的容器保持原文。
|
||||||
|
完整公共模型、选择流程和限制见
|
||||||
|
[`0009-generalized-mapped-line-join.md`](research-wiki/design/0009-generalized-mapped-line-join.md)。
|
||||||
|
|
||||||
`html_table_entity_unescape()` 只在严格完整的 `<td>` / `<th>` 文本中处理 `&lt;`、`&gt;` 和
|
`html_table_entity_unescape()` 只在严格完整的 `<td>` / `<th>` 文本中处理 `&lt;`、`&gt;` 和
|
||||||
`&amp;`。`html_table_layout()` 只调整严格单行表格的外层行布局,并保留标签、属性和单元格内容。
|
`&amp;`。`html_table_layout()` 只调整严格单行表格的外层行布局,并保留标签、属性和单元格内容。
|
||||||
@@ -174,7 +189,7 @@ research-wiki/
|
|||||||
|
|
||||||
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
|
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
|
||||||
- 自动规则发现、注册表或默认流水线;
|
- 自动规则发现、注册表或默认流水线;
|
||||||
- Markdown AST、完整 HTML parser 或新运行依赖;
|
- Markdown AST、完整 HTML parser 或必装的第三方运行依赖;
|
||||||
- artifact、报告、Web/桌面评审器;
|
- artifact、报告、Web/桌面评审器;
|
||||||
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
|
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
|
||||||
|
|
||||||
@@ -202,7 +217,7 @@ git status --short
|
|||||||
```
|
```
|
||||||
|
|
||||||
上述检查已于 2026-08-26 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 21 个源码和测试文件无问题,
|
上述检查已于 2026-08-26 在 Python 3.13.11 环境实际运行:Ruff 通过,mypy 检查 21 个源码和测试文件无问题,
|
||||||
pytest 共 104 项测试通过,`mdpolish-0.2.0-py3-none-any.whl` 构建成功。wheel 内容已单独检查,只包含通用 Python
|
pytest 共 168 项通过、2 项因本环境未安装可选词典 backend 而跳过,`mdpolish-0.2.0-py3-none-any.whl` 构建成功。
|
||||||
包、类型标记和包元数据,不包含项目规则、实验脚本、评审器或 Node.js 文件。
|
wheel 内容已单独检查,只包含通用 Python 包、类型标记和包元数据,不包含项目规则、实验脚本、评审器或 Node.js 文件。
|
||||||
|
|
||||||
`pyproject.toml` 声明的 Python 3.11 及以上为支持范围;本次结果不表示已经在每个受支持版本上完成兼容性验证。
|
`pyproject.toml` 声明的 Python 3.11 及以上为支持范围;本次结果不表示已经在每个受支持版本上完成兼容性验证。
|
||||||
|
|||||||
@@ -10,6 +10,13 @@ requires-python = ">=3.11"
|
|||||||
dependencies = []
|
dependencies = []
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
|
frequency = [
|
||||||
|
"wordfreq>=3.1.1,<4",
|
||||||
|
]
|
||||||
|
lexical = [
|
||||||
|
"pyphen>=0.18.1,<1",
|
||||||
|
"pyspellchecker>=0.9,<1",
|
||||||
|
]
|
||||||
dev = [
|
dev = [
|
||||||
"mypy>=1.15,<2",
|
"mypy>=1.15,<2",
|
||||||
"pytest>=8.3,<10",
|
"pytest>=8.3,<10",
|
||||||
|
|||||||
@@ -0,0 +1,762 @@
|
|||||||
|
# 0009:通用映射驱动的跨行片段合并
|
||||||
|
|
||||||
|
## 状态
|
||||||
|
|
||||||
|
已批准(2026-08-26)。
|
||||||
|
|
||||||
|
`supersedes: 0008`(范围有限):本文替代 `0008` 第 6.1 节中“保留当前左片段、右片段、结果文本算法”
|
||||||
|
的窄接口,并为调用方显式启用的词典规则放宽“运行时只依赖标准库”这一点。核心安装仍然零依赖;精确和正则规则
|
||||||
|
仍然只处理内存字符串。词典规则只允许在构造修改器时读取已安装 optional package 自带的版本化词典资源,
|
||||||
|
不读取调用方文档、任意路径或网络。`propose()` 仍然是只读快照的纯函数。
|
||||||
|
|
||||||
|
`0008` 确定的函数式 `Modifier`、无默认启用行为、无项目固定词表、无网络和无模型推断等边界继续有效。
|
||||||
|
|
||||||
|
本文批准后才能实施。批准本文不等于批准提交、推送、创建 PR、发布、修改其他仓库或处理真实材料。
|
||||||
|
|
||||||
|
## 1. 问题与可观察现象
|
||||||
|
|
||||||
|
当前 `mapped_line_join()` 只接受 `(left, right, replacement)` 三元组。它能处理调用方已经确认的少量精确断词,
|
||||||
|
但遇到下列情况时,调用方只能复制整个修改器或在库外另写一套边界扫描逻辑:
|
||||||
|
|
||||||
|
- 同一类断词需要用命名正则捕获不同词干;
|
||||||
|
- 只需要匹配下一行开头,左侧只要求位于当前行尾;
|
||||||
|
- OCR 把一个词连续拆到三行以上,需要在一次提议中完成链式合并;
|
||||||
|
- 同一位置有多条规则,需要显式选择第一条、最高优先级或报错;
|
||||||
|
- 规则只允许用于段落、标题、列表项或引用,不能进入表格和代码块;
|
||||||
|
- 合并后需要保留换行、删除换行、换成空格或形成段落分隔;
|
||||||
|
- 文本和规则可能使用不同 Unicode 规范形式,或调用方明确需要忽略大小写。
|
||||||
|
|
||||||
|
更根本的问题是:如果每个 `example`、`international`、`database` 都要先手写一条映射,调用方实际上要自己维护
|
||||||
|
一份英语词典。成熟的通用方案应能从行尾和行首自动生成候选词,查询明确配置的词典或词频资源;精确映射只负责
|
||||||
|
项目术语、误判否决和其他例外,不应成为处理普通英文断词的唯一入口。
|
||||||
|
|
||||||
|
当前实现还会在整个物理行内容上匹配。它不知道 Markdown 围栏、缩进代码、pipe table、列表和引用前缀,
|
||||||
|
因此无法可靠表达“只在某类块中生效”。
|
||||||
|
|
||||||
|
这些都属于清洗语义、规则格式和冲突策略变化,不能作为 `0008` 已批准实现的机械扩展。
|
||||||
|
|
||||||
|
## 2. 调研结论及其边界
|
||||||
|
|
||||||
|
本次调研只用于确定库边界,不把外部工具行为直接变成 `mdpolish` 的默认规则。
|
||||||
|
|
||||||
|
### 2.1 Pandoc:软换行语义与源码重排是两件事
|
||||||
|
|
||||||
|
Pandoc 默认把段落内普通换行当作空格。`hard_line_breaks` 会把段落内每个换行解释为硬换行,
|
||||||
|
`ignore_line_breaks` 和 `east_asian_line_breaks` 则提供另外两种读取语义。输出侧的 `--wrap=auto|none|preserve`
|
||||||
|
决定生成源码怎样折行,不负责判断 OCR 断词。
|
||||||
|
|
||||||
|
当前 Pandoc 手册没有 `reflowed_text` 扩展。与“reflowed text”最接近的当前公共能力是输出侧 `--wrap`,
|
||||||
|
不能把这个非现行名称设计成库兼容目标。
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- <https://pandoc.org/MANUAL.html#paragraphs>
|
||||||
|
- <https://pandoc.org/MANUAL.html#extension-hard_line_breaks>
|
||||||
|
- <https://pandoc.org/MANUAL.html#option--wrap>
|
||||||
|
|
||||||
|
因此,本修改器不能把所有物理换行统一解释成一种语义。每条规则必须明确怎样处理命中的边界,代码块和表格等结构
|
||||||
|
必须先排除。
|
||||||
|
|
||||||
|
### 2.2 OCR 去连字符:成熟方案仍然需要证据来源和取舍
|
||||||
|
|
||||||
|
常见 OCR 后处理会组合以下信号:
|
||||||
|
|
||||||
|
| 信号 | 能解决的问题 | 不能单独保证的事情 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 词典查表 | 判断拼接词是否为已知词 | 专名、新词、领域词和真正带连字符的词 |
|
||||||
|
| 词缀与形态分析 | 识别屈折、派生和复合词 | 依赖语言及词典质量 |
|
||||||
|
| 词频或统计语言模型 | 在“连写、保留连字符、加空格”之间排序 | 结果依赖训练语料和领域分布 |
|
||||||
|
| OCR 置信度与版面元数据 | 利用识别器对字符、词或断词的显式证据 | 普通 Markdown 通常已经丢失这些信息 |
|
||||||
|
|
||||||
|
ALTO 可以用 `SUBS_TYPE=HypPart1/HypPart2` 和 `SUBS_CONTENT` 表达断词及完整词,并提供词置信度;hOCR 定义了
|
||||||
|
`x_wconf`。这说明如果上游仍持有结构化 OCR 证据,优先在上游使用它比从 Markdown 猜测更可靠。
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- <https://www.loc.gov/standards/alto/>
|
||||||
|
- <https://kba.github.io/hocr-spec/1.2/>
|
||||||
|
- <https://aclanthology.org/L18-1113/>
|
||||||
|
- <https://arxiv.org/abs/1204.0188>
|
||||||
|
|
||||||
|
本仓库当前只有 Markdown 字符串,没有 OCR 置信度、坐标或上游候选。因此本轮只实现调用方显式配置的本地词典判断
|
||||||
|
和统计打分,不实现依赖版面证据的判断或模型推断。词典结果是候选证据,不绕过歧义策略和精确覆盖规则。
|
||||||
|
|
||||||
|
### 2.3 Python 词典与断词库可以提供证据,但必须显式选择
|
||||||
|
|
||||||
|
| 工具 | 主要能力 | 本轮不直接集成的原因 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `wordninja` | 按 unigram 概率拆分粘连英文词 | 目标是拆词,不是恢复 Markdown 跨行结构;默认模型有语言和语料偏置 |
|
||||||
|
| `pyspellchecker` | 基于词频和编辑距离给出拼写候选 | 候选不是唯一正确修改,默认大小写和词典行为也需项目决定 |
|
||||||
|
| `PyEnchant` | 通过 Enchant provider 检查和建议拼写 | 依赖系统 provider 与外部词典,安装结果不完全由 Python 包锁定 |
|
||||||
|
| `wordfreq` | 查询多语言词频 | 能排序候选,但不能确定是否应保留自然连字符 |
|
||||||
|
| `PyHyphen` / `Pyphen` | 使用 TeX/Hunspell 断词模式找合法断点 | 正向排版断词不等于逆向 OCR 去断词 |
|
||||||
|
| Hunspell | 拼写检查、词缀、复合词和形态分析 | 需要语言词典;不同词典的形态字段和能力不同 |
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- <https://github.com/keredson/wordninja>
|
||||||
|
- <https://pyspellchecker.readthedocs.io/>
|
||||||
|
- <https://pyenchant.github.io/pyenchant/>
|
||||||
|
- <https://rspeer.github.io/wordfreq/>
|
||||||
|
- <https://pyhyphen.readthedocs.io/>
|
||||||
|
- <https://pyphen.org/>
|
||||||
|
- <https://github.com/hunspell/hunspell>
|
||||||
|
|
||||||
|
调研后的采用边界如下:
|
||||||
|
|
||||||
|
- `pyspellchecker` 具有随包分发的多语言词频词典,能够同时做精确 `known()` 查询和频率比较,适合作为第一种较轻的
|
||||||
|
自动词典 backend;不使用它的编辑距离纠错,因为本任务只在几个明确候选之间选择,不改写 OCR 字符;
|
||||||
|
- `wordfreq` 提供可比较的 Zipf 频率和更广的多语言数据,适合作为可选的频率 backend;它的数据主要截至 2021 年,
|
||||||
|
且官方明确说明多 token 查询会高估罕见组合,因此不能把任意短语分数当作可靠词典命中;
|
||||||
|
- `Pyphen` 能列出一个词的合法断词位置,适合作为“这个行尾位置是否可能是排版断词”的附加门槛,但它不能单独证明
|
||||||
|
拼接词真实存在;
|
||||||
|
- `PyEnchant` / Hunspell 能处理词缀、复合词和自然连字符,但依赖系统 provider 和外部词典,环境可复现性较差;
|
||||||
|
本轮记录边界但不实现该 backend;
|
||||||
|
- `wordninja` 的目标是把粘连字符串拆成多个词,方向与本任务相反,本轮不集成。
|
||||||
|
|
||||||
|
这些 backend 必须由 `LexicalLineJoinRule` 显式选择。缺少请求的 extra 时立即报错,不退回另一个词典,也不改成只靠
|
||||||
|
正则猜测。
|
||||||
|
|
||||||
|
### 2.4 Markdown lint 工具负责语法和风格,不负责 OCR 语义修复
|
||||||
|
|
||||||
|
`markdownlint` 的规则集中有行长、尾随空格、空行、标题和列表等结构/风格检查;`remark-lint` 的规则检查 mdast,
|
||||||
|
并允许项目自行编写插件。两者都没有“判断行尾连字符是不是排版断词并自动拼回”的内置规则。
|
||||||
|
|
||||||
|
这不是遗漏:lint 工具可以判断 Markdown 是否符合某种书写约定,却没有词典、原始页面或 OCR 置信度来唯一决定正文
|
||||||
|
应该怎样改。OCR 语义修复应由上游转换器、专用后处理器或项目明确规则负责。
|
||||||
|
|
||||||
|
来源:
|
||||||
|
|
||||||
|
- <https://github.com/DavidAnson/markdownlint/blob/main/doc/Rules.md>
|
||||||
|
- <https://github.com/remarkjs/remark-lint>
|
||||||
|
|
||||||
|
## 3. 目标与非目标
|
||||||
|
|
||||||
|
### 3.1 目标
|
||||||
|
|
||||||
|
- 保留旧三元组精确映射的调用方式;
|
||||||
|
- 增加不可变、带类型的精确规则和正则规则;
|
||||||
|
- 增加自动词典规则:从行尾、separator 和行首生成候选,查询显式配置的本地词典或词频资源;
|
||||||
|
- 让一条语言级规则可以复用于任意文档,不要求调用方逐词枚举普通英文;
|
||||||
|
- 所有规则仍由调用方显式传入,不内置词表或模式;
|
||||||
|
- 在一次 `propose()` 中处理独立命中和跨多行链式命中;
|
||||||
|
- 显式配置块范围、边界替换、冲突策略、大小写和 Unicode 规范化;
|
||||||
|
- 对代码块和表格失败关闭;
|
||||||
|
- 最终只产生绑定当前快照的非重叠精确 `TextEdit`;
|
||||||
|
- 规则、选项和顺序完整记录在 `Modifier.parameters` 中。
|
||||||
|
|
||||||
|
### 3.2 非目标
|
||||||
|
|
||||||
|
- 不实现自动语言识别、在线词典发现或模型调用;语言、backend、候选形式和阈值必须显式配置;
|
||||||
|
- 不读取 ALTO、hOCR、PDF、DOCX、图片或外部文件;
|
||||||
|
- 核心安装不引入 Markdown parser 或 OCR 库;词典能力只通过明确安装的 optional extra 启用;
|
||||||
|
- 不声称这个词法块分类器覆盖全部 CommonMark、GFM、Pandoc Markdown 或任意嵌套 HTML;
|
||||||
|
- 不自动选择规则、重排规则或循环运行整个 `Pipeline`;
|
||||||
|
- 不修改 `_text_ranges.py`;
|
||||||
|
- 不提供配置文件、CLI、默认映射或默认流水线。
|
||||||
|
|
||||||
|
## 4. 公共规则模型
|
||||||
|
|
||||||
|
新值类型均放在 `mapped_line_join.py`,使用 `@dataclass(frozen=True, slots=True)`。为避免扩大原始任务的源码范围,
|
||||||
|
它们先通过 `mdpolish.modifiers.mapped_line_join` 模块路径提供;本轮不要求修改 `modifiers/__init__.py`。
|
||||||
|
|
||||||
|
### 4.1 枚举
|
||||||
|
|
||||||
|
```python
|
||||||
|
class LineJoinBlock(StrEnum):
|
||||||
|
PARAGRAPH = "paragraph"
|
||||||
|
HEADING = "heading"
|
||||||
|
LIST_ITEM = "list_item"
|
||||||
|
BLOCK_QUOTE = "block_quote"
|
||||||
|
|
||||||
|
|
||||||
|
class LineBreakPolicy(StrEnum):
|
||||||
|
PRESERVE = "preserve"
|
||||||
|
DELETE = "delete"
|
||||||
|
SPACE = "space"
|
||||||
|
PARAGRAPH = "paragraph"
|
||||||
|
|
||||||
|
|
||||||
|
class LineJoinConflictPolicy(StrEnum):
|
||||||
|
PRIORITY = "priority"
|
||||||
|
FIRST = "first"
|
||||||
|
ERROR = "error"
|
||||||
|
|
||||||
|
|
||||||
|
class UnicodeNormalization(StrEnum):
|
||||||
|
NFC = "NFC"
|
||||||
|
NFD = "NFD"
|
||||||
|
NFKC = "NFKC"
|
||||||
|
NFKD = "NFKD"
|
||||||
|
|
||||||
|
|
||||||
|
class LexiconBackend(StrEnum):
|
||||||
|
SPELLCHECKER = "pyspellchecker"
|
||||||
|
WORDFREQ = "wordfreq"
|
||||||
|
|
||||||
|
|
||||||
|
class LexicalCandidateForm(StrEnum):
|
||||||
|
JOINED = "joined"
|
||||||
|
HYPHENATED = "hyphenated"
|
||||||
|
SPACED = "spaced"
|
||||||
|
|
||||||
|
|
||||||
|
class LexicalAmbiguityPolicy(StrEnum):
|
||||||
|
KEEP = "keep"
|
||||||
|
ERROR = "error"
|
||||||
|
```
|
||||||
|
|
||||||
|
默认可匹配块为四种 `LineJoinBlock` 的不可变集合。代码块和表格不是可选块类型,因为它们始终排除,调用方不能通过
|
||||||
|
普通规则意外打开。
|
||||||
|
|
||||||
|
### 4.2 精确规则
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ExactLineJoinRule:
|
||||||
|
rule_id: str
|
||||||
|
left: str
|
||||||
|
right: str
|
||||||
|
replacement: str
|
||||||
|
separator: str = ""
|
||||||
|
blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
|
||||||
|
line_break: LineBreakPolicy = LineBreakPolicy.DELETE
|
||||||
|
priority: int = 0
|
||||||
|
case_sensitive: bool = True
|
||||||
|
normalization: UnicodeNormalization | None = None
|
||||||
|
require_word_boundaries: bool = True
|
||||||
|
```
|
||||||
|
|
||||||
|
`separator` 是紧跟在 `left` 后、位于物理换行前的显式文本。它可以是 `"-"`,也可以是空字符串:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ExactLineJoinRule(
|
||||||
|
rule_id="example.dehyphenate",
|
||||||
|
left="exam",
|
||||||
|
separator="-",
|
||||||
|
right="ple",
|
||||||
|
replacement="example",
|
||||||
|
)
|
||||||
|
|
||||||
|
ExactLineJoinRule(
|
||||||
|
rule_id="example.join_split_word",
|
||||||
|
left="exam",
|
||||||
|
separator="",
|
||||||
|
right="ple",
|
||||||
|
replacement="example",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
这样不需要靠“`left` 是否碰巧以连字符结尾”推断规则类型。`replacement` 是完整的替换片段;库不会自动拼接
|
||||||
|
`left`、`separator` 或 `right`。
|
||||||
|
|
||||||
|
自动词典不可避免会遇到项目专名和自然连字符例外。为让映射表能明确否决而不是只能增加修改,再提供同样不可变的
|
||||||
|
精确保护规则:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KeepLineJoinRule:
|
||||||
|
rule_id: str
|
||||||
|
left: str
|
||||||
|
right: str
|
||||||
|
separator: str = ""
|
||||||
|
blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
|
||||||
|
priority: int = 0
|
||||||
|
case_sensitive: bool = True
|
||||||
|
normalization: UnicodeNormalization | None = None
|
||||||
|
require_word_boundaries: bool = True
|
||||||
|
```
|
||||||
|
|
||||||
|
它命中并赢得冲突选择后保留原文,不产生无效 `TextEdit`,同时阻止低优先级词典规则处理该边界。保护规则会进入
|
||||||
|
`Modifier.parameters`;由于没有实际修改,不伪造 `Change`。
|
||||||
|
|
||||||
|
### 4.3 左右正则规则
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class RegexLineJoinRule:
|
||||||
|
rule_id: str
|
||||||
|
left_pattern: str
|
||||||
|
right_pattern: str
|
||||||
|
replacement: str
|
||||||
|
separator: str = ""
|
||||||
|
blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
|
||||||
|
line_break: LineBreakPolicy = LineBreakPolicy.DELETE
|
||||||
|
priority: int = 0
|
||||||
|
case_sensitive: bool = True
|
||||||
|
normalization: UnicodeNormalization | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
- `left_pattern` 必须匹配左侧逻辑内容的非空后缀;
|
||||||
|
- `right_pattern` 必须匹配右侧逻辑内容的非空前缀;
|
||||||
|
- 捕获组必须命名,左右两侧的组名不能重复;
|
||||||
|
- `replacement` 只支持 `\g<name>` 命名反向引用,不支持数字组和动态回调;
|
||||||
|
- 非捕获组、lookaround 和普通正则语法可以使用,但最终左右匹配本身必须消耗字符;
|
||||||
|
- 正则按 Python `re` 语义编译,不启用 `MULTILINE` 或 `DOTALL`,因为物理行边界由修改器负责。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```python
|
||||||
|
RegexLineJoinRule(
|
||||||
|
rule_id="example.named_regex",
|
||||||
|
left_pattern=r"(?P<stem>[A-Za-z]+)",
|
||||||
|
separator="-",
|
||||||
|
right_pattern=r"(?P<suffix>[a-z]+)",
|
||||||
|
replacement=r"\g<stem>\g<suffix>",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.4 只指定右侧的行尾正则规则
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LineEndRegexRule:
|
||||||
|
rule_id: str
|
||||||
|
right_pattern: str
|
||||||
|
separator: str = ""
|
||||||
|
blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
|
||||||
|
line_break: LineBreakPolicy = LineBreakPolicy.DELETE
|
||||||
|
priority: int = 0
|
||||||
|
case_sensitive: bool = True
|
||||||
|
normalization: UnicodeNormalization | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
这类规则把左侧定义为逻辑行尾的零宽锚点。`right_pattern` 必须匹配右侧逻辑内容的非空前缀,但只作为边界条件;
|
||||||
|
右侧匹配文本不进入编辑范围,也不会被重新输出。实际编辑只覆盖可选 `separator`、物理行尾、允许的空行和右侧重复
|
||||||
|
块前缀:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LineEndRegexRule(
|
||||||
|
rule_id="example.line_end_and_right",
|
||||||
|
right_pattern=r"(?P<right>[a-z])",
|
||||||
|
line_break=LineBreakPolicy.SPACE,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
上例把 `foo\nbar` 变成 `foo bar`,而不是把空格放到 `b` 之后。规则不会猜测左侧单词,只明确表示
|
||||||
|
“当前逻辑行尾 + 下一逻辑行首命中该正则时怎样改”。它仍然是映射表规则,不是对所有小写行首自动生效的隐藏默认值。
|
||||||
|
|
||||||
|
`LineEndRegexRule` 不允许 `PRESERVE`,因为它不会改变 separator、边界或右侧文本,只会产生无效编辑。
|
||||||
|
|
||||||
|
### 4.5 自动词典规则
|
||||||
|
|
||||||
|
普通英文断词不要求逐词映射。调用方配置一条语言级规则,修改器从每个合格边界提取左右单词片段并自动查询本地词典:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class LexicalLineJoinRule:
|
||||||
|
rule_id: str
|
||||||
|
left_pattern: str
|
||||||
|
right_pattern: str
|
||||||
|
separator: str
|
||||||
|
backend: LexiconBackend
|
||||||
|
language: str
|
||||||
|
candidate_forms: tuple[LexicalCandidateForm, ...]
|
||||||
|
minimum_score: float
|
||||||
|
minimum_score_margin: float = 0.0
|
||||||
|
hyphenation_language: str | None = None
|
||||||
|
ambiguity: LexicalAmbiguityPolicy = LexicalAmbiguityPolicy.KEEP
|
||||||
|
blocks: frozenset[LineJoinBlock] = ALL_LINE_JOIN_BLOCKS
|
||||||
|
priority: int = 0
|
||||||
|
case_sensitive: bool = True
|
||||||
|
normalization: UnicodeNormalization | None = None
|
||||||
|
```
|
||||||
|
|
||||||
|
示例配置一次后可以复用于任意英文文档:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LexicalLineJoinRule(
|
||||||
|
rule_id="english.dictionary_dehyphenation",
|
||||||
|
left_pattern=r"[A-Za-z]{2,}",
|
||||||
|
right_pattern=r"[a-z]{2,}",
|
||||||
|
separator="-",
|
||||||
|
backend=LexiconBackend.SPELLCHECKER,
|
||||||
|
language="en",
|
||||||
|
candidate_forms=(
|
||||||
|
LexicalCandidateForm.JOINED,
|
||||||
|
LexicalCandidateForm.HYPHENATED,
|
||||||
|
),
|
||||||
|
minimum_score=1.0,
|
||||||
|
minimum_score_margin=0.5,
|
||||||
|
hyphenation_language="en_US",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
这条规则会自动处理 `exam-\nple`、`inter-\nnational` 等候选,不需要为 `example`、`international` 分别写映射。
|
||||||
|
规则表在这里表达的是“什么文本形态、用哪个词典、比较哪些候选、阈值多保守”,不是穷举英语单词。
|
||||||
|
|
||||||
|
左右 pattern 分别锚定逻辑行尾和行首,整个非空匹配就是候选片段。`separator` 本轮只允许 `"-"` 或 `""`。
|
||||||
|
修改器生成调用方列出的候选:
|
||||||
|
|
||||||
|
| 候选 | 输出 |
|
||||||
|
| --- | --- |
|
||||||
|
| `JOINED` | `left + right` |
|
||||||
|
| `HYPHENATED` | `left + "-" + right` |
|
||||||
|
| `SPACED` | `left + " " + right` |
|
||||||
|
|
||||||
|
规则必须包含 `JOINED`,并包含与源边界对应的竞争形式:`separator="-"` 时包含 `HYPHENATED`,空 separator 时包含
|
||||||
|
`SPACED`。这样“拼接词在词典中”不会自动压过同样合理的自然连字符或两个独立单词。
|
||||||
|
|
||||||
|
backend 行为:
|
||||||
|
|
||||||
|
- `SPELLCHECKER` 用 `SpellChecker.known()` 判断单 token 是否存在,并把 `word_usage_frequency()` 换算到每十亿词的
|
||||||
|
对数尺度。`SPACED` 或需要拆成多个单 token 验证的 `HYPHENATED` 只在各组成 token 分别已知时成立,准入分数取
|
||||||
|
各 token 的最低值;这个值不是短语频率,也不能与单 token 分数按 `minimum_score_margin` 排序;
|
||||||
|
- `WORDFREQ` 使用 `zipf_frequency(candidate, language)`;它可以评价多 token 字符串,但调用方必须考虑官方说明的
|
||||||
|
罕见组合高估问题,并通过 `minimum_score_margin`、精确否决规则和合成反例收紧;
|
||||||
|
- `hyphenation_language` 非空时,再用 Pyphen 检查 `len(left)` 是否在 `positions(left + right)` 中。这个门槛只作用于
|
||||||
|
`JOINED` 候选,不替代词典存在性和候选比较。
|
||||||
|
|
||||||
|
`minimum_score` 和 `minimum_score_margin` 是 backend 内部的阈值,不是跨 backend 的统一物理量。对 `WORDFREQ`,二者
|
||||||
|
都是 Zipf 尺度;对 `SPELLCHECKER`,`minimum_score` 可以过滤单 token 或各组成 token 中明显低频的候选,但
|
||||||
|
`minimum_score_margin` 只适用于两个都有可比单 token 分数的候选。调用方不能把同一个数值配置在两个 backend 间
|
||||||
|
直接搬用并期待相同含义。
|
||||||
|
|
||||||
|
选择流程固定为:
|
||||||
|
|
||||||
|
1. 为每个显式候选查询 backend,得到“是否存在”、准入分数和可选的可比分数;
|
||||||
|
2. 丢弃不存在、准入分数低于 `minimum_score` 或未通过可选 Pyphen 门槛的候选;
|
||||||
|
3. 没有候选时保留原文;
|
||||||
|
4. 只有一个候选时选择它;
|
||||||
|
5. 多个候选且所有候选都有同一尺度的可比分数时,只有存在唯一最高分,且它比第二名至少高
|
||||||
|
`minimum_score_margin` 才选择;
|
||||||
|
6. 存在不可排序候选、仍然并列或差距不足时,按 `ambiguity` 处理:`KEEP` 保留原文,`ERROR` 抛出包含
|
||||||
|
`rule_id` 和位置但不包含正文的错误;
|
||||||
|
7. 选中候选后,把左右片段和物理边界合成一个精确替换;若选中的是自然连字符或空格形式,也会删除物理换行,
|
||||||
|
但保留 `-` 或一个空格。
|
||||||
|
|
||||||
|
因此,`SPELLCHECKER + SPACED` 的两个单 token 最低频率只用于准入,不冒充短语频率。它与 `JOINED` 同时存活时属于
|
||||||
|
不可排序歧义,不能靠 margin 自动裁决。`SPELLCHECKER + HYPHENATED` 在 backend 只能按组成 token 验证时遵循同一规则。
|
||||||
|
|
||||||
|
因此,“拼起来查词典,命中就合并”是自动规则的基础,但不是唯一判断。至少还要让源形式参与竞争,并对多候选歧义
|
||||||
|
失败关闭。项目专名、缩写和已知例外仍可用更高优先级的精确/正则规则覆盖。
|
||||||
|
|
||||||
|
### 4.6 旧三元组兼容
|
||||||
|
|
||||||
|
以下调用继续有效:
|
||||||
|
|
||||||
|
```python
|
||||||
|
mapped_line_join((("exam-", "ple", "example"),))
|
||||||
|
```
|
||||||
|
|
||||||
|
旧三元组等价于一条精确规则:`left` 保持原值、`separator=""`、删除换行、区分大小写、不做 Unicode 规范化、
|
||||||
|
启用词边界并允许四种文本块。它不会被改写成词典或正则规则。
|
||||||
|
|
||||||
|
旧三元组没有 `rule_id`。工厂按声明位置为审计和冲突消息生成 `legacy.0000`、`legacy.0001` 等内部 ID;
|
||||||
|
因此旧映射的声明顺序也会进入参数记录。这里保留的是调用语法和精确映射能力,不承诺继续在代码块或表格中修改,
|
||||||
|
因为本文明确把这两类区域改为始终排除。
|
||||||
|
|
||||||
|
旧类型别名保留:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LineJoinMapping = tuple[str, str, str]
|
||||||
|
LineJoinRule = ExactLineJoinRule | KeepLineJoinRule | RegexLineJoinRule | LineEndRegexRule | LexicalLineJoinRule
|
||||||
|
```
|
||||||
|
|
||||||
|
工厂签名为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mapped_line_join(
|
||||||
|
mappings: Iterable[LineJoinMapping | LineJoinRule],
|
||||||
|
*,
|
||||||
|
conflict_policy: LineJoinConflictPolicy = LineJoinConflictPolicy.PRIORITY,
|
||||||
|
max_intervening_blank_lines: int = 1,
|
||||||
|
) -> Modifier:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
保留 `max_intervening_blank_lines=1` 是为了兼容当前“可以隔一个同风格空行”的已实现行为。调用方可以设为 `0`,
|
||||||
|
禁止跨空行。这里只接受类型严格为 `int` 的 `0` 或 `1`,`bool`、负数和 `2` 以上都拒绝。两个及以上连续空行更像
|
||||||
|
结构边界;在没有 OCR provenance、版面置信度或真实样本证据时,本轮不允许用一个任意整数把搜索扩大到更远段落。
|
||||||
|
如果后续证据表明某个上游稳定地产生多空行误断,应以有上限的新设计明确扩大范围,而不是静默放宽本参数。
|
||||||
|
|
||||||
|
## 5. 匹配与替换语义
|
||||||
|
|
||||||
|
### 5.1 逻辑内容和锚点
|
||||||
|
|
||||||
|
修改器仍使用 `_text_ranges.py` 的 `PhysicalLine` 和 `physical_lines()` 获取准确范围,不改变底层工具。
|
||||||
|
|
||||||
|
目标文件内新增保守的块扫描,把每条物理行分成:
|
||||||
|
|
||||||
|
- Markdown 容器/块前缀,例如 `> `、列表 marker 后的缩进或 `# `;
|
||||||
|
- 可匹配逻辑内容;
|
||||||
|
- 精确物理行尾。
|
||||||
|
|
||||||
|
左规则只在逻辑内容末尾匹配,右规则只在逻辑内容开头匹配。删除或替换物理边界时,右行为了表达同一块而重复的
|
||||||
|
引用前缀或 continuation 缩进一并按第 7 节处理,不会残留在拼接词中。
|
||||||
|
|
||||||
|
### 5.2 精确词边界
|
||||||
|
|
||||||
|
`require_word_boundaries=True` 时:
|
||||||
|
|
||||||
|
- 左匹配前一个字符不能是 Unicode 字母、数字或下划线;
|
||||||
|
- 右匹配后一个字符不能是 Unicode 字母、数字或下划线。
|
||||||
|
|
||||||
|
这替代当前只检查 ASCII 字母的窄规则。调用方确实要匹配词内片段时必须显式设为 `False`。正则规则不另加自动词边界,
|
||||||
|
调用方用正则本身表达边界。
|
||||||
|
|
||||||
|
### 5.3 空规则集和无命中
|
||||||
|
|
||||||
|
空映射仍创建合法的 no-op `Modifier`。没有规则命中时返回空提议,不修改换行、Unicode 或任何其他内容。
|
||||||
|
|
||||||
|
## 6. Markdown 块范围
|
||||||
|
|
||||||
|
本轮不引入 parser。目标文件内使用失败关闭的词法扫描,只为跨行候选提供最小结构边界。
|
||||||
|
|
||||||
|
扫描器内部必须有独立的 `UNKNOWN` 分类;它不是公共 `LineJoinBlock`,任何规则都不能选择它。`PARAGRAPH` 必须由
|
||||||
|
“普通文本行、容器前缀一致且两侧都没有未识别块 marker”正向识别,不能作为“不像其他块”的兜底。`EXCLUDED`
|
||||||
|
用于已确认的代码和表格范围,`UNKNOWN` 用于 marker 损坏、容器深度不一致、缩进关系不足或方言结构无法确认的范围;
|
||||||
|
两者都不产生候选。
|
||||||
|
|
||||||
|
### 6.1 始终排除
|
||||||
|
|
||||||
|
- backtick 或 tilde fenced code block,包括 fence 行;
|
||||||
|
- 保守识别的四空格或 tab 缩进代码;
|
||||||
|
- 有 delimiter row 的 GFM pipe table 整个连续区域;
|
||||||
|
- 从 `<table` 开始到配对 `</table>` 的 raw HTML table 区域;不完整 opening 从该处排除到文档末尾。
|
||||||
|
|
||||||
|
围栏识别支持至多三个前导空格、相同 fence 字符和不短于 opening 的 closing。它不解释 fence 内语言。
|
||||||
|
|
||||||
|
### 6.2 可选块
|
||||||
|
|
||||||
|
| `LineJoinBlock` | 词法范围 |
|
||||||
|
| --- | --- |
|
||||||
|
| `PARAGRAPH` | 不属于其他支持块的连续普通文本行 |
|
||||||
|
| `HEADING` | ATX heading 的内容及其紧邻、没有新块 marker 的 OCR continuation;Setext underline 本身不参与匹配 |
|
||||||
|
| `LIST_ITEM` | list marker 行与达到该 item 内容列的 continuation;遇到下一个同级 marker 或块边界结束 |
|
||||||
|
| `BLOCK_QUOTE` | 具有相同 quote depth 的普通引用内容;引用内的 heading/list 使用更具体的块类型 |
|
||||||
|
|
||||||
|
块类型取最内层可识别类型。例如引用中的列表项是 `LIST_ITEM`,引用中的普通段落是 `BLOCK_QUOTE`。
|
||||||
|
|
||||||
|
Markdown 允许 lazy continuation,标题后也允许紧接普通段落;仅靠文本无法总是区分这些情况。因此:
|
||||||
|
|
||||||
|
- 规则映射仍是决定修改正确性的主要证据;
|
||||||
|
- 块分类只缩小规则适用范围,不证明词语一定应该合并;
|
||||||
|
- 无法确认同一块、容器深度不一致或前缀损坏时归入 `UNKNOWN`,不回退成 `PARAGRAPH`,也不提出修改;
|
||||||
|
- 合法且前缀对称的多层引用或嵌套列表可以正向识别;深度、item content column 或 continuation 缩进不一致时
|
||||||
|
失败关闭。`> > >` 和多级有序列表本身不自动等于损坏语法,测试必须同时固定合法嵌套和损坏变体。
|
||||||
|
|
||||||
|
## 7. 换行策略
|
||||||
|
|
||||||
|
一个候选的“原始边界”包括左物理行尾、允许的空行,以及右侧重复的块前缀。精确和左右正则规则先把左右匹配片段
|
||||||
|
变成 `replacement`,再把所选边界输出放在 replacement 之后、右侧未匹配内容之前。行尾正则规则不消费右侧匹配,
|
||||||
|
只按下表替换原始边界:
|
||||||
|
|
||||||
|
| 策略 | 输出 |
|
||||||
|
| --- | --- |
|
||||||
|
| `PRESERVE` | 保留原始物理行尾、空行和右侧块前缀 |
|
||||||
|
| `DELETE` | 删除整个边界和右侧重复前缀 |
|
||||||
|
| `SPACE` | 用一个 ASCII 空格替换整个边界和右侧重复前缀 |
|
||||||
|
| `PARAGRAPH` | 用左物理行尾样式生成恰好一个空行,并恢复右侧所需块前缀 |
|
||||||
|
|
||||||
|
`PARAGRAPH` 在普通段落中分别产生 `EOL + EOL`;在 block quote 中产生合法的空 quote 行和下一行 quote 前缀;
|
||||||
|
在 list item 中保留 continuation 所需缩进。具体字节由合成测试固定,不根据操作系统默认换行。
|
||||||
|
|
||||||
|
候选内部出现混合 CR、LF、CRLF 时失败关闭,不匹配该候选。文档其他位置可以使用不同换行风格,不做全文规范化。
|
||||||
|
|
||||||
|
## 8. 冲突与链式合并
|
||||||
|
|
||||||
|
### 8.1 同一边界多规则冲突
|
||||||
|
|
||||||
|
每个候选边界先按调用方给出的规则顺序收集全部命中,再执行:
|
||||||
|
|
||||||
|
| 策略 | 行为 |
|
||||||
|
| --- | --- |
|
||||||
|
| `FIRST` | 选择声明顺序最早的规则 |
|
||||||
|
| `PRIORITY` | 选择最高 `priority`;最高值相同时选择声明顺序最早的规则 |
|
||||||
|
| `ERROR` | 两条及以上规则命中即抛出 `ModifierContractError`,消息只含位置和 `rule_id` |
|
||||||
|
|
||||||
|
自动词典规则只有在结构 pattern 命中且第 4.5 节选出了一个候选形式后才进入冲突集合;无词典候选或 `ambiguity=KEEP`
|
||||||
|
不算命中。`ambiguity=ERROR` 的词典歧义直接报错,不先交给规则冲突策略掩盖。
|
||||||
|
|
||||||
|
规则顺序因此是参数和行为的一部分,不再为了得到顺序无关结果而排序。`PRIORITY` 的同优先级 tie-break 明确采用
|
||||||
|
声明顺序,不把集合迭代顺序或 replacement 字典序当作隐藏规则。
|
||||||
|
|
||||||
|
`KeepLineJoinRule` 与其他规则参加同一次冲突选择;它只有在赢得选择后才阻止修改。这样精确项目例外可以用更高
|
||||||
|
优先级覆盖语言级词典规则,而不会让一条低优先级保护规则意外屏蔽更明确的替换。
|
||||||
|
|
||||||
|
### 8.2 跨多行链式合并
|
||||||
|
|
||||||
|
修改器从前到后处理物理边界,并维护只存在于当前 `propose()` 调用内的不可变中间片段。选中一条规则后:
|
||||||
|
|
||||||
|
1. 组合该边界的虚拟输出;
|
||||||
|
2. 标记该原始物理边界已经消费;
|
||||||
|
3. 如果输出与下一原始物理行形成新的候选,继续匹配下一边界;
|
||||||
|
4. 每个原始物理边界最多消费一次,因此最多执行 `physical_line_count - 1` 次,不存在无界循环;
|
||||||
|
5. 相连边界最终合成一个覆盖原始连续范围的 `TextEdit`。
|
||||||
|
|
||||||
|
例如两条显式规则可以把:
|
||||||
|
|
||||||
|
```text
|
||||||
|
exam-
|
||||||
|
ple-
|
||||||
|
based
|
||||||
|
```
|
||||||
|
|
||||||
|
在一次提议中变成 `example-based`。修改器不会依赖 `Pipeline` 自动运行第二轮,也不会先提交两个重叠编辑再让执行器猜测。
|
||||||
|
|
||||||
|
不相连的链生成不同且不重叠的 `TextEdit`。最终按原文起点排序。若内部规划仍产生重叠、无实际变化或无法映射回原始
|
||||||
|
范围,修改器抛出契约错误,不静默丢弃整批正确候选。
|
||||||
|
|
||||||
|
## 9. 大小写和 Unicode 规范化
|
||||||
|
|
||||||
|
- `case_sensitive=True` 是默认值;
|
||||||
|
- `normalization=None` 是默认值;
|
||||||
|
- 规范化只建立匹配视图,不规范化整篇文档;
|
||||||
|
- 固定 replacement 按调用方原样输出;
|
||||||
|
- 正则反向引用使用映射回原文的捕获文本,不借匹配视图静默改变大小写或规范形式;
|
||||||
|
- 如果规范化后的匹配边界不能唯一映射回 Python 原字符串索引,该候选失败关闭;
|
||||||
|
- 兼容等价匹配、NFKC/NFKD 兼容匹配和忽略大小写都必须由规则显式开启。
|
||||||
|
|
||||||
|
实现可以为一个逻辑内容建立规范化视图及源索引边界表,但不能把规范化后的全文作为 `expected_text`,也不能绕过
|
||||||
|
`TextSpan` 的原始 Python 字符索引契约。
|
||||||
|
|
||||||
|
## 10. 验证、错误与审计
|
||||||
|
|
||||||
|
工厂构造时验证:
|
||||||
|
|
||||||
|
- 输入可迭代且每项是合法三元组或规则 dataclass;
|
||||||
|
- `rule_id` 非空、稳定、唯一;
|
||||||
|
- 枚举、布尔值、整数和块集合类型准确,不接受用真值冒充布尔值;
|
||||||
|
- 左右精确片段、separator 和 replacement 不包含 CR/LF;
|
||||||
|
- 精确规则的 `left`、`right` 和 replacement 非空;separator 可以为空;
|
||||||
|
- 正则可以编译,左右正则规则的两侧及行尾正则规则的右侧不会只产生零长度匹配;
|
||||||
|
- 捕获组均有名称、左右名称不重复、replacement 引用存在;
|
||||||
|
- 行尾正则规则不接受 `PRESERVE`;
|
||||||
|
- 自动词典规则的 separator 只能是 `"-"` 或 `""`,pattern 必须消费非空片段;
|
||||||
|
- 自动词典规则必须包含 `JOINED` 和对应源形式,候选形式不能重复;
|
||||||
|
- backend、非空语言代码、有限且非负的 `minimum_score` / `minimum_score_margin` 和可选 Pyphen 语言合法;
|
||||||
|
- 请求的 backend 或 Pyphen 不可用时明确报错,不创建行为不完整的修改器;
|
||||||
|
- `max_intervening_blank_lines` 的类型严格为 `int` 且只能是 `0` 或 `1`;
|
||||||
|
- 旧三元组仍拒绝空字段、换行和重复的 `(left, right)`。
|
||||||
|
|
||||||
|
运行时冲突、无法映射的内部范围和不变量破坏抛出明确异常。普通无匹配、结构排除和不能唯一映射的 Unicode 候选是
|
||||||
|
失败关闭条件,返回无提议,不是异常。
|
||||||
|
|
||||||
|
每个实际链生成一个 `ProposedChange` 和一个 `TextEdit`。`reason` 包含所用 `rule_id` 序列,不记录未命中规则,
|
||||||
|
也不输出候选周围正文。`Modifier.parameters` 记录:
|
||||||
|
|
||||||
|
- 调用顺序中的完整规则;
|
||||||
|
- 规则类型、pattern/literal、replacement、separator、块范围、换行策略、优先级、大小写和规范化选项;
|
||||||
|
- 冲突策略和允许的空行数。
|
||||||
|
|
||||||
|
修改器版本从 `1.0.0` 更新为 `2.0.0`,表示规则语义和参数记录发生变化。
|
||||||
|
|
||||||
|
## 11. 依赖和函数式边界
|
||||||
|
|
||||||
|
- 核心安装仍只依赖 Python 标准库;
|
||||||
|
- `pyproject.toml` 新增 `lexical` optional extra,包含受限版本的 `pyspellchecker` 和 `pyphen`;
|
||||||
|
- `wordfreq` 体积和传递依赖更大,放入单独的 `frequency` optional extra,不随 `lexical` 或核心安装;
|
||||||
|
- optional import 使用模块级 `try/except ImportError` 保存“不可用”状态;只有规则明确请求对应 backend 时才报
|
||||||
|
`ModifierContractError`,精确和正则规则不受未安装 extra 影响;
|
||||||
|
- backend 和 Pyphen 实例在 `mapped_line_join()` 构造修改器时建立一次,不在每个文档或每个候选上重复加载词典;
|
||||||
|
- 参数记录 backend、第三方包版本、语言、阈值、候选形式和 Pyphen 语言;
|
||||||
|
- 不在 import 时读取词典、环境变量、当前时间或网络;
|
||||||
|
- 规则 dataclass、编译后的内部规则和扫描结果不可变;
|
||||||
|
- 闭包只捕获构造时验证和冻结的数据;
|
||||||
|
- `propose()` 只读取 `DocumentSnapshot` 并返回 tuple,不应用编辑或写文件。
|
||||||
|
|
||||||
|
`pyspellchecker` 和 `wordfreq` 自带的数据版本随包版本冻结;本轮不下载更新。Pyphen 使用其已安装包内词典。
|
||||||
|
调用方仅安装 extra 不会自动启用任何规则,因而不会出现“环境里碰巧多了一个包,清洗结果就改变”的隐式行为。
|
||||||
|
|
||||||
|
PyEnchant/Hunspell backend 若以后进入运行时,需要新的 design 解决系统 provider、词典版本和可复现记录,不能在本轮
|
||||||
|
用宽泛 `try/except` 悄悄替代已选择的 backend。
|
||||||
|
|
||||||
|
## 12. 文档与兼容范围
|
||||||
|
|
||||||
|
实现后的模块顶层 docstring 记录第 2 节的简短结论、支持模式和限制,不新增同目录 `NOTES.md`,避免形成第二份接口事实。
|
||||||
|
具体签名和运行行为以代码与测试为准。
|
||||||
|
|
||||||
|
根 `README.md` 当前明确写着 `mapped_line_join()` 只使用三元组并只处理当前窄边界。实现本文后该说明会过期。
|
||||||
|
按照 README 的事实权威要求,实施范围必须允许同步更新 README 的当前能力和示例。若仍要求 Git diff 只能包含目标源码
|
||||||
|
和 tests,则本文不能实施。
|
||||||
|
|
||||||
|
本轮不承诺新 dataclass 从 `mdpolish.modifiers` 聚合模块导出。受支持导入路径是:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from mdpolish.modifiers import mapped_line_join
|
||||||
|
from mdpolish.modifiers.mapped_line_join import (
|
||||||
|
ExactLineJoinRule,
|
||||||
|
KeepLineJoinRule,
|
||||||
|
LexicalLineJoinRule,
|
||||||
|
LineEndRegexRule,
|
||||||
|
RegexLineJoinRule,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
旧 `mapped_line_join()` 和 `LineJoinMapping` 聚合导入保持可用。
|
||||||
|
|
||||||
|
## 13. 测试与验收
|
||||||
|
|
||||||
|
测试只使用虚构 Markdown,不读取真实或外部数据。至少覆盖:
|
||||||
|
|
||||||
|
- 旧三元组精确匹配和新 `ExactLineJoinRule`;
|
||||||
|
- `separator="-"` 与 `separator=""`;
|
||||||
|
- 左右命名正则和跨两侧 backreference;
|
||||||
|
- `LineEndRegexRule` 的行尾锚点 + 行首正则,并确认右侧条件文本不被消费;
|
||||||
|
- 小型合成词典 backend 自动把行尾和行首组成 `JOINED`、`HYPHENATED`、`SPACED` 候选;
|
||||||
|
- 拼接词唯一命中自动合并,不需要逐词精确映射;
|
||||||
|
- 拼接词与源形式都有可比分数时按分差选择,分差不足按 `KEEP`/`ERROR` 处理;
|
||||||
|
- Pyphen 合法断点门槛允许和拒绝 `JOINED` 候选;
|
||||||
|
- 高优先级 `KeepLineJoinRule` 能否决自动词典规则;
|
||||||
|
- 请求未安装 backend 明确失败,绝不退回另一 backend 或正则猜测;
|
||||||
|
- 已安装 optional extra 时分别做最小真实 adapter 测试;未安装时明确 skip 并报告,不把 skip 写成通过;
|
||||||
|
- 段落、ATX 标题 continuation、列表 continuation 和同深度引用的正向范围;
|
||||||
|
- 合法的三层引用和正确缩进的嵌套有序列表可以合并;
|
||||||
|
- 引用深度不一致、损坏 quote 前缀、相邻同级列表项、缩进不足的嵌套列表 continuation,以及未识别的 marker-like
|
||||||
|
结构归入内部 `UNKNOWN`,不合并且绝不回退成段落;
|
||||||
|
- heading/list/quote 前缀在四种换行策略下的准确输出;
|
||||||
|
- fenced code、缩进代码、GFM pipe table 和 raw HTML table 不合并;
|
||||||
|
- 空文档、空规则、文末无换行;
|
||||||
|
- LF、CR、CRLF 以及候选内混合行尾失败关闭;
|
||||||
|
- 隔零个或一个空行的配置,以及 `-1`、`True`、`2` 被构造期拒绝;
|
||||||
|
- 三行及以上链式合并、同一合并后逻辑行继续匹配;
|
||||||
|
- 一篇文档中的多个独立链按原文顺序报告;
|
||||||
|
- `FIRST`、`PRIORITY`、同优先级 tie-break 和 `ERROR`;
|
||||||
|
- 默认区分大小写和显式忽略大小写;
|
||||||
|
- 默认不规范化、NFC/NFD 匹配、不能唯一回映时失败关闭;
|
||||||
|
- Unicode 词边界和显式关闭词边界;
|
||||||
|
- 无效 dataclass 字段、正则、捕获组、replacement 引用和重复 `rule_id` 明确失败;
|
||||||
|
- 无效 backend、语言、候选组合、score 阈值和 margin 明确失败;
|
||||||
|
- `WORDFREQ` 候选按 Zipf margin 比较;`SPELLCHECKER` 单 token 与 `SPACED`/拆分验证的 `HYPHENATED`
|
||||||
|
同时存活时按不可排序歧义处理,不用 margin 强行选择;
|
||||||
|
- 参数记录完整且确定;
|
||||||
|
- 所有正向输出再次运行零修改;
|
||||||
|
- 精确/正则规则不读取文件、网络、词典或环境;词典规则只读取已安装 extra 自带资源,且不访问网络或任意路径。
|
||||||
|
|
||||||
|
实现后运行根 README 届时列出的全部基础检查:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
.venv/bin/ruff check .
|
||||||
|
.venv/bin/mypy src tests
|
||||||
|
.venv/bin/pytest
|
||||||
|
.venv/bin/python -m pip wheel . --no-deps --wheel-dir /tmp/mdpolish-wheel-check
|
||||||
|
git diff --check
|
||||||
|
git status --short
|
||||||
|
```
|
||||||
|
|
||||||
|
最终报告只贴本轮真实输出。失败必须修复或明确报告,不能放宽断言掩盖。
|
||||||
|
|
||||||
|
## 14. 风险与代价
|
||||||
|
|
||||||
|
- **词法块扫描不是完整 parser:** 它能保守排除常见代码和表格,并处理约定的四类块;复杂嵌套或方言结构可能漏匹配。
|
||||||
|
- **显式正则仍可能写错:** 库保证锚点、冲突和精确编辑安全,不保证调用方规则符合具体业务语义。
|
||||||
|
- **heading continuation 有歧义:** 紧随标题的普通行可能本来就是段落;只有映射本身已获项目确认时才应允许 heading 规则。
|
||||||
|
- **兼容规范化可能多对一:** 无法唯一回映时选择漏处理,不猜测源范围。
|
||||||
|
- **链式规划增加实现复杂度:** 用每个物理边界最多消费一次和单个连续编辑限制状态空间,换取一次提议内稳定完成。
|
||||||
|
- **规则顺序成为契约:** 这是支持 `FIRST` 以及优先级 tie-break 的必要代价,调用方必须把顺序纳入版本和评审。
|
||||||
|
- **词典仍会漏掉专名和新词:** 自动规则对无命中和歧义失败关闭;项目可用精确规则补充,不把低覆盖伪装成全文正确。
|
||||||
|
|
||||||
|
## 15. 批准后的实施边界
|
||||||
|
|
||||||
|
批准本文将授权:
|
||||||
|
|
||||||
|
1. 重构 `src/mdpolish/modifiers/mapped_line_join.py`;
|
||||||
|
2. 新增或扩展 `tests/test_mapped_line_join.py`,必要时只在 `tests/` 增加同主题测试文件;
|
||||||
|
3. 更新 `pyproject.toml`,只增加第 11 节批准的 optional extras;
|
||||||
|
4. 更新根 `README.md` 中已经过期的当前能力边界、安装方式和调用示例;
|
||||||
|
5. 在不改变正文的前提下检查 `AGENTS.md` 与 `CLAUDE.md` 镜像;
|
||||||
|
6. 运行第 13 节检查并查看 Git diff、wheel 内容和工作区状态。
|
||||||
|
|
||||||
|
批准本文不授权修改 `_text_ranges.py`、其他 modifier、其他 Wiki 文档、真实数据、外部系统,
|
||||||
|
也不授权提交、推送、创建 PR 或发布。
|
||||||
File diff suppressed because it is too large
Load Diff
+509
-43
@@ -1,19 +1,62 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from importlib import import_module
|
||||||
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from mdpolish import ModifierContractError, Pipeline, RunStatus
|
from mdpolish import DocumentSnapshot, ModifierContractError, Pipeline, RunStatus
|
||||||
from mdpolish.modifiers import mapped_line_join
|
from mdpolish.modifiers import mapped_line_join
|
||||||
|
from mdpolish.modifiers.mapped_line_join import (
|
||||||
|
ExactLineJoinRule,
|
||||||
|
KeepLineJoinRule,
|
||||||
|
LexicalAmbiguityPolicy,
|
||||||
|
LexicalCandidateForm,
|
||||||
|
LexicalLineJoinRule,
|
||||||
|
LexiconBackend,
|
||||||
|
LineBreakPolicy,
|
||||||
|
LineEndRegexRule,
|
||||||
|
LineJoinBlock,
|
||||||
|
LineJoinConflictPolicy,
|
||||||
|
LineJoinRule,
|
||||||
|
RegexLineJoinRule,
|
||||||
|
UnicodeNormalization,
|
||||||
|
)
|
||||||
|
|
||||||
MAPPINGS = (
|
if TYPE_CHECKING:
|
||||||
|
from _pytest.monkeypatch import MonkeyPatch
|
||||||
|
|
||||||
|
mapped_module = import_module("mdpolish.modifiers.mapped_line_join")
|
||||||
|
|
||||||
|
LEGACY_MAPPINGS = (
|
||||||
("exam-", "ple", "example"),
|
("exam-", "ple", "example"),
|
||||||
("rule-", "based", "rule-based"),
|
("rule-", "based", "rule-based"),
|
||||||
("value.", "ues", "values"),
|
("value.", "ues", "values"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def transform(markdown: str): # type: ignore[no-untyped-def]
|
def transform(
|
||||||
return Pipeline([mapped_line_join(MAPPINGS)]).transform(markdown)
|
markdown: str,
|
||||||
|
rules: tuple[tuple[str, str, str] | LineJoinRule, ...] = LEGACY_MAPPINGS,
|
||||||
|
**options: object,
|
||||||
|
) -> str:
|
||||||
|
modifier = mapped_line_join(rules, **options) # type: ignore[arg-type]
|
||||||
|
result = Pipeline((modifier,)).transform(markdown)
|
||||||
|
assert result.status is RunStatus.SUCCESS
|
||||||
|
assert result.output_markdown is not None
|
||||||
|
return result.output_markdown
|
||||||
|
|
||||||
|
|
||||||
|
def exact_rule(**overrides: object) -> ExactLineJoinRule:
|
||||||
|
fields: dict[str, object] = {
|
||||||
|
"rule_id": "example.join",
|
||||||
|
"left": "exam",
|
||||||
|
"right": "ple",
|
||||||
|
"replacement": "example",
|
||||||
|
"separator": "-",
|
||||||
|
}
|
||||||
|
fields.update(overrides)
|
||||||
|
return ExactLineJoinRule(**fields) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
@@ -21,70 +64,493 @@ def transform(markdown: str): # type: ignore[no-untyped-def]
|
|||||||
[
|
[
|
||||||
("an exam-\nple here", "an example here"),
|
("an exam-\nple here", "an example here"),
|
||||||
("an exam-\n\nple here", "an example here"),
|
("an exam-\n\nple here", "an example here"),
|
||||||
("a rule-\nbased method", "a rule-based method"),
|
|
||||||
("the value.\n\nues differ", "the values differ"),
|
|
||||||
("an exam-\r\n\r\nple here", "an example here"),
|
("an exam-\r\n\r\nple here", "an example here"),
|
||||||
("an exam-\r\rple here", "an example here"),
|
("an exam-\r\rple here", "an example here"),
|
||||||
|
("a rule-\nbased method", "a rule-based method"),
|
||||||
|
("the value.\nues differ", "the values differ"),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_applies_exact_mapping_across_supported_line_shapes(markdown: str, expected: str) -> None:
|
def test_legacy_exact_mapping_supports_lf_crlf_cr_and_one_blank_line(markdown: str, expected: str) -> None:
|
||||||
result = transform(markdown)
|
assert transform(markdown) == expected
|
||||||
assert result.status is RunStatus.SUCCESS
|
|
||||||
assert result.output_markdown == expected
|
|
||||||
assert len(result.changes) == 1
|
def test_exact_rules_distinguish_explicit_and_empty_separator() -> None:
|
||||||
|
hyphenated = exact_rule()
|
||||||
|
unseparated = exact_rule(rule_id="example.unseparated", separator="")
|
||||||
|
|
||||||
|
assert transform("exam-\nple", (hyphenated,)) == "example"
|
||||||
|
assert transform("exam\nple", (unseparated,)) == "example"
|
||||||
|
assert transform("exam\nple", (hyphenated,)) == "exam\nple"
|
||||||
|
|
||||||
|
|
||||||
|
def test_named_regex_combines_backreferences_from_both_sides() -> None:
|
||||||
|
rule = RegexLineJoinRule(
|
||||||
|
rule_id="regex.join",
|
||||||
|
left_pattern=r"(?P<stem>[A-Za-z]+)",
|
||||||
|
right_pattern=r"(?P<suffix>[a-z]+)",
|
||||||
|
replacement=r"\g<stem>_\g<suffix>",
|
||||||
|
separator="-",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform("prefix exam-\nple suffix", (rule,)) == "prefix exam_ple suffix"
|
||||||
|
|
||||||
|
|
||||||
|
def test_regex_keeps_its_inline_flags() -> None:
|
||||||
|
rule = RegexLineJoinRule(
|
||||||
|
rule_id="regex.flags",
|
||||||
|
left_pattern=r"(?i:(?P<stem>exam))",
|
||||||
|
right_pattern=r"(?i:(?P<suffix>ple))",
|
||||||
|
replacement=r"\g<stem>\g<suffix>",
|
||||||
|
separator="-",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform("EXAM-\nPLE", (rule,)) == "EXAMPLE"
|
||||||
|
|
||||||
|
|
||||||
|
def test_regex_case_insensitive_option_applies_to_the_pattern() -> None:
|
||||||
|
rule = RegexLineJoinRule(
|
||||||
|
rule_id="regex.ignore-case",
|
||||||
|
left_pattern=r"(?P<stem>EXAM)",
|
||||||
|
right_pattern=r"(?P<suffix>PLE)",
|
||||||
|
replacement=r"\g<stem>\g<suffix>",
|
||||||
|
separator="-",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform("exam-\nple", (rule,)) == "example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_regex_suffix_matching_considers_overlapping_starts() -> None:
|
||||||
|
rule = RegexLineJoinRule(
|
||||||
|
rule_id="regex.overlap",
|
||||||
|
left_pattern=r"(?P<stem>aba)",
|
||||||
|
right_pattern=r"(?P<suffix>x)",
|
||||||
|
replacement=r"\g<stem>\g<suffix>",
|
||||||
|
separator="-",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform("ababa-\nx", (rule,)) == "ababax"
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_end_regex_uses_right_match_only_as_a_condition() -> None:
|
||||||
|
rule = LineEndRegexRule(
|
||||||
|
rule_id="line-end.space",
|
||||||
|
right_pattern=r"(?P<initial>[a-z])",
|
||||||
|
line_break=LineBreakPolicy.SPACE,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform("foo\nbar", (rule,)) == "foo bar"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("policy", "expected"),
|
||||||
|
[
|
||||||
|
(LineBreakPolicy.PRESERVE, "example\n"),
|
||||||
|
(LineBreakPolicy.DELETE, "example"),
|
||||||
|
(LineBreakPolicy.SPACE, "example "),
|
||||||
|
(LineBreakPolicy.PARAGRAPH, "example\n\n"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_all_line_break_policies_are_exact(policy: LineBreakPolicy, expected: str) -> None:
|
||||||
|
assert transform("exam-\nple", (exact_rule(line_break=policy),)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_paragraph_policy_restores_quote_and_list_prefixes() -> None:
|
||||||
|
rule = exact_rule(line_break=LineBreakPolicy.PARAGRAPH)
|
||||||
|
|
||||||
|
assert transform("> exam-\n> ple", (rule,)) == "> example\n>\n> "
|
||||||
|
assert transform("- exam-\n ple", (rule,)) == "- example\n\n "
|
||||||
|
assert transform("> - exam-\n> ple", (rule,)) == "> - example\n>\n> "
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("markdown", "expected"),
|
||||||
|
[
|
||||||
|
("plain exam-\nple", "plain example"),
|
||||||
|
("# exam-\nple", "# example"),
|
||||||
|
("- exam-\n ple", "- example"),
|
||||||
|
("> exam-\n> ple", "> example"),
|
||||||
|
("> > > exam-\n> > > ple", "> > > example"),
|
||||||
|
("1. outer\n 2. exam-\n ple", "1. outer\n 2. example"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_supported_markdown_blocks_and_valid_nested_containers(markdown: str, expected: str) -> None:
|
||||||
|
assert transform(markdown, (exact_rule(),)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_scope_is_explicit() -> None:
|
||||||
|
heading_only = exact_rule(blocks=frozenset({LineJoinBlock.HEADING}))
|
||||||
|
|
||||||
|
assert transform("# exam-\nple", (heading_only,)) == "# example"
|
||||||
|
assert transform("exam-\nple", (heading_only,)) == "exam-\nple"
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"markdown",
|
"markdown",
|
||||||
[
|
[
|
||||||
"an unknown-\nword here",
|
"> > > exam-\n> > ple",
|
||||||
"an exam-\n\n\nple here",
|
"> #broken exam-\n> ple",
|
||||||
"an exam-\r\n\nple here",
|
"1. exam-\n2. ple",
|
||||||
"an EXAM-\nple here",
|
"1. outer\n 2. exam-\n ple",
|
||||||
"an exam-\nplemore here",
|
"- exam-\n ple",
|
||||||
"an xrule-\nbased method",
|
"#broken exam-\nple",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_unknown_or_unsafe_boundaries_are_preserved(markdown: str) -> None:
|
def test_unknown_or_mismatched_containers_fail_closed_instead_of_becoming_paragraphs(markdown: str) -> None:
|
||||||
assert transform(markdown).output_markdown == markdown
|
assert transform(markdown, (exact_rule(),)) == markdown
|
||||||
|
|
||||||
|
|
||||||
def test_parameters_and_results_are_independent_of_mapping_order() -> None:
|
|
||||||
first = mapped_line_join(MAPPINGS)
|
|
||||||
second = mapped_line_join(reversed(MAPPINGS))
|
|
||||||
markdown = "example becomes exam-\nple"
|
|
||||||
|
|
||||||
first_result = Pipeline([first]).transform(markdown)
|
|
||||||
second_result = Pipeline([second]).transform(markdown)
|
|
||||||
|
|
||||||
assert first_result.modifiers == second_result.modifiers
|
|
||||||
assert first_result.output_markdown == second_result.output_markdown
|
|
||||||
|
|
||||||
|
|
||||||
def test_library_contains_no_default_mapping() -> None:
|
|
||||||
modifier = mapped_line_join(())
|
|
||||||
result = Pipeline([modifier]).transform("an exam-\nple here")
|
|
||||||
|
|
||||||
assert modifier.parameters == (("mappings", ()),)
|
|
||||||
assert result.output_markdown == "an exam-\nple here"
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize(
|
@pytest.mark.parametrize(
|
||||||
"mappings",
|
"markdown",
|
||||||
|
[
|
||||||
|
"```text\nexam-\nple\n```",
|
||||||
|
" exam-\n ple",
|
||||||
|
"> exam-\n> ple",
|
||||||
|
"- ```\n exam-\n ple\n ```",
|
||||||
|
"| word |\n| --- |\n| exam- |\n| ple |",
|
||||||
|
"<table>\nexam-\nple\n</table>",
|
||||||
|
"<table>\nexam-\nple",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_code_and_table_regions_are_always_excluded(markdown: str) -> None:
|
||||||
|
assert transform(markdown, (exact_rule(),)) == markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_line_chain_is_one_proposal_and_uses_virtual_output() -> None:
|
||||||
|
rules = (
|
||||||
|
exact_rule(rule_id="chain.first"),
|
||||||
|
ExactLineJoinRule(
|
||||||
|
rule_id="chain.second",
|
||||||
|
left="example",
|
||||||
|
right="based",
|
||||||
|
replacement="example-based",
|
||||||
|
separator="-",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
modifier = mapped_line_join(rules)
|
||||||
|
proposals = modifier.propose(DocumentSnapshot("exam-\nple-\nbased"))
|
||||||
|
|
||||||
|
assert len(proposals) == 1
|
||||||
|
assert len(proposals[0].edits) == 1
|
||||||
|
assert "chain.first, chain.second" in proposals[0].reason
|
||||||
|
assert transform("exam-\nple-\nbased", rules) == "example-based"
|
||||||
|
|
||||||
|
|
||||||
|
def test_mixed_line_endings_fail_the_entire_connected_chain_closed() -> None:
|
||||||
|
rules = (
|
||||||
|
exact_rule(rule_id="chain.first"),
|
||||||
|
ExactLineJoinRule("chain.second", "example", "based", "example-based", separator="-"),
|
||||||
|
)
|
||||||
|
markdown = "exam-\r\nple-\nbased"
|
||||||
|
|
||||||
|
assert transform(markdown, rules) == markdown
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiple_independent_chains_are_reported_in_source_order() -> None:
|
||||||
|
modifier = mapped_line_join((exact_rule(),))
|
||||||
|
snapshot = DocumentSnapshot("exam-\nple and\n\nexam-\nple")
|
||||||
|
proposals = modifier.propose(snapshot)
|
||||||
|
|
||||||
|
assert len(proposals) == 2
|
||||||
|
assert proposals[0].edits[0].span.start < proposals[1].edits[0].span.start
|
||||||
|
|
||||||
|
|
||||||
|
def test_keep_rule_can_veto_a_lower_priority_replacement() -> None:
|
||||||
|
keep = KeepLineJoinRule("keep.example", "exam", "ple", separator="-", priority=10)
|
||||||
|
replace = exact_rule(priority=0)
|
||||||
|
|
||||||
|
assert transform("exam-\nple", (replace, keep)) == "exam-\nple"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_first_and_priority_are_deterministic() -> None:
|
||||||
|
first = exact_rule(rule_id="first", replacement="first", priority=0)
|
||||||
|
second = exact_rule(rule_id="second", replacement="second", priority=10)
|
||||||
|
|
||||||
|
assert transform(
|
||||||
|
"exam-\nple",
|
||||||
|
(first, second),
|
||||||
|
conflict_policy=LineJoinConflictPolicy.FIRST,
|
||||||
|
) == "first"
|
||||||
|
assert transform(
|
||||||
|
"exam-\nple",
|
||||||
|
(first, second),
|
||||||
|
conflict_policy=LineJoinConflictPolicy.PRIORITY,
|
||||||
|
) == "second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_priority_tie_uses_declaration_order() -> None:
|
||||||
|
first = exact_rule(rule_id="first", replacement="first")
|
||||||
|
second = exact_rule(rule_id="second", replacement="second")
|
||||||
|
|
||||||
|
assert transform("exam-\nple", (second, first)) == "second"
|
||||||
|
|
||||||
|
|
||||||
|
def test_conflict_error_names_rules_but_not_surrounding_text() -> None:
|
||||||
|
modifier = mapped_line_join(
|
||||||
|
(exact_rule(rule_id="first"), exact_rule(rule_id="second")),
|
||||||
|
conflict_policy=LineJoinConflictPolicy.ERROR,
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(ModifierContractError, match=r"first, second") as raised:
|
||||||
|
modifier.propose(DocumentSnapshot("secret exam-\nple material"))
|
||||||
|
assert "secret" not in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
def test_case_insensitive_matching_is_opt_in() -> None:
|
||||||
|
assert transform("EXAM-\nPLE", (exact_rule(),)) == "EXAM-\nPLE"
|
||||||
|
assert transform("EXAM-\nPLE", (exact_rule(case_sensitive=False),)) == "example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_normalization_is_opt_in_and_preserves_source_index_mapping() -> None:
|
||||||
|
decomposed_left = "cafe\N{COMBINING ACUTE ACCENT}"
|
||||||
|
markdown = "CAFÉ-\nteria"
|
||||||
|
default = ExactLineJoinRule(
|
||||||
|
"unicode.default",
|
||||||
|
decomposed_left,
|
||||||
|
"teria",
|
||||||
|
"cafeteria",
|
||||||
|
separator="-",
|
||||||
|
case_sensitive=False,
|
||||||
|
)
|
||||||
|
normalized = ExactLineJoinRule(
|
||||||
|
"unicode.nfc",
|
||||||
|
decomposed_left,
|
||||||
|
"teria",
|
||||||
|
"cafeteria",
|
||||||
|
separator="-",
|
||||||
|
case_sensitive=False,
|
||||||
|
normalization=UnicodeNormalization.NFC,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform(markdown, (default,)) == markdown
|
||||||
|
assert transform(markdown, (normalized,)) == "cafeteria"
|
||||||
|
|
||||||
|
|
||||||
|
def test_unicode_word_boundaries_and_explicit_override() -> None:
|
||||||
|
strict = exact_rule()
|
||||||
|
permissive = exact_rule(rule_id="permissive", require_word_boundaries=False)
|
||||||
|
markdown = "éexam-\nplemore"
|
||||||
|
|
||||||
|
assert transform(markdown, (strict,)) == markdown
|
||||||
|
assert transform(markdown, (permissive,)) == "éexamplemore"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("max_blank_lines", [0, 1])
|
||||||
|
def test_blank_line_limit_is_configurable(max_blank_lines: int) -> None:
|
||||||
|
expected = "exam-\n\nple" if max_blank_lines == 0 else "example"
|
||||||
|
assert transform(
|
||||||
|
"exam-\n\nple",
|
||||||
|
(exact_rule(),),
|
||||||
|
max_intervening_blank_lines=max_blank_lines,
|
||||||
|
) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("invalid", [-1, True, 2])
|
||||||
|
def test_invalid_blank_line_limits_are_rejected(invalid: object) -> None:
|
||||||
|
with pytest.raises(ModifierContractError):
|
||||||
|
mapped_line_join((), max_intervening_blank_lines=invalid) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def test_empty_document_and_empty_rule_set_are_no_ops() -> None:
|
||||||
|
modifier = mapped_line_join(())
|
||||||
|
|
||||||
|
assert transform("", ()) == ""
|
||||||
|
assert transform("exam-\nple", ()) == "exam-\nple"
|
||||||
|
assert modifier.version == "2.0.0"
|
||||||
|
assert dict(modifier.parameters)["rules"] == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_parameters_preserve_rule_order_and_record_all_options() -> None:
|
||||||
|
first = mapped_line_join((exact_rule(rule_id="first"), exact_rule(rule_id="second")))
|
||||||
|
second = mapped_line_join((exact_rule(rule_id="second"), exact_rule(rule_id="first")))
|
||||||
|
|
||||||
|
assert first.parameters != second.parameters
|
||||||
|
records = dict(first.parameters)["rules"]
|
||||||
|
assert isinstance(records, tuple)
|
||||||
|
first_record = cast(tuple[tuple[str, object], ...], records[0])
|
||||||
|
assert ("rule_id", "first") in first_record
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"rules",
|
||||||
[
|
[
|
||||||
(("", "right", "word"),),
|
(("", "right", "word"),),
|
||||||
(("left", "right", "two words"),),
|
(("left", "right", "two words"),),
|
||||||
(("left", "right", "word"), ("left", "right", "other")),
|
(("left", "right", "word"), ("left", "right", "other")),
|
||||||
(("left", "right"),),
|
(("left", "right"),),
|
||||||
|
(exact_rule(rule_id="same"), exact_rule(rule_id="same")),
|
||||||
|
(exact_rule(case_sensitive=1),),
|
||||||
|
(exact_rule(blocks={LineJoinBlock.PARAGRAPH}),),
|
||||||
|
(RegexLineJoinRule("regex", "", r"(?P<right>x)", "x"),),
|
||||||
|
(RegexLineJoinRule("regex", r"(x)", r"(?P<right>x)", "x"),),
|
||||||
|
(RegexLineJoinRule("regex", r"(?P<x>x)", r"(?P<x>x)", r"\g<x>"),),
|
||||||
|
(RegexLineJoinRule("regex", r"(?P<x>x)", r"(?P<y>x)", r"\g<missing>"),),
|
||||||
|
(LineEndRegexRule("line-end", r"(?P<x>x)", line_break=LineBreakPolicy.PRESERVE),),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
def test_invalid_mappings_raise_contract_error(mappings: object) -> None:
|
def test_invalid_rules_raise_contract_error(rules: object) -> None:
|
||||||
with pytest.raises(ModifierContractError):
|
with pytest.raises(ModifierContractError):
|
||||||
mapped_line_join(mappings) # type: ignore[arg-type]
|
mapped_line_join(rules) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
class FakeLexicon:
|
||||||
|
package_version = "test-lexicon"
|
||||||
|
|
||||||
|
def __init__(self, scores: dict[str, tuple[float, float | None]]) -> None:
|
||||||
|
self._scores = scores
|
||||||
|
|
||||||
|
def evidence(
|
||||||
|
self,
|
||||||
|
candidate: str,
|
||||||
|
form: LexicalCandidateForm,
|
||||||
|
left: str,
|
||||||
|
right: str,
|
||||||
|
) -> object:
|
||||||
|
del form, left, right
|
||||||
|
score = self._scores.get(candidate)
|
||||||
|
return None if score is None else mapped_module._LexicalEvidence(*score)
|
||||||
|
|
||||||
|
|
||||||
|
def lexical_rule(**overrides: object) -> LexicalLineJoinRule:
|
||||||
|
fields: dict[str, object] = {
|
||||||
|
"rule_id": "english.lexical",
|
||||||
|
"left_pattern": r"[A-Za-z]+",
|
||||||
|
"right_pattern": r"[a-z]+",
|
||||||
|
"separator": "-",
|
||||||
|
"backend": LexiconBackend.WORDFREQ,
|
||||||
|
"language": "en",
|
||||||
|
"candidate_forms": (LexicalCandidateForm.JOINED, LexicalCandidateForm.HYPHENATED),
|
||||||
|
"minimum_score": 1.0,
|
||||||
|
"minimum_score_margin": 0.5,
|
||||||
|
}
|
||||||
|
fields.update(overrides)
|
||||||
|
return LexicalLineJoinRule(**fields) # type: ignore[arg-type]
|
||||||
|
|
||||||
|
|
||||||
|
def install_fake_lexicon(monkeypatch: MonkeyPatch, scores: dict[str, tuple[float, float | None]]) -> None:
|
||||||
|
def build(rule: LexicalLineJoinRule) -> FakeLexicon:
|
||||||
|
del rule
|
||||||
|
return FakeLexicon(scores)
|
||||||
|
|
||||||
|
monkeypatch.setattr(mapped_module, "_build_lexicon", build)
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_rule_joins_a_unique_dictionary_candidate_without_per_word_mapping(
|
||||||
|
monkeypatch: MonkeyPatch,
|
||||||
|
) -> None:
|
||||||
|
install_fake_lexicon(monkeypatch, {"example": (5.0, 5.0)})
|
||||||
|
|
||||||
|
assert transform("exam-\nple", (lexical_rule(),)) == "example"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_rule_can_select_the_natural_hyphenated_form_by_score(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
install_fake_lexicon(monkeypatch, {"rulebased": (3.0, 3.0), "rule-based": (6.0, 6.0)})
|
||||||
|
|
||||||
|
assert transform("rule-\nbased", (lexical_rule(),)) == "rule-based"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_margin_and_keep_policy_preserve_an_ambiguous_boundary(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
install_fake_lexicon(monkeypatch, {"rulebased": (5.0, 5.0), "rule-based": (4.8, 4.8)})
|
||||||
|
|
||||||
|
assert transform("rule-\nbased", (lexical_rule(minimum_score_margin=0.5),)) == "rule-\nbased"
|
||||||
|
|
||||||
|
|
||||||
|
def test_spellchecker_style_spaced_proxy_is_not_ranked_against_single_token(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
install_fake_lexicon(monkeypatch, {"inside": (6.0, 6.0), "in side": (5.0, None)})
|
||||||
|
rule = lexical_rule(
|
||||||
|
separator="",
|
||||||
|
candidate_forms=(LexicalCandidateForm.JOINED, LexicalCandidateForm.SPACED),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert transform("in\nside", (rule,)) == "in\nside"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lexical_ambiguity_error_is_source_safe(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
install_fake_lexicon(monkeypatch, {"inside": (6.0, 6.0), "in side": (5.0, None)})
|
||||||
|
rule = lexical_rule(
|
||||||
|
separator="",
|
||||||
|
candidate_forms=(LexicalCandidateForm.JOINED, LexicalCandidateForm.SPACED),
|
||||||
|
ambiguity=LexicalAmbiguityPolicy.ERROR,
|
||||||
|
)
|
||||||
|
modifier = mapped_line_join((rule,))
|
||||||
|
|
||||||
|
with pytest.raises(ModifierContractError, match=r"english\.lexical") as raised:
|
||||||
|
modifier.propose(DocumentSnapshot("secret in\nside"))
|
||||||
|
assert "secret" not in str(raised.value)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeHyphenator:
|
||||||
|
def __init__(self, positions: tuple[int, ...]) -> None:
|
||||||
|
self._positions = positions
|
||||||
|
|
||||||
|
def positions(self, word: str) -> tuple[int, ...]:
|
||||||
|
del word
|
||||||
|
return self._positions
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(("positions", "expected"), [((4,), "example"), ((), "exam-ple")])
|
||||||
|
def test_pyphen_gate_only_controls_the_joined_candidate(
|
||||||
|
monkeypatch: MonkeyPatch,
|
||||||
|
positions: tuple[int, ...],
|
||||||
|
expected: str,
|
||||||
|
) -> None:
|
||||||
|
install_fake_lexicon(monkeypatch, {"example": (6.0, 6.0), "exam-ple": (4.0, 4.0)})
|
||||||
|
monkeypatch.setattr(
|
||||||
|
mapped_module,
|
||||||
|
"_build_hyphenator",
|
||||||
|
lambda language: (FakeHyphenator(positions), f"test-{language}"),
|
||||||
|
)
|
||||||
|
rule = lexical_rule(hyphenation_language="en_US", minimum_score_margin=0.5)
|
||||||
|
|
||||||
|
assert transform("exam-\nple", (rule,)) == expected
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"overrides",
|
||||||
|
[
|
||||||
|
{"separator": "/"},
|
||||||
|
{"candidate_forms": (LexicalCandidateForm.JOINED,)},
|
||||||
|
{"candidate_forms": (LexicalCandidateForm.JOINED, LexicalCandidateForm.JOINED)},
|
||||||
|
{"minimum_score": -1.0},
|
||||||
|
{"minimum_score": float("nan")},
|
||||||
|
{"minimum_score_margin": True},
|
||||||
|
{"language": ""},
|
||||||
|
{"hyphenation_language": ""},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_invalid_lexical_rules_fail_before_loading_a_backend(overrides: dict[str, object]) -> None:
|
||||||
|
with pytest.raises(ModifierContractError):
|
||||||
|
mapped_line_join((lexical_rule(**overrides),))
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_requested_optional_backend_raises_without_fallback(monkeypatch: MonkeyPatch) -> None:
|
||||||
|
monkeypatch.setattr(mapped_module, "_zipf_frequency", None)
|
||||||
|
|
||||||
|
with pytest.raises(ModifierContractError, match="frequency"):
|
||||||
|
mapped_line_join((lexical_rule(),))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("backend", "available"),
|
||||||
|
[
|
||||||
|
(LexiconBackend.SPELLCHECKER, mapped_module._SpellChecker is not None),
|
||||||
|
(LexiconBackend.WORDFREQ, mapped_module._zipf_frequency is not None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_installed_optional_backend_constructs_or_is_reported_as_skipped(
|
||||||
|
backend: LexiconBackend,
|
||||||
|
available: bool,
|
||||||
|
) -> None:
|
||||||
|
if not available:
|
||||||
|
pytest.skip(f"optional backend is not installed: {backend.value}")
|
||||||
|
mapped_line_join((lexical_rule(backend=backend),))
|
||||||
|
|
||||||
|
|
||||||
def test_successful_output_is_idempotent() -> None:
|
def test_successful_output_is_idempotent() -> None:
|
||||||
pipeline = Pipeline([mapped_line_join(MAPPINGS)])
|
rules = (exact_rule(),)
|
||||||
|
pipeline = Pipeline((mapped_line_join(rules),))
|
||||||
first = pipeline.transform("an exam-\n\nple here")
|
first = pipeline.transform("an exam-\n\nple here")
|
||||||
|
|
||||||
|
assert first.status is RunStatus.SUCCESS
|
||||||
assert first.output_markdown is not None
|
assert first.output_markdown is not None
|
||||||
assert pipeline.transform(first.output_markdown).changes == ()
|
assert pipeline.transform(first.output_markdown).changes == ()
|
||||||
|
|||||||
Reference in New Issue
Block a user