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

38 KiB
Raw Permalink Blame History

0009:通用映射驱动的跨行片段合并

状态

已批准(2026-08-26)。

supersedes: 0008(范围有限):本文替代 0008 第 6.1 节中“保留当前左片段、右片段、结果文本算法” 的窄接口,并为调用方显式启用的词典规则放宽“运行时只依赖标准库”这一点。核心安装仍然零依赖;精确和正则规则 仍然只处理内存字符串。词典规则只允许在构造修改器时读取已安装 optional package 自带的版本化词典资源, 不读取调用方文档、任意路径或网络。propose() 仍然是只读快照的纯函数。

0008 确定的函数式 Modifier、无默认启用行为、无项目固定词表、无网络和无模型推断等边界继续有效。

本文批准后才能实施。批准本文不等于批准提交、推送、创建 PR、发布、修改其他仓库或处理真实材料。

1. 问题与可观察现象

当前 mapped_line_join() 只接受 (left, right, replacement) 三元组。它能处理调用方已经确认的少量精确断词, 但遇到下列情况时,调用方只能复制整个修改器或在库外另写一套边界扫描逻辑:

  • 同一类断词需要用命名正则捕获不同词干;
  • 只需要匹配下一行开头,左侧只要求位于当前行尾;
  • OCR 把一个词连续拆到三行以上,需要在一次提议中完成链式合并;
  • 同一位置有多条规则,需要显式选择第一条、最高优先级或报错;
  • 规则只允许用于段落、标题、列表项或引用,不能进入表格和代码块;
  • 合并后需要保留换行、删除换行、换成空格或形成段落分隔;
  • 文本和规则可能使用不同 Unicode 规范形式,或调用方明确需要忽略大小写。

更根本的问题是:如果每个 exampleinternationaldatabase 都要先手写一条映射,调用方实际上要自己维护 一份英语词典。成熟的通用方案应能从行尾和行首自动生成候选词,查询明确配置的词典或词频资源;精确映射只负责 项目术语、误判否决和其他例外,不应成为处理普通英文断词的唯一入口。

当前实现还会在整个物理行内容上匹配。它不知道 Markdown 围栏、缩进代码、pipe table、列表和引用前缀, 因此无法可靠表达“只在某类块中生效”。

这些都属于清洗语义、规则格式和冲突策略变化,不能作为 0008 已批准实现的机械扩展。

2. 调研结论及其边界

本次调研只用于确定库边界,不把外部工具行为直接变成 mdpolish 的默认规则。

2.1 Pandoc:软换行语义与源码重排是两件事

Pandoc 默认把段落内普通换行当作空格。hard_line_breaks 会把段落内每个换行解释为硬换行, ignore_line_breakseast_asian_line_breaks 则提供另外两种读取语义。输出侧的 --wrap=auto|none|preserve 决定生成源码怎样折行,不负责判断 OCR 断词。

当前 Pandoc 手册没有 reflowed_text 扩展。与“reflowed text”最接近的当前公共能力是输出侧 --wrap, 不能把这个非现行名称设计成库兼容目标。

来源:

因此,本修改器不能把所有物理换行统一解释成一种语义。每条规则必须明确怎样处理命中的边界,代码块和表格等结构 必须先排除。

2.2 OCR 去连字符:成熟方案仍然需要证据来源和取舍

常见 OCR 后处理会组合以下信号:

信号 能解决的问题 不能单独保证的事情
词典查表 判断拼接词是否为已知词 专名、新词、领域词和真正带连字符的词
词缀与形态分析 识别屈折、派生和复合词 依赖语言及词典质量
词频或统计语言模型 在“连写、保留连字符、加空格”之间排序 结果依赖训练语料和领域分布
OCR 置信度与版面元数据 利用识别器对字符、词或断词的显式证据 普通 Markdown 通常已经丢失这些信息

ALTO 可以用 SUBS_TYPE=HypPart1/HypPart2SUBS_CONTENT 表达断词及完整词,并提供词置信度;hOCR 定义了 x_wconf。这说明如果上游仍持有结构化 OCR 证据,优先在上游使用它比从 Markdown 猜测更可靠。

