# 精确物理行范围 本文记录 `mdpolish.text_ranges` 当前稳定的公共查询口径。实现和运行校验以 `src/mdpolish/text_ranges.py` 与测试为准;设计理由、方案取舍和施工边界见 [`0013-public-physical-line-ranges.md`](../design/0013-public-physical-line-ranges.md)。 ## 1. 公共入口 ```python 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)`: ```python 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`,并满足: ```text 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 中使用 物理行范围只是定位工具,不会自动成为修改: ```python 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="", ), ), ), ) ``` 项目仍负责判断标题、列表、引用、代码、表格和业务语义。公共编辑执行器仍负责验证快照哈希、范围、预期原文与冲突。