4.7 KiB
精确物理行范围
本文记录 mdpolish.text_ranges 当前稳定的公共查询口径。实现和运行校验以
src/mdpolish/text_ranges.py 与测试为准;设计理由、方案取舍和施工边界见
0013-public-physical-line-ranges.md。
1. 公共入口
from mdpolish.text_ranges import (
PhysicalLine,
iter_physical_lines,
line_ending_styles,
physical_lines,
)
这些名称只从 mdpolish.text_ranges 导出,不在包根重复导出。mdpolish._text_ranges 不是兼容路径。
模块只接收内存中的 str。它不读取文件、不改写文本、不建立 Markdown AST,也不会在 Pipeline 运行前自动执行。
2. 什么是物理行
物理行由以下任一边界结束:
| 原文边界 | 行尾字符串 | code point 长度 |
|---|---|---|
| LF | "\n" |
1 |
| CR | "\r" |
1 |
| CRLF | "\r\n" |
2 |
| 文档末尾 | "" |
0 |
CRLF 是一个物理行尾。U+2028、U+2029、vertical tab、form feed、NUL 以及其他字符都属于行内容,不会被当成物理行尾。 扫描不做 Unicode 或换行规范化。
空文档产生零条物理行。原文以行尾结束时,不会在末尾虚构额外空行;连续行尾之间确实存在的零长度行仍会返回。
3. PhysicalLine
PhysicalLine 是 dataclass(frozen=True, slots=True):
PhysicalLine(
content_start=0,
content_end=4,
full_end=6,
)
三个 offset 都是相对于同一个 Python str 的 0-based Unicode code point index:
- 内容范围:
[content_start, content_end); - 行尾范围:
[content_end, full_end); - 完整物理行范围:
[content_start, full_end)。
它们不是 UTF-8 byte、UTF-16 code unit、终端显示列或人类使用的 1-based 行列。
公共成员:
| 成员 | 结果 |
|---|---|
content(source) |
返回内容范围的精确切片 |
line_ending(source) |
返回 ""、LF、CR 或 CRLF |
is_empty |
内容范围长度是否为零 |
is_empty 不等于 Markdown blank line。" \n" 和 "\t\n" 的内容不是零长度,因此均为 False。项目若要把 space、Tab
或其他 Unicode whitespace 当作空白,必须在自己的 Modifier 中显式定义。
手工构造时,offset 必须是非负的严格 int,不接受 bool,并满足:
content_start <= content_end <= full_end
full_end - content_end <= 2
访问器还会拒绝非字符串来源、超出来源长度的范围,以及不是 ""、LF、CR、CRLF 的行尾切片。错误信息不包含原文。
4. 扫描函数
iter_physical_lines(source) 返回惰性迭代器,但会在函数调用当下检查 source 类型。它单次从左到右扫描,不预先复制原文。
physical_lines(source) 返回同一扫描结果的不可变 tuple,适合需要查看前后行或重复遍历的 Modifier。
line_ending_styles(source) 返回原文实际出现过的非空行尾集合,只可能包含 "\n"、"\r"、"\r\n"。空文档或
没有换行的文档返回空 frozenset。
5. 精确示例
下表的结果写作 (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),) |
按顺序拼接所有 source[line.content_start:line.full_end] 必须逐 code point 还原原文。
6. 在项目 Modifier 中使用
物理行范围只是定位工具,不会自动成为修改:
from mdpolish import DocumentSnapshot, ProposedChange, TextEdit, TextSpan
from mdpolish.text_ranges import physical_lines
def propose(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
lines = physical_lines(snapshot.markdown)
if not lines or lines[0].content(snapshot.markdown) != "[REMOVE-ME]":
return ()
line = lines[0]
expected = snapshot.markdown[line.content_start : line.full_end]
return (
ProposedChange(
snapshot_sha256=snapshot.sha256,
reason="删除项目确认的独占行标记",
edits=(
TextEdit(
snapshot_sha256=snapshot.sha256,
span=TextSpan(line.content_start, line.full_end),
expected_text=expected,
replacement="",
),
),
),
)
项目仍负责判断标题、列表、引用、代码、表格和业务语义。公共编辑执行器仍负责验证快照哈希、范围、预期原文与冲突。