376 lines
18 KiB
Markdown
376 lines
18 KiB
Markdown
# mdpolish
|
||
|
||
`mdpolish` 是实验室共用的、项目无关的 Python Markdown 修改库。它提供函数式 `Modifier`、精确文本编辑执行器、
|
||
有序 `Pipeline`、正则修改器工厂,以及少量可以用合成样例完整说明的通用修改器。
|
||
|
||
当前发布版本是 [`v0.6.0`](https://github.com/Bepr4/mdpolish/releases/tag/v0.6.0)。库只处理内存中的 Markdown 字符串,
|
||
不读取或写入文件,不提供默认流水线,也不包含任何项目的规则集合、
|
||
数据清单、实验脚本或评审界面。
|
||
|
||
## 当前能力
|
||
|
||
| 能力 | 作用 | 明确边界 |
|
||
| --- | --- | --- |
|
||
| `Modifier` | 把不可变元数据与普通提议函数组合起来 | 函数只提议修改,不直接改字符串或文件 |
|
||
| 精确编辑执行器 | 校验快照、范围、原文、重复和冲突后原子应用一个批次 | 不判断项目业务语义 |
|
||
| `Pipeline` | 按调用方顺序运行修改器,并对最终快照做只读稳定性复查 | 不自动选规则、不重排、不循环执行 |
|
||
| `build_review_document()` | 验证并重放已有结果,提供可信阶段、位置、全文和错误/残留证据 | 不重新运行修改器,不猜测损坏或不完整的结果 |
|
||
| `render_markdown_report()` | 把评审视图编排成完整 Markdown 源码报告字符串 | 只返回内存字符串,不创建文件或业务页面 |
|
||
| `review_document_to_dict()` | 按 schema `1.0` 把评审视图投影成普通 JSON 基本值 | 单向投影,不反序列化或重新应用修改 |
|
||
| `render_json_report()` | 复用正式 dict 投影生成确定的内存 JSON 字符串 | 不创建文件;默认摘要不等于公开安全日志 |
|
||
| `mdpolish.text_ranges` | 返回 CR/LF/CRLF 物理行的精确不可变原文范围 | 不解析 Markdown 块,不自动执行或修改文本 |
|
||
| `regex_replace()` | 把非空正则匹配转换为精确编辑 | 不提供规则注册表、配置加载或默认模式 |
|
||
| `mapped_line_join()` | 用精确、正则或可选本地词典规则合并跨行片段 | 无默认规则;代码、表格、未知结构和歧义失败关闭 |
|
||
| HTML 表格修改器 | 处理严格表格子集的实体和单行布局 | 不是完整 HTML parser,也不是 HTML→GFM 转换器 |
|
||
|
||
一次运行会返回 `success`、`failed` 或 `unstable`:
|
||
|
||
- `success`:所选修改器完成运行,且对最终结果不再提出修改;
|
||
- `failed`:修改器、契约或编辑验证发生错误;
|
||
- `unstable`:运行没有错误,但最终复查仍发现有效候选修改。
|
||
|
||
`success` 只代表本次选择的修改器已经稳定,不代表文档不存在其他质量问题。
|
||
|
||
## 安装
|
||
|
||
正式版本只通过 GitHub tag 和 GitHub Release 交付,不发布到 PyPI 或其他 Python 包索引。使用项目可以固定
|
||
不可移动的 tag:
|
||
|
||
```bash
|
||
python -m pip install 'mdpolish @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0'
|
||
python -m pip install 'mdpolish[lexical] @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0'
|
||
python -m pip install 'mdpolish[frequency] @ git+https://github.com/Bepr4/mdpolish.git@v0.6.0'
|
||
```
|
||
|
||
也可以安装同一 GitHub Release 附带的 wheel:
|
||
|
||
```bash
|
||
python -m pip install 'mdpolish[lexical] @ https://github.com/Bepr4/mdpolish/releases/download/v0.6.0/mdpolish-0.6.0-py3-none-any.whl'
|
||
```
|
||
|
||
Release 页面同时提供 wheel 的 SHA-256 校验值。仓库或 Release 如果是私有的,调用方需要自行配置 GitHub 访问权限;
|
||
库不会保存凭据。开发环境仍从本地工作树安装:
|
||
|
||
```bash
|
||
python -m venv .venv
|
||
.venv/bin/python -m pip install -e '.[dev]'
|
||
```
|
||
|
||
核心运行时只依赖 Python 标准库,支持 Python 3.11 及以上版本。自动词典规则需要调用方明确安装并选择对应 extra:
|
||
|
||
```bash
|
||
python -m pip install '/path/to/mdpolish[lexical]' # pyspellchecker + Pyphen
|
||
python -m pip install '/path/to/mdpolish[frequency]' # wordfreq,体积和传递依赖更大
|
||
```
|
||
|
||
安装 extra 不会自动启用规则,也不会触发在线下载或改变精确/正则规则行为。
|
||
|
||
## 组装自己的流水线
|
||
|
||
项目拥有规则、参数和顺序。下面的正则规则与两个通用修改器最终都是 `Modifier`:
|
||
|
||
```python
|
||
from mdpolish import Pipeline, RunStatus, regex_replace
|
||
from mdpolish.modifiers import html_table_entity_unescape, mapped_line_join
|
||
|
||
normalize_spaces = regex_replace(
|
||
modifier_id="my_project.normalize_spaces",
|
||
version="1.0.0",
|
||
pattern=r" {2,}",
|
||
replacement=" ",
|
||
applicability="把正文中连续两个及以上的 ASCII 空格收敛为一个;项目需自行排除不适用区域。",
|
||
)
|
||
|
||
join_fragments = mapped_line_join(
|
||
(
|
||
("exam-", "ple", "example"),
|
||
("rule-", "based", "rule-based"),
|
||
)
|
||
)
|
||
|
||
pipeline = Pipeline(
|
||
(
|
||
normalize_spaces,
|
||
join_fragments,
|
||
html_table_entity_unescape(),
|
||
)
|
||
)
|
||
|
||
result = pipeline.transform("an exam-\nple text")
|
||
if result.status is not RunStatus.SUCCESS:
|
||
raise RuntimeError(f"cleaning did not succeed: {result.status}")
|
||
|
||
assert result.output_markdown == "an example text"
|
||
```
|
||
|
||
## 自动英文断词
|
||
|
||
普通英文断词不需要逐词维护映射。调用方安装 `lexical` extra 后,可以明确选择 `pyspellchecker` 的本地词典,
|
||
并用 Pyphen 排除不合法的断词位置:
|
||
|
||
```python
|
||
from mdpolish import Pipeline, RunStatus
|
||
from mdpolish.modifiers import mapped_line_join
|
||
from mdpolish.modifiers.mapped_line_join import (
|
||
LexicalCandidateForm,
|
||
LexicalLineJoinRule,
|
||
LexiconBackend,
|
||
)
|
||
|
||
join_english_wraps = mapped_line_join(
|
||
(
|
||
LexicalLineJoinRule(
|
||
rule_id="english.dehyphenate",
|
||
left_pattern=r"[A-Za-z]+",
|
||
right_pattern=r"[a-z]+",
|
||
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",
|
||
case_sensitive=False,
|
||
),
|
||
)
|
||
)
|
||
|
||
result = Pipeline((join_english_wraps,)).transform("an exam-\nple")
|
||
if result.status is not RunStatus.SUCCESS:
|
||
raise RuntimeError(f"cleaning did not succeed: {result.status}")
|
||
|
||
assert result.output_markdown == "an example"
|
||
```
|
||
|
||
`pyspellchecker` 的内置语言词典不支持大小写敏感查询,因此这个 backend 要求调用方明确写出
|
||
`case_sensitive=False`;库不会替调用方改变该开关或切换 backend。上面的分数和 margin 只用于演示完整配置,
|
||
不是适合所有项目的生产阈值。`JOINED` 与源 `HYPHENATED` 会共同参与候选选择;词典不能确认唯一结果时保持原文。
|
||
|
||
库不会保存 `output_markdown`。调用项目应先检查状态,再自行决定写入位置:
|
||
|
||
```python
|
||
from pathlib import Path
|
||
|
||
source_path = Path("input.md")
|
||
output_path = Path("output.md")
|
||
|
||
result = pipeline.transform(source_path.read_text(encoding="utf-8"))
|
||
if result.status is RunStatus.SUCCESS and result.output_markdown is not None:
|
||
output_path.write_text(result.output_markdown, encoding="utf-8")
|
||
```
|
||
|
||
文件读取、输出命名、覆盖策略、批处理和 CLI 都属于调用项目。`mdpolish` 可以生成通用的内存评审视图、机器投影、JSON
|
||
字符串与 Markdown 报告字符串,但不会自动保存它们,也不知道报告来自哪个文件。
|
||
|
||
## 构建内存评审视图和报告
|
||
|
||
调用方保留原始 Markdown,并把它与 `TransformResult` 一起传给评审构建函数:
|
||
|
||
```python
|
||
from mdpolish.review import (
|
||
build_review_document,
|
||
render_json_report,
|
||
render_markdown_report,
|
||
review_document_to_dict,
|
||
)
|
||
|
||
input_markdown = "an exam-\nple text"
|
||
result = pipeline.transform(input_markdown)
|
||
|
||
review = build_review_document(input_markdown, result)
|
||
review_summary = review_document_to_dict(review)
|
||
report_json = render_json_report(review, detail="changes")
|
||
report_markdown = render_markdown_report(review)
|
||
|
||
assert review.current_markdown == "an example text"
|
||
assert review_summary["schema_version"] == "1.0"
|
||
assert review_summary["detail"] == "summary"
|
||
assert report_json.startswith('{\n "schema_name": "mdpolish.review"')
|
||
assert report_markdown.startswith("# mdpolish review report\n")
|
||
```
|
||
|
||
`build_review_document()` 不调用 `Modifier.propose()`,而是从输入开始重放已经记录的实际修改,逐阶段验证修改器身份、快照
|
||
哈希、范围、原文、冲突、排序和阶段结果。无法证明结果一致时会抛出 `ReviewBuildError`,不会生成近似报告。
|
||
|
||
成功结果的 `stages_complete` 为 `True`。`unstable` 和只有 final review 错误的 `failed` 也已经完成全部 transform 阶段,
|
||
但其当前全文仍明确标记为部分输出;preflight 或 transform 失败只包含失败前能够证明完成的阶段。
|
||
|
||
Markdown reporter 会包含完整输入、当前全文、统一 diff 以及实际修改的 `before` / `after`,因此不是安全日志。它默认只列出
|
||
前 20 条残留候选,并且不重复输出残留候选正文;调用项目如果保存报告,仍需负责路径、权限、脱敏和保留周期。每个完整
|
||
阶段都会保留前后快照,当前版本没有承诺无限文档长度或修改器数量下的内存上限。
|
||
|
||
机器投影使用独立于包版本的 schema `1.0`,并提供三个显式 detail:
|
||
|
||
- `summary`:默认值;只含状态、哈希、计数、modifier 身份、阶段摘要和稳定错误代码,不含正文承载字段;
|
||
- `changes`:再加入 modifier 参数、适用说明、实际修改片段、错误消息和未应用残留候选;
|
||
- `full`:再加入完整输入、当前全文和每个阶段的完整前后全文。
|
||
|
||
高 detail 可能还原敏感内容。即使 `summary` 不含正文,它仍然携带项目元数据和哈希,不能自动视为匿名或适合公开传播。
|
||
dict 和 JSON 都是单向派生视图,不用于恢复 `ReviewDocument` 或重新应用修改。完整 schema、坐标、哈希和兼容口径见
|
||
[`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。
|
||
|
||
## 扫描精确物理行
|
||
|
||
项目 Modifier 如果需要识别独占行、检查相邻行或连同行尾删除一行,可以按原文 code point 范围扫描:
|
||
|
||
```python
|
||
from mdpolish.text_ranges import physical_lines
|
||
|
||
source = "first\r\n\r\nlast"
|
||
lines = physical_lines(source)
|
||
|
||
assert lines[0].content(source) == "first"
|
||
assert lines[0].line_ending(source) == "\r\n"
|
||
assert lines[1].is_empty
|
||
assert source[lines[2].content_start : lines[2].full_end] == "last"
|
||
```
|
||
|
||
扫描只识别 LF、CR 和 CRLF,不规范化原文,也不判断段落、标题、列表、引用、代码或表格。它不会成为 Pipeline 的全局预处理;
|
||
只有显式调用它的 Modifier 才会扫描当前阶段快照。精确空文档、尾换行、offset 和失败关闭口径见
|
||
[`physical-line-ranges.md`](research-wiki/reference/physical-line-ranges.md)。
|
||
|
||
## 编写项目自己的修改器
|
||
|
||
复杂规则使用普通函数返回精确候选修改,不需要继承库基类:
|
||
|
||
```python
|
||
from mdpolish import DocumentSnapshot, Modifier, ProposedChange, TextEdit, TextSpan
|
||
|
||
|
||
def propose_marker_removal(snapshot: DocumentSnapshot) -> tuple[ProposedChange, ...]:
|
||
marker = "[REMOVE-ME]"
|
||
start = snapshot.markdown.find(marker)
|
||
if start < 0:
|
||
return ()
|
||
end = start + len(marker)
|
||
return (
|
||
ProposedChange(
|
||
snapshot_sha256=snapshot.sha256,
|
||
reason="删除项目确认过的占位标记",
|
||
edits=(
|
||
TextEdit(
|
||
snapshot_sha256=snapshot.sha256,
|
||
span=TextSpan(start, end),
|
||
expected_text=marker,
|
||
replacement="",
|
||
),
|
||
),
|
||
),
|
||
)
|
||
|
||
|
||
remove_marker = Modifier(
|
||
modifier_id="my_project.remove_marker",
|
||
version="1.0.0",
|
||
parameters={"marker": "[REMOVE-ME]"},
|
||
applicability="只删除项目声明的完整占位标记;不处理近似文本。",
|
||
propose=propose_marker_removal,
|
||
)
|
||
```
|
||
|
||
修改器决定“建议改哪里、为什么改、改成什么”;公共执行器真正修改内存字符串。每项 `TextEdit` 都绑定当前快照哈希、
|
||
半开字符串范围、预期原文和替换文本。任意候选无效时,该修改器当前批次不会产生部分修改。
|
||
|
||
项目函数应保持确定且无副作用,不读取文件、网络、环境变量、当前时间或随机数。Python 无法沙箱隔离任意函数;项目
|
||
函数私下产生的外部副作用不属于本库的验证或审计范围。
|
||
|
||
## 通用修改器的严格边界
|
||
|
||
`mapped_line_join()` 保留 `(left, right, replacement)` 三元组,也接受模块
|
||
`mdpolish.modifiers.mapped_line_join` 中的不可变规则值。规则可以使用精确片段、两侧命名正则、只约束右侧的行尾正则,
|
||
或从行尾与行首自动生成 `JOINED` / `HYPHENATED` / `SPACED` 候选并查询显式选择的本地词典。自动规则不要求逐词维护
|
||
映射;精确规则和 `KeepLineJoinRule` 用于项目词、例外与否决。
|
||
|
||
调用方还要显式决定块范围、换行处理、冲突策略、大小写和 Unicode 规范化。默认区分大小写且不规范化;支持相邻行或
|
||
中间最多一个同风格空行,并可在一次提议内完成多行链式合并。保守词法扫描只正向识别段落、ATX 标题 continuation、
|
||
列表 continuation 和同深度引用;代码块、GFM pipe table、raw HTML table、混合候选行尾及无法确认的容器保持原文。
|
||
完整公共模型、选择流程和限制见
|
||
[`0009-generalized-mapped-line-join.md`](research-wiki/design/0009-generalized-mapped-line-join.md),真实 backend 验收、
|
||
版本身份和 GitHub 发布边界见
|
||
[`0010-first-cross-project-library-delivery.md`](research-wiki/design/0010-first-cross-project-library-delivery.md)。
|
||
|
||
`html_table_entity_unescape()` 只在严格完整的 `<td>` / `<th>` 文本中处理 `&lt;`、`&gt;` 和
|
||
`&amp;`。`html_table_layout()` 只调整严格单行表格的外层行布局,并保留标签、属性和单元格内容。
|
||
|
||
这两个 HTML 修改器采用保守的词法子集,不识别 Markdown 围栏。混合换行、嵌套标签、`<tbody>`、嵌套表格或损坏
|
||
结构会保持原样。
|
||
|
||
## 仓库结构
|
||
|
||
```text
|
||
src/mdpolish/
|
||
├── models.py # 不可变快照、精确编辑、审计和结果模型
|
||
├── modifier.py # 函数式 Modifier 契约
|
||
├── edits.py # 批次验证与原子应用
|
||
├── pipeline.py # 有序执行与最终稳定性复查
|
||
├── review.py # 可信评审视图、机器投影及内存 JSON/Markdown reporter
|
||
├── regex.py # 正则修改器工厂
|
||
├── text_ranges.py # 公共精确物理行范围
|
||
└── modifiers/ # 少量项目无关的通用修改器
|
||
tests/ # 只使用虚构文本的核心与通用修改器测试
|
||
research-wiki/
|
||
├── design/ # 已批准决策及被冻结的历史记录
|
||
├── explanation/ # 当前有效机制
|
||
├── reference/ # 代码无法完整表达的稳定查询事实
|
||
├── guides/ # 已实际验证的操作步骤
|
||
└── scratch/ # 不作为当前事实的本地草稿
|
||
```
|
||
|
||
## 当前不提供
|
||
|
||
- 文件适配器、公共 CLI、配置文件、profile 或批处理协议;
|
||
- 自动规则发现、注册表或默认流水线;
|
||
- Markdown AST、完整 HTML parser 或必装的第三方运行依赖;
|
||
- artifact、自动保存的报告文件、正式 JSON Schema 文件、HTML reporter、Web/桌面评审器或项目审核流程;
|
||
- 任何业务项目的规则、固定参数、文档 ID、数据或验收统计。
|
||
|
||
公共边界与原因见
|
||
[`0008-generic-functional-library-boundary.md`](research-wiki/design/0008-generic-functional-library-boundary.md),当前机制见
|
||
[`functional-modifier-core.md`](research-wiki/explanation/functional-modifier-core.md)。通用内存评审能力的批准边界见
|
||
[`0011-generic-review-projection-and-reporting.md`](research-wiki/design/0011-generic-review-projection-and-reporting.md),当前机制见
|
||
[`review-projection.md`](research-wiki/explanation/review-projection.md)。正式机器投影的批准边界见
|
||
[`0012-review-document-machine-projection.md`](research-wiki/design/0012-review-document-machine-projection.md),schema `1.0` 的稳定
|
||
查询口径见 [`review-projection-schema-v1.md`](research-wiki/reference/review-projection-schema-v1.md)。精确物理行公共接口的边界见
|
||
[`0013-public-physical-line-ranges.md`](research-wiki/design/0013-public-physical-line-ranges.md),稳定查询口径见
|
||
[`physical-line-ranges.md`](research-wiki/reference/physical-line-ranges.md)。旧 design 只保存历史决策,不代表当前交付能力。
|
||
|
||
## 当前可用检查
|
||
|
||
在已经安装开发依赖的仓库环境中运行:
|
||
|
||
```bash
|
||
.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
|
||
```
|
||
|
||
检查本地变更:
|
||
|
||
```bash
|
||
git diff --check
|
||
git status --short
|
||
```
|
||
|
||
上述检查已于 2026-08-27 实际运行。Python 3.13.11 核心开发环境中 Ruff 和 mypy 通过,pytest 为
|
||
`211 passed, 3 skipped`;三个 skip 是该环境没有安装的真实 optional backend 路径,不计入发布验收。
|
||
|
||
`mdpolish-0.4.0-py3-none-any.whl` 共 17 个文件,包含 `review.py` 和 `py.typed`,不包含 tests、Wiki、artifact、页面或
|
||
真实数据。安装全部 extras 后,Python 3.11.16 和 Python 3.13.11 环境分别得到 `214 passed`,没有 skip;仓库外消费者
|
||
smoke test 已覆盖核心正则、`pyspellchecker + Pyphen`、`wordfreq`、评审视图和 Markdown reporter。核心运行依赖仍只有
|
||
Python 标准库。Release wheel 的 SHA-256 是
|
||
`12e24863314958130ed082f78e89ab8bc0dad39a3848f2ae42e273d19a409693`。
|
||
|
||
上述结果证明当前版本可安装并按合成契约运行,不代表任意词典阈值已经在真实业务语料上达到生产准确率。
|
||
|
||
`v0.6.0` 于 2026-08-28 在 Python 3.13.11 开发环境中实际得到:Ruff 和 mypy 通过,pytest 为
|
||
`290 passed, 3 skipped`;三个 skip 仍是没有安装的 optional backend。
|
||
|
||
Release wheel `mdpolish-0.6.0-py3-none-any.whl` 共 17 个文件,包含 `text_ranges.py`、`review.py` 和 `py.typed`,不包含
|
||
`_text_ranges.py`、tests、Wiki、报告或真实数据;在仓库外全新虚拟环境中无依赖安装后,版本、公共导入、精确混合行尾范围、
|
||
行尾集合、空行判断和 wheel 清单 smoke test 通过。Release wheel 的 SHA-256 是
|
||
`695502b1a443d4e98dbf63e8bdcee59452baea2185cb7e1e13160127f70c920f`。
|