来源:

本仓库当前只有 Markdown 字符串,没有 OCR 置信度、坐标或上游候选。因此本轮只实现调用方显式配置的本地词典判断 和统计打分,不实现依赖版面证据的判断或模型推断。词典结果是候选证据,不绕过歧义策略和精确覆盖规则。

2.3 Python 词典与断词库可以提供证据,但必须显式选择

工具 主要能力 本轮不直接集成的原因
wordninja 按 unigram 概率拆分粘连英文词 目标是拆词,不是恢复 Markdown 跨行结构;默认模型有语言和语料偏置
pyspellchecker 基于词频和编辑距离给出拼写候选 候选不是唯一正确修改,默认大小写和词典行为也需项目决定
PyEnchant 通过 Enchant provider 检查和建议拼写 依赖系统 provider 与外部词典,安装结果不完全由 Python 包锁定
wordfreq 查询多语言词频 能排序候选,但不能确定是否应保留自然连字符
PyHyphen / Pyphen 使用 TeX/Hunspell 断词模式找合法断点 正向排版断词不等于逆向 OCR 去断词
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 语义修复应由上游转换器、专用后处理器或项目明确规则负责。

来源:

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 枚举

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 精确规则

@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 后、位于物理换行前的显式文本。它可以是 "-",也可以是空字符串:

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 是完整的替换片段;库不会自动拼接 leftseparatorright

自动词典不可避免会遇到项目专名和自然连字符例外。为让映射表能明确否决而不是只能增加修改,再提供同样不可变的 精确保护规则:

@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 左右正则规则

@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 语义编译,不启用 MULTILINEDOTALL,因为物理行边界由修改器负责。

示例:

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 只指定右侧的行尾正则规则

@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、物理行尾、允许的空行和右侧重复 块前缀:

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 自动词典规则

普通英文断词不要求逐词映射。调用方配置一条语言级规则,修改器从每个合格边界提取左右单词片段并自动查询本地词典:

@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

示例配置一次后可以复用于任意英文文档:

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-\npleinter-\nnational 等候选,不需要为 exampleinternational 分别写映射。 规则表在这里表达的是“什么文本形态、用哪个词典、比较哪些候选、阈值多保守”,不是穷举英语单词。

左右 pattern 分别锚定逻辑行尾和行首,整个非空匹配就是候选片段。separator 本轮只允许 "-"""。 修改器生成调用方列出的候选:

候选 输出
JOINED left + right
HYPHENATED left + "-" + right
SPACED left + " " + right

规则必须包含 JOINED,并包含与源边界对应的竞争形式:separator="-" 时包含 HYPHENATED,空 separator 时包含 SPACED。这样“拼接词在词典中”不会自动压过同样合理的自然连字符或两个独立单词。

backend 行为:

  • SPELLCHECKERSpellChecker.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_scoreminimum_score_margin 是 backend 内部的阈值,不是跨 backend 的统一物理量。对 WORDFREQ,二者 都是 Zipf 尺度;对 SPELLCHECKERminimum_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 旧三元组兼容

以下调用继续有效:

mapped_line_join((("exam-", "ple", "example"),))

旧三元组等价于一条精确规则:left 保持原值、separator=""、删除换行、区分大小写、不做 Unicode 规范化、 启用词边界并允许四种文本块。它不会被改写成词典或正则规则。

旧三元组没有 rule_id。工厂按声明位置为审计和冲突消息生成 legacy.0000legacy.0001 等内部 ID; 因此旧映射的声明顺序也会进入参数记录。这里保留的是调用语法和精确映射能力,不承诺继续在代码块或表格中修改, 因为本文明确把这两类区域改为始终排除。

旧类型别名保留:

LineJoinMapping = tuple[str, str, str]
LineJoinRule = ExactLineJoinRule | KeepLineJoinRule | RegexLineJoinRule | LineEndRegexRule | LexicalLineJoinRule

工厂签名为:

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 禁止跨空行。这里只接受类型严格为 int01bool、负数和 2 以上都拒绝。两个及以上连续空行更像 结构边界;在没有 OCR provenance、版面置信度或真实样本证据时,本轮不允许用一个任意整数把搜索扩大到更远段落。 如果后续证据表明某个上游稳定地产生多空行误断,应以有上限的新设计明确扩大范围,而不是静默放宽本参数。

