Files
mdpolish/research-wiki/design/0009-generalized-mapped-line-join.md
T

763 lines
38 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 continuationSetext 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 或发布。