18 KiB
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 中实现:
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. 职责边界
采用后的调用关系为:
完整 DocumentSnapshot
│
▼
项目 Modifier.propose(snapshot)
│
│ 只有该 Modifier 需要时调用
▼
physical_lines(snapshot.markdown)
│
▼
PhysicalLine 原文范围
│
│ 项目判断业务证据
▼
ProposedChange / TextEdit
│
▼
公共执行器验证并原子应用
| 层次 | 负责 | 不负责 |
|---|---|---|
text_ranges |
确定物理行内容与行尾范围 | 判断行的业务含义 |
| 项目 Modifier | 根据项目证据选择范围和替换 | 重写 CR/LF 扫描算法 |
| 编辑执行器 | 验证快照、范围、原文、冲突并应用 | 判断为什么要删除一行 |
| Pipeline | 按顺序运行并稳定性复查 | 建立全局共享分块 |
公开接口不会在未调用时执行,也不会改变传给第一个 Modifier 的原始 Markdown。
5. 第一版公共模块
第一版受支持的导入路径是:
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 字段
@dataclass(frozen=True, slots=True)
class PhysicalLine:
"""一个源字符串中物理行内容及可选行尾的精确范围。"""
content_start: int
content_end: int
full_end: int
范围均使用 Python 字符串的 Unicode code point index:
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 内容和行尾访问
def content(self, source: str) -> str:
...
def line_ending(self, source: str) -> str:
...
两个方法都必须:
- 要求
source是str; - 确认
full_end <= len(source); - 根据保存的半开范围返回精确切片,不规范化或复制其他字符。
line_ending() 还要确认切片严格属于:
""
"\n"
"\r"
"\r\n"
否则抛出 ValueError,不把手工构造的错误范围静默当作合法行尾。正常扫描结果不会触发这个错误。
范围对象不保存 source 或快照哈希。这样保留单个 PhysicalLine 不会隐式持有整篇文档,也不会在每条行对象中重复相同哈希。
项目最终建立 TextEdit 时,现有 snapshot SHA-256 和 expected text 校验仍负责拒绝跨快照范围。
6.4 零长度行判断
当前私有方法:
line.is_blank(markdown)
完全没有读取 markdown,实际只判断 content_start == content_end。公开前改成只读属性:
@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 行定义为空白,应明确写出自己的条件,例如:
line.content(source).strip(" \t") == ""
是否允许其他 Unicode whitespace 属于项目规则,不能由底层工具默认扩大。
7. 扫描函数
7.1 惰性扫描
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 急切扫描
def physical_lines(source: str) -> tuple[PhysicalLine, ...]:
...
它返回 iter_physical_lines() 的完整不可变 tuple,不另写扫描逻辑。适合需要前后行、重复组或多次遍历的 Modifier。
7.3 行尾样式
def line_ending_styles(source: str) -> frozenset[str]:
...
它返回扫描结果中实际出现的非空行尾集合,只可能包含:
"\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 语义改变任何范围。例如:
# Heading
> quote
```text
code
```
对它来说都只是物理行。标题、引用和围栏只有调用它的 Modifier 才能解释。
范围也不自动成为修改。项目仍必须构造:
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. 源码、兼容与版本
批准后计划:
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 后在仓库外全新虚拟环境确认:
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. 批准后的实施边界
用户明确批准本文后,只授权:
- 把私有物理行唯一实现迁移到
mdpolish.text_ranges,实现第 5 至 8 节公共契约; - 更新两个现有通用 Modifier 的内部导入和
is_empty调用,不改变其清洗语义; - 增加第 11 节合成测试、reference 和 README 说明;
- 把包版本候选更新为
0.6.0,执行根 README 当时列出的全套检查和 wheel smoke test; - 保留并隔离工作区已有的
AGENTS.md、CLAUDE.md、src/mdpolish/regex.py等用户改动。
批准本文不授权:
- 提交、push、创建 PR、tag、GitHub Release 或上传 wheel;
- 修改 wheel-pilot、真实文档、外部数据或其他仓库;
- 新增 Markdown parser、全局预处理、共享扫描缓存、通用删除 Modifier、CLI 或文件适配器;
- 改变现有清洗规则、Pipeline 顺序、机器投影 schema 或报告内容。