# 0013:公开精确物理行范围接口 ## 状态 已于 2026-08-28 获用户明确批准,按本文第 13 节实施。本文自批准起冻结;后续改变决策需新增 design 并使用 `supersedes` 指向本文。 `extends: 0008`:继续使用函数式 `Modifier`、不可变值、精确原文范围、无文件 I/O 和项目规则外置的公共库边界;本文只把 已经由多个通用 Modifier 共用的物理行范围工具变成受支持的扩展接口。 `extends: 0009`:`mapped_line_join()` 的 Markdown 块分类、代码/表格排除和跨行合并语义保持不变;本文只公开它已经使用的 底层物理行扫描,不公开其保守块扫描器。 本文不改变 `0012` 的机器投影 schema、detail 或报告行为。 ## 1. 问题与可观察现象 项目自定义 Modifier 最终要提交精确 `TextEdit`。只要规则依赖“完整独占一行”“删除自己的换行”“检查下一空行”或“聚合 重复行”,项目就必须先准确回答: - 这一行正文从哪里开始、在哪里结束; - 行尾是 LF、CR、CRLF,还是文档末尾没有换行; - 空行是否真的有零长度内容; - 删除范围是否包含行尾; - 下一条物理行在原始字符串中的准确位置是什么。 上游已经在私有模块 `mdpolish._text_ranges` 中实现: ```python PhysicalLine iter_physical_lines() physical_lines() line_ending_styles() ``` `mapped_line_join()` 使用它建立跨行候选,`html_table_layout()` 使用它检查混合换行。独立 wheel-pilot 的论文清洗设计又需要 同样的 LF、CRLF、CR、空行、无末尾换行和原文范围语义,但项目治理正确地禁止导入带下划线的私有模块,只能计划复制一份 扫描器。 这已经不是“某个项目想少写几行代码”。同一个确定性边界算法出现在上游多个通用 Modifier,并被独立消费项目再次需要。 如果项目复制实现,以下细节很容易漂移: - 把 CRLF 错当成两个换行; - 在末尾换行之后制造一条不存在的空行; - 把只有空格的行和零长度空行混为一谈; - 删除内容却遗留 `\r`; - 用累计字符串长度重新计算 offset 时产生偏移; - 上游修复边界后,项目副本继续使用旧语义。 因此,缺少的是“项目 Modifier 作者可以合法复用的精确物理行范围”,不是新的清洗规则或 Markdown parser。 ## 2. 目标与非目标 ### 2.1 目标 - 提供受支持的公共模块 `mdpolish.text_ranges`; - 公开不可变 `PhysicalLine` 和惰性/急切两种物理行扫描入口; - 精确保留 LF、CRLF、CR 和无末尾换行,不规范化源字符串; - 公开已存在的行尾样式集合查询; - 在首次公开前修正空行判断的误导命名,并明确零长度与空白字符的区别; - 给公共值对象和函数增加显式类型、范围和来源切片校验; - 让上游现有 Modifier 使用同一公共实现,不维护私有/公共两套算法; - 用合成文本直接固定空文档、空行、Unicode、混合换行和范围语义; - 保持核心安装零第三方运行依赖,无文件 I/O、网络或模型调用。 ### 2.2 非目标 - 不在 Pipeline 运行 Modifier 前自动扫描、分块或预处理 Markdown; - 不缓存或跨 Modifier 共享扫描结果;后一个 Modifier 仍读取前一阶段应用后的完整新快照; - 不解析段落、标题、列表、引用、代码块、表格、HTML 或 Markdown AST; - 不公开 `mapped_line_join()` 内部的 `_ScannedLine`、scope、结构排除或块分类器; - 不提供 `remove_matching_lines()`、行号删除器、页眉删除器、批注删除器或其他 Modifier 工厂; - 不定义“只含空格或 Tab 的行是否算 blank”,也不使用 Unicode `strip()` 暗中替项目作决定; - 不读取文件、不接收路径、不返回文件行号或改写文本; - 不把 range 自动转成 `TextEdit`,不绕过快照哈希、expected text 和批次验证; - 不修改 wheel-pilot、真实论文、外部数据或项目 design; - 不承诺通用文本编辑器、LSP、UTF-16 或 byte offset 接口。 ## 3. 方案比较 | 方案 | 优点 | 问题 | 选择 | | --- | --- | --- | --- | | 每个项目复制扫描器 | 上游不增加接口 | CRLF、尾换行和空行语义会漂移;多个项目重复测试 | 不采用 | | 项目直接导入 `mdpolish._text_ranges` | 零上游改动 | 私有路径没有兼容承诺,wheel 消费方被迫依赖内部实现 | 不采用 | | 把当前私有模块原样改成公共路径 | 改动最小 | `is_blank(markdown)` 命名和参数误导;没有直接公共契约测试或范围校验 | 不直接采用 | | 公开经过收窄和加固的精确物理行范围 | 一个算法来源;项目规则保持外置;接口小而可测试 | 增加需要兼容维护的公共模块 | 采用 | | 引入统一 Markdown parser / AST | 可共享高级结构 | 远超当前需求,并会改变所有 Modifier 的共同边界 | 不采用 | | Pipeline 预扫描并把行列表传给所有 Modifier | 避免重复扫描 | 改变 `ModifierFunction` 签名;快照每阶段变化使缓存身份复杂 | 不采用 | ## 4. 职责边界 采用后的调用关系为: ```text 完整 DocumentSnapshot │ ▼ 项目 Modifier.propose(snapshot) │ │ 只有该 Modifier 需要时调用 ▼ physical_lines(snapshot.markdown) │ ▼ PhysicalLine 原文范围 │ │ 项目判断业务证据 ▼ ProposedChange / TextEdit │ ▼ 公共执行器验证并原子应用 ``` | 层次 | 负责 | 不负责 | | --- | --- | --- | | `text_ranges` | 确定物理行内容与行尾范围 | 判断行的业务含义 | | 项目 Modifier | 根据项目证据选择范围和替换 | 重写 CR/LF 扫描算法 | | 编辑执行器 | 验证快照、范围、原文、冲突并应用 | 判断为什么要删除一行 | | Pipeline | 按顺序运行并稳定性复查 | 建立全局共享分块 | 公开接口不会在未调用时执行,也不会改变传给第一个 Modifier 的原始 Markdown。 ## 5. 第一版公共模块 第一版受支持的导入路径是: ```python from mdpolish.text_ranges import ( PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines, ) ``` 这些名称只从 `mdpolish.text_ranges` 导出。本轮不把它们再放进 `mdpolish.__init__`,避免包根继续堆积低层工具。 私有路径 `mdpolish._text_ranges` 从来不是公共契约。实现时把唯一算法移动到公共模块并更新上游内部导入,不保留另一份算法, 也不为未受支持的私有导入建立长期兼容别名。 ## 6. `PhysicalLine` 公共值对象 ### 6.1 字段 ```python @dataclass(frozen=True, slots=True) class PhysicalLine: """一个源字符串中物理行内容及可选行尾的精确范围。""" content_start: int content_end: int full_end: int ``` 范围均使用 Python 字符串的 Unicode code point index: ```text content_start content_end full_end │ │ │ ▼ ▼ ▼ one physical line content\r\n └──────── content ───────┘└ line ending ┘ └──────────── complete physical line ────┘ ``` - 完整物理行范围是 `[content_start, full_end)`; - 内容范围是 `[content_start, content_end)`; - 行尾范围是 `[content_end, full_end)`; - 最后一行没有行尾时 `content_end == full_end`; - `content_start` 同时是完整物理行的开始,不另设重复字段; - offset 不是 UTF-8 byte、UTF-16 code unit、终端显示列或 1-based 人类行列。 ### 6.2 构造校验 `__post_init__()` 必须显式拒绝: - 字段不是严格 `int`,包括 `bool`; - 任一字段为负数; - 不满足 `content_start <= content_end <= full_end`; - `full_end - content_end > 2`,因为受支持的物理行尾最长是 CRLF 两个 code point。 这些检查不能证明手工构造的范围一定来自某个 source;它们只保证值对象局部自洽。权威正常用法是消费扫描函数返回的对象。 ### 6.3 内容和行尾访问 ```python def content(self, source: str) -> str: ... def line_ending(self, source: str) -> str: ... ``` 两个方法都必须: - 要求 `source` 是 `str`; - 确认 `full_end <= len(source)`; - 根据保存的半开范围返回精确切片,不规范化或复制其他字符。 `line_ending()` 还要确认切片严格属于: ```python "" "\n" "\r" "\r\n" ``` 否则抛出 `ValueError`,不把手工构造的错误范围静默当作合法行尾。正常扫描结果不会触发这个错误。 范围对象不保存 source 或快照哈希。这样保留单个 `PhysicalLine` 不会隐式持有整篇文档,也不会在每条行对象中重复相同哈希。 项目最终建立 `TextEdit` 时,现有 snapshot SHA-256 和 expected text 校验仍负责拒绝跨快照范围。 ### 6.4 零长度行判断 当前私有方法: ```python line.is_blank(markdown) ``` 完全没有读取 `markdown`,实际只判断 `content_start == content_end`。公开前改成只读属性: ```python @property def is_empty(self) -> bool: return self.content_start == self.content_end ``` 它的语义固定为“内容范围长度为零”: | 原始物理行 | `is_empty` | | --- | ---: | | `"\n"` | `True` | | `"\r\n"` | `True` | | `""` | 不会由空文档产生 PhysicalLine | | `" \n"` | `False` | | `"\t\n"` | `False` | 本轮不保留或公开 `is_blank` 别名。它原来属于私有模块,没有公共兼容承诺;继续保留会同时留下误导名称和无用 source 参数。 项目若要把 ASCII space / Tab 行定义为空白,应明确写出自己的条件,例如: ```python line.content(source).strip(" \t") == "" ``` 是否允许其他 Unicode whitespace 属于项目规则,不能由底层工具默认扩大。 ## 7. 扫描函数 ### 7.1 惰性扫描 ```python def iter_physical_lines(source: str) -> Iterator[PhysicalLine]: ... ``` - 调用时先验证 `source` 是 `str`,非法类型立即抛出 `TypeError`,不等到第一次迭代才暴露; - 单次从左到右扫描,不预先复制 source; - 只把 `\n`、`\r`、`\r\n` 识别为行尾; - CRLF 是一个物理行尾,范围长度为两个 code point; - Unicode line separator、paragraph separator、vertical tab、form feed 和 NUL 都保留在内容中; - 产生顺序严格按 source offset 递增,范围不重叠且完整覆盖 source; - 返回迭代器,不承诺可以重复迭代同一对象。 为保证非法输入在函数调用时立即失败,实现可以使用普通包装函数返回私有 generator;公共签名和可观察行为以上述契约为准。 ### 7.2 急切扫描 ```python def physical_lines(source: str) -> tuple[PhysicalLine, ...]: ... ``` 它返回 `iter_physical_lines()` 的完整不可变 tuple,不另写扫描逻辑。适合需要前后行、重复组或多次遍历的 Modifier。 ### 7.3 行尾样式 ```python def line_ending_styles(source: str) -> frozenset[str]: ... ``` 它返回扫描结果中实际出现的非空行尾集合,只可能包含: ```python "\n" "\r" "\r\n" ``` 无换行或空文档返回空 `frozenset`。它复用 `iter_physical_lines()`,不维护另一份 `splitlines()` 逻辑。 ## 8. 精确边界示例 以下 tuple 写作 `(content_start, content_end, full_end)`: | source | 扫描结果 | | --- | --- | | `""` | `()` | | `"a"` | `((0, 1, 1),)` | | `"a\n"` | `((0, 1, 2),)` | | `"\n"` | `((0, 0, 1),)` | | `"\r\n"` | `((0, 0, 2),)` | | `"a\n\n"` | `((0, 1, 2), (2, 2, 3))` | | `"\ntext"` | `((0, 0, 1), (1, 5, 5))` | | `"a\r\nb\rc\n"` | `((0, 1, 3), (3, 4, 5), (5, 6, 7))` | | `"a\u2028b"` | `((0, 3, 3),)`,U+2028 属于内容 | 特别说明: - 末尾有行尾不额外产生一条虚构空行; - `"a\n\n"` 的第二个 `\n` 是一条真实零长度物理行; - 空文档没有任何物理行; - 拼接所有 `[content_start, full_end)` 切片必须精确还原 source。 这些行为保持当前私有扫描算法的结果,不改变现有 Modifier 的换行语义。 ## 9. 与 Markdown 和编辑契约的关系 `text_ranges` 不根据 Markdown 语义改变任何范围。例如: ````markdown # Heading > quote ```text code ``` ```` 对它来说都只是物理行。标题、引用和围栏只有调用它的 Modifier 才能解释。 范围也不自动成为修改。项目仍必须构造: ```python TextEdit( snapshot_sha256=snapshot.sha256, span=TextSpan(line.content_start, line.full_end), expected_text=snapshot.markdown[line.content_start : line.full_end], replacement="", ) ``` 执行器随后验证哈希、范围和原文。物理行接口不能跳过这层保护,也不承诺手工构造的 `PhysicalLine` 自动安全。 ## 10. 源码、兼容与版本 批准后计划: ```text src/mdpolish/ ├── text_ranges.py # 公共唯一实现 └── modifiers/ ├── mapped_line_join.py # 改用公共模块路径 └── html_table_layout.py # 改用公共模块路径 tests/ └── test_text_ranges.py # 公共接口直接测试 research-wiki/reference/ └── physical-line-ranges.md # 稳定边界查询口径 ``` 实现时删除私有源码文件 `_text_ranges.py`,不保留双份算法。`mdpolish._text_ranges` 从未受支持;本轮不为私有导入提供弃用期。 现有公开的 `Modifier`、`Pipeline`、`TextSpan`、`mapped_line_join()` 和 HTML Modifier 签名、身份、参数、输出及审计行为不变。 这是新增公共扩展接口,计划包版本为 `0.6.0`。`v0.5.0` 的机器投影保持独立交付范围;如果 `v0.5.0` 尚未完成合并和发布, 本功能仍使用单独提交与验证记录,不把两个功能写成同一项能力。 批准后实现完成并验证时才更新根 README 的当前能力、候选版本和实际检查结果。设计批准不自动授权 commit、push、tag、 GitHub Release 或 wheel 发布。 ## 11. 测试与验收 ### 11.1 公共扫描契约 直接测试至少覆盖: - 第 8 节所有精确范围; - 空文档、单行、首行为空、连续空行和无末尾换行; - 纯 LF、纯 CRLF、纯 CR 和混合行尾; - 中文、补充平面字符、组合字符、BOM、U+2028、NUL、Tab 和 form feed; - 每条 `content()`、`line_ending()` 与 source 精确切片一致; - 所有完整行切片按顺序拼接后逐 code point 等于原 source; - `physical_lines(source) == tuple(iter_physical_lines(source))`; - `line_ending_styles()` 只返回实际非空样式; - `is_empty` 只对零长度内容为真,space / Tab 不自动算空; - 相同 source 重复扫描得到值相等、顺序相同的不可变结果。 ### 11.2 失败关闭 至少拒绝: - 扫描函数接收非 `str`,包括 bytes、Path 和 `None`; - dataclass 字段为 `bool`、float、字符串或其他非严格 int; - 负数、逆序范围和超过两个 code point 的行尾范围; - `content()` / `line_ending()` 接收非字符串或比 `full_end` 更短的 source; - `line_ending()` 指向不是 `""` / LF / CR / CRLF 的切片。 错误使用 `TypeError` 或 `ValueError`,消息只说明字段和契约,不输出 source 内容。 ### 11.3 现有行为回归 - `mapped_line_join()` 全部精确、正则、词典、结构、换行和链式测试结果不变; - `html_table_layout()` 的单/混合行尾行为不变; - 其他核心、评审和机器投影测试不受影响; - mypy strict 和 Ruff 通过; - 核心运行依赖仍为空。 ### 11.4 Wheel 消费 构建候选 wheel 后在仓库外全新虚拟环境确认: ```python from mdpolish.text_ranges import PhysicalLine, iter_physical_lines, line_ending_styles, physical_lines ``` 可以导入并得到第 8 节结果。wheel 必须包含 `mdpolish/text_ranges.py` 和 `py.typed`,不包含 tests、Wiki、真实数据或报告。 本轮不修改 wheel-pilot。它升级正式 wheel、删除私有扫描计划和验证四个项目 Modifier,仍需在消费仓自己的未冻结 design 中 调整并获得授权。 ## 12. 风险与代价 - **公共表面积增加:** range 字段、空文档和尾换行语义发布后需要兼容维护;直接测试和 reference 用于锁定行为。 - **低层工具可能被误认为 Markdown parser:** 模块、README 和 docstring 都要明确它只识别 CR/LF,不解释块结构。 - **每个 Modifier 可能重复 O(n) 扫描:** 当前快照会逐阶段变化,简单重扫比跨阶段缓存更可靠;有真实性能证据后再设计共享索引。 - **手工范围仍可能来自错误 source:** 访问器做局部验证,最终 `TextEdit` 的快照和 expected text 继续承担权威保护。 - **删除私有路径可能影响越界使用者:** 下划线路径从未承诺兼容;已知正式消费项目明确没有导入它。 - **`is_empty` 不等于 Markdown blank line:** 这是有意边界;项目必须显式定义 space、Tab 或 Unicode whitespace。 - **新版本依赖发布顺序:** wheel-pilot 只有安装含公共模块的正式 wheel 后才能删除自己的扫描计划,不能从相邻源码树偷导入。 ## 13. 批准后的实施边界 用户明确批准本文后,只授权: 1. 把私有物理行唯一实现迁移到 `mdpolish.text_ranges`,实现第 5 至 8 节公共契约; 2. 更新两个现有通用 Modifier 的内部导入和 `is_empty` 调用,不改变其清洗语义; 3. 增加第 11 节合成测试、reference 和 README 说明; 4. 把包版本候选更新为 `0.6.0`,执行根 README 当时列出的全套检查和 wheel smoke test; 5. 保留并隔离工作区已有的 `AGENTS.md`、`CLAUDE.md`、`src/mdpolish/regex.py` 等用户改动。 批准本文不授权: - 提交、push、创建 PR、tag、GitHub Release 或上传 wheel; - 修改 wheel-pilot、真实文档、外部数据或其他仓库; - 新增 Markdown parser、全局预处理、共享扫描缓存、通用删除 Modifier、CLI 或文件适配器; - 改变现有清洗规则、Pipeline 顺序、机器投影 schema 或报告内容。