5. 匹配与替换语义

5.1 逻辑内容和锚点

修改器仍使用 _text_ranges.pyPhysicalLinephysical_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

例如两条显式规则可以把:

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
  • 精确规则的 leftright 和 replacement 非空;separator 可以为空;
  • 正则可以编译,左右正则规则的两侧及行尾正则规则的右侧不会只产生零长度匹配;
  • 捕获组均有名称、左右名称不重复、replacement 引用存在;
  • 行尾正则规则不接受 PRESERVE
  • 自动词典规则的 separator 只能是 "-"""pattern 必须消费非空片段;
  • 自动词典规则必须包含 JOINED 和对应源形式,候选形式不能重复;
  • backend、非空语言代码、有限且非负的 minimum_score / minimum_score_margin 和可选 Pyphen 语言合法;
  • 请求的 backend 或 Pyphen 不可用时明确报错,不创建行为不完整的修改器;
  • max_intervening_blank_lines 的类型严格为 int 且只能是 01
  • 旧三元组仍拒绝空字段、换行和重复的 (left, right)

运行时冲突、无法映射的内部范围和不变量破坏抛出明确异常。普通无匹配、结构排除和不能唯一映射的 Unicode 候选是 失败关闭条件,返回无提议,不是异常。

每个实际链生成一个 ProposedChange 和一个 TextEditreason 包含所用 rule_id 序列,不记录未命中规则, 也不输出候选周围正文。Modifier.parameters 记录:

  • 调用顺序中的完整规则;
  • 规则类型、pattern/literal、replacement、separator、块范围、换行策略、优先级、大小写和规范化选项;
  • 冲突策略和允许的空行数。

修改器版本从 1.0.0 更新为 2.0.0,表示规则语义和参数记录发生变化。

11. 依赖和函数式边界

  • 核心安装仍只依赖 Python 标准库;
  • pyproject.toml 新增 lexical optional extra,包含受限版本的 pyspellcheckerpyphen
  • 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,不应用编辑或写文件。

pyspellcheckerwordfreq 自带的数据版本随包版本冻结;本轮不下载更新。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 聚合模块导出。受支持导入路径是:

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 自动把行尾和行首组成 JOINEDHYPHENATEDSPACED 候选;
  • 拼接词唯一命中自动合并,不需要逐词精确映射;
  • 拼接词与源形式都有可比分数时按分差选择,分差不足按 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 以及候选内混合行尾失败关闭;
  • 隔零个或一个空行的配置,以及 -1True2 被构造期拒绝;
  • 三行及以上链式合并、同一合并后逻辑行继续匹配;
  • 一篇文档中的多个独立链按原文顺序报告;
  • FIRSTPRIORITY、同优先级 tie-break 和 ERROR
  • 默认区分大小写和显式忽略大小写;
  • 默认不规范化、NFC/NFD 匹配、不能唯一回映时失败关闭;
  • Unicode 词边界和显式关闭词边界;
  • 无效 dataclass 字段、正则、捕获组、replacement 引用和重复 rule_id 明确失败;
  • 无效 backend、语言、候选组合、score 阈值和 margin 明确失败;
  • WORDFREQ 候选按 Zipf margin 比较;SPELLCHECKER 单 token 与 SPACED/拆分验证的 HYPHENATED 同时存活时按不可排序歧义处理,不用 margin 强行选择;
  • 参数记录完整且确定;
  • 所有正向输出再次运行零修改;
  • 精确/正则规则不读取文件、网络、词典或环境;词典规则只读取已安装 extra 自带资源,且不访问网络或任意路径。

实现后运行根 README 届时列出的全部基础检查:

.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.mdCLAUDE.md 镜像;
  6. 运行第 13 节检查并查看 Git diff、wheel 内容和工作区状态。

批准本文不授权修改 _text_ranges.py、其他 modifier、其他 Wiki 文档、真实数据、外部系统, 也不授权提交、推送、创建 PR 或发布。