From c31ac33eff82bf0ebdfcde1c147cf9272f54a3f4 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 17:55:30 +0800
Subject: [PATCH 01/12] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Markdown=20=E2=86=92?=
=?UTF-8?q?=20Docx=20=E8=BD=AC=E6=8D=A2=E5=8A=9F=E8=83=BD=20+=20=E4=BC=98?=
=?UTF-8?q?=E5=8C=96=20apply=20=E6=A8=A1=E5=BC=8F=E6=89=B9=E6=B3=A8?=
=?UTF-8?q?=E9=80=BB=E8=BE=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 `wordf md` 命令,支持将 Markdown 文件转换为格式化 .docx
- 基于 mistune 解析 Markdown AST,复用现有 FormatNode 树 + YAML 配置驱动的格式管线
- apply 模式采用"先写后 diff"策略:只对修正失败的残留差异写批注,已成功修正的不再产生噪音
- 修复标点检测批注误用红色的问题(标点 → 提醒 → 蓝色)
- 修复 NodeConfigRoot.__getattr__ 对缺失 key 返回 None 导致的 .enabled 崩溃
- 新增 17 个 Markdown 解析器单元测试
---
README.md | 5 +
docs/md-to-docx.md | 99 ++++++
pyproject.toml | 1 +
src/wordformat/cli.py | 35 +-
src/wordformat/config/models.py | 2 +-
src/wordformat/markdown/__init__.py | 0
src/wordformat/markdown/parser.py | 186 ++++++++++
src/wordformat/pipeline/context.py | 12 +-
src/wordformat/pipeline/orchestrate.py | 65 ++++
src/wordformat/pipeline/stages.py | 20 +-
src/wordformat/pipeline/stages_md.py | 74 ++++
src/wordformat/rules/body.py | 2 +-
src/wordformat/rules/node.py | 55 ++-
src/wordformat/style/comments.py | 12 +-
tests/api/test_api.py | 140 ++++++--
tests/classify/test_tag.py | 20 +-
tests/conftest.py | 34 +-
tests/markdown/__init__.py | 0
tests/markdown/test_parser.py | 132 +++++++
tests/rules/test_abstract.py | 23 +-
tests/rules/test_acknowledgement.py | 9 +-
tests/rules/test_body.py | 41 ++-
tests/rules/test_caption.py | 116 ++++--
tests/rules/test_heading.py | 26 +-
tests/rules/test_keywords.py | 14 +-
tests/rules/test_node.py | 47 +--
tests/rules/test_references.py | 9 +-
tests/style/test_defs.py | 364 +++++++++++++------
tests/style/test_diff.py | 185 +++++++---
tests/style/test_inheritance.py | 6 +-
tests/style/test_reader.py | 167 ++++++---
tests/style/test_writer.py | 117 ++++--
tests/test_coverage_boost.py | 79 +++--
tests/test_integration.py | 469 ++++++++++++++++++-------
tests/test_numbering.py | 63 +++-
tests/test_utils.py | 6 +-
tests/utils/test_text.py | 4 +
37 files changed, 2037 insertions(+), 602 deletions(-)
create mode 100644 docs/md-to-docx.md
create mode 100644 src/wordformat/markdown/__init__.py
create mode 100644 src/wordformat/markdown/parser.py
create mode 100644 src/wordformat/pipeline/stages_md.py
create mode 100644 tests/markdown/__init__.py
create mode 100644 tests/markdown/test_parser.py
diff --git a/README.md b/README.md
index a79252c..bdb44c1 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,7 @@
- **灵活的交互方式**:支持「生成结构文件→手动调整→执行校验」的分步流程,兼顾自动化与灵活性
### 实用功能
+- **Markdown 转 Word**:支持将 Markdown 文件直接转换为格式化后的 .docx 文档,保留标题层级结构并自动应用配置中的格式规范
- **自动批注生成**:在格式违规位置自动添加 Word 批注,标注问题类型和修正建议
- **文档结构可视化**:通过 `wordf tree` 命令以树形结构展示文档段落分类和层级关系,支持按类别过滤、显示置信度
- **格式一键修正**:支持根据规范自动修正部分常见格式问题(如标题字号、正文行距)
@@ -115,6 +116,9 @@ wordf af -d 论文.docx -c 配置.yaml -f 结构文件.json
# 5. 启动 Web 可视化界面
wordf startapi
# 然后在浏览器打开 http://127.0.0.1:8000
+
+# 6. Markdown 转 Word(从 Markdown 直接生成格式化后的 .docx)
+wordf md -d 论文.md -c 配置.yaml
```
### startapi命令行预览(推荐使用)
@@ -184,6 +188,7 @@ wordformat-skill/
- [安装指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/installation.md) - 环境要求和安装步骤
- [使用指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/usage.md) - 命令行、Python编程和API调用的详细使用方法
- [配置文件说明](https://github.com/AfishInLake/WordFormat/blob/master/docs/configuration.md) - 格式规范配置项和自定义配置方法
+- [Markdown 转 Word](https://github.com/AfishInLake/WordFormat/blob/master/docs/md-to-docx.md) - Markdown → Docx 转换功能介绍与使用说明
- [常见问题](https://github.com/AfishInLake/WordFormat/blob/master/docs/faq.md) - 常见问题及解决方案
- [贡献指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/contributing.md) - 如何为项目贡献代码和文档
- [技术架构](https://github.com/AfishInLake/WordFormat/blob/master/docs/architecture.md) - 项目的技术架构和实现原理
diff --git a/docs/md-to-docx.md b/docs/md-to-docx.md
new file mode 100644
index 0000000..5e7300c
--- /dev/null
+++ b/docs/md-to-docx.md
@@ -0,0 +1,99 @@
+# Markdown 转 Word 功能
+
+## 概述
+
+`wordf md` 命令支持将 Markdown 文件直接转换为格式化的 .docx 文档。Markdown 提供文本内容和文档结构(标题层级、段落划分),所有格式(字体、字号、段落样式、编号等)由 YAML 配置文件驱动,与现有的 docx 格式化流程共享同一套配置。
+
+## 使用场景
+
+- **论文初稿写作**:先用 Markdown 写论文草稿(语法简单、纯文本、易于版本控制),定稿后一键导出为符合学校格式规范的 Word 文档
+- **自动化报告生成**:在数据分析、实验报告等场景中,用脚本生成 Markdown 文件,再批量转换为格式统一的 Word 文档
+- **混合工作流**:团队中部分成员用 Markdown 协作,最终交付 Word 格式给导师/期刊/学校系统
+
+## 使用方法
+
+### 命令行
+
+```bash
+# 基本用法
+wordf md -d 论文.md -c 配置.yaml
+
+# 指定输出目录
+wordf md -d 论文.md -c 配置.yaml -o output/
+```
+
+### Python API
+
+```python
+from wordformat.pipeline.orchestrate import md_to_docx
+
+output_path = md_to_docx(
+ md_path="thesis.md",
+ config_path="config.yaml",
+ save_dir="output/",
+)
+print(f"生成文件: {output_path}")
+```
+
+## Markdown 语法映射
+
+| Markdown | 文档类别 |
+|----------|----------|
+| `# 标题` | heading_level_1(一级标题) |
+| `## 标题` | heading_level_2(二级标题) |
+| `### 标题` | heading_level_3(三级标题) |
+| `#### 标题` | heading_level_3(四级及以上统一映射为三级) |
+| 普通段落 | body_text(正文) |
+| `` | figure_image(图片占位) |
+
+列表、引用块、代码块等均展开为 body_text。
+
+## 工作流程
+
+```
+Markdown 文件
+ │
+ ▼ mistune 解析
+AST 块列表(heading / paragraph / list / table / ...)
+ │
+ ▼ 类别映射
+扁平段落列表 [{category, paragraph, ...}]
+ │
+ ▼ DocumentTreeBuilder
+FormatNode 文档树
+ │
+ ▼ DocumentCreationStage
+新建 .docx Document → 逐节点 add_paragraph
+ │
+ ▼ StyleDefinitionFixStage
+修正内置样式定义(Normal, Heading 1, ...)
+ │
+ ▼ FormattingExecutionStage
+应用 YAML 配置中的格式规则
+ │
+ ▼ PostProcessingStage
+标题自动编号 + 引用超链接
+ │
+ ▼ 保存
+{文件名}--生成版.docx
+```
+
+## 格式应用与批注
+
+md→docx 的格式应用逻辑与现有 `wordf af`(apply format)流程一致:
+
+- **段落样式**(对齐、间距、缩进)和**字符样式**(字体、字号、颜色)由 YAML 配置定义,自动应用到每个段落
+- apply 模式先应用格式,再 diff 检查残留差异——只有实际无法修正的问题才生成批注,已成功修正的变更不产生噪音
+- 业务规则(标点检测、图片对齐等)照常运行,发现的问题以批注形式标注在文档中
+- 批注颜色由问题严重等级决定:**错误**为红色,**提醒**为蓝色
+
+## 注意事项
+
+- Markdown 仅提供结构和文本,所有格式完全由 YAML 配置控制——配置文件的质量决定了输出文档的格式合规度
+- 引用半角标点(如英文引号 `""`)不会自动转换,标点检测规则会以批注形式提醒
+- 图片语法 `` 生成的是文本占位段,不会真正插入图片文件
+- 如需对生成的 docx 做进一步检查,可先 `wordf gj` 生成 JSON,再 `wordf cf` 校验
+
+## 依赖
+
+Markdown 解析基于 [mistune](https://github.com/lepture/mistune) 库(>=3.0.0),支持标准 Markdown 语法及表格、删除线、链接等扩展。
diff --git a/pyproject.toml b/pyproject.toml
index 26fd853..b775006 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,7 @@ classifiers = [
dependencies = [
"fastapi>=0.128.1",
"loguru>=0.7.3",
+ "mistune>=3.0.0",
"numpy>=1.24.0",
"onnxruntime>=1.17.0",
"pydantic>=2.12.5",
diff --git a/src/wordformat/cli.py b/src/wordformat/cli.py
index 351d340..ebb7306 100644
--- a/src/wordformat/cli.py
+++ b/src/wordformat/cli.py
@@ -13,7 +13,7 @@
from rich.console import Console
from wordformat.classify.tag import set_tag_main
-from wordformat.pipeline.orchestrate import auto_format_thesis_document
+from wordformat.pipeline.orchestrate import auto_format_thesis_document, md_to_docx
from wordformat.settings import VERSION
from wordformat.tree import print_tree
@@ -90,6 +90,7 @@ def main():
wordf af 自动格式化论文
wordf tree 查看文档结构树
wordf config 查看所有可配置字段
+wordf md Markdown 转 Docx
wordf startapi 启动API服务
【一键示例】
@@ -97,6 +98,7 @@ def main():
wordf cf -d 论文.docx -c config.yaml -f output/xxx.json -o output/
wordf af -d 论文.docx -c config.yaml -f output/xxx.json -o output/
wordf tree -f output/xxx.json
+wordf md -d thesis.md -c config.yaml -o output/
wordf config
wordf startapi -H 127.0.0.1 -p 8000
==================================================
@@ -206,7 +208,25 @@ def main():
)
# ------------------------------
- # 6. startapi = 启动API服务
+ # 6. md = Markdown 转 Docx
+ # ------------------------------
+ p_md = subparsers.add_parser("md", help="Markdown 转 Docx")
+ p_md.add_argument(
+ "-d",
+ required=True,
+ type=lambda x: validate_file(x, "Markdown文件", [".md"]),
+ help="Markdown 文件路径",
+ )
+ p_md.add_argument(
+ "-c",
+ default=None,
+ type=lambda x: validate_file(x, "配置", [".yaml", ".yml"]),
+ help="YAML 配置路径(可选)",
+ )
+ p_md.add_argument("-o", default="output/", help="输出目录(默认output/)")
+
+ # ------------------------------
+ # 7. startapi = 启动API服务
# ------------------------------
p_startapi = subparsers.add_parser("startapi", help="启动API服务")
p_startapi.add_argument(
@@ -231,7 +251,7 @@ def _validate_port(x):
args = parser.parse_args()
# 只在需要输出目录的命令中创建目录
- if args.mode in ["gj", "cf", "af"]:
+ if args.mode in ["gj", "cf", "af", "md"]:
output_dir = Path(args.o)
output_dir.mkdir(parents=True, exist_ok=True)
@@ -330,6 +350,15 @@ def _validate_port(x):
else:
_show_config()
+ elif args.mode == "md":
+ logger.info("📝 开始 Markdown → Docx 转换...")
+ md_to_docx(
+ md_path=args.d,
+ config_path=args.c,
+ save_dir=args.o,
+ )
+ logger.success(f"✅ 转换完成!文件保存在:{args.o}")
+
elif args.mode == "startapi":
logger.info("🚀 启动API服务...")
logger.info(f"🌐 服务地址:http://{args.host}:{args.port}")
diff --git a/src/wordformat/config/models.py b/src/wordformat/config/models.py
index 6b6ef03..d543fd4 100644
--- a/src/wordformat/config/models.py
+++ b/src/wordformat/config/models.py
@@ -87,7 +87,7 @@ def _walk_config_for_styles(obj, style_map: dict[str, object]) -> None:
if not isinstance(obj, dict):
return
eng_name = _resolve_builtin_style_name(obj)
- if eng_name:
+ if eng_name and isinstance(eng_name, str):
style_map[eng_name] = obj
for _key, val in obj.items():
if isinstance(val, dict):
diff --git a/src/wordformat/markdown/__init__.py b/src/wordformat/markdown/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/wordformat/markdown/parser.py b/src/wordformat/markdown/parser.py
new file mode 100644
index 0000000..524956a
--- /dev/null
+++ b/src/wordformat/markdown/parser.py
@@ -0,0 +1,186 @@
+#! /usr/bin/env python
+# @Time : 2026/7/12
+# @Author : afish
+# @File : parser.py
+"""Markdown 解析器,基于 mistune AST 将 Markdown 文本转为扁平段落列表。"""
+
+from __future__ import annotations
+
+from typing import Any
+
+import mistune
+
+
+def parse_markdown(md_text: str) -> list[dict[str, Any]]:
+ """解析 Markdown 文本,返回与现有 JSON 格式兼容的扁平段落列表。
+
+ category 映射:
+ h1 → heading_level_1, h2 → heading_level_2, h3+ → heading_level_3
+ 普通段落 → body_text
+ 仅含图片的段落 → figure_image
+
+ 每个段落附带 inline_marks,记录行内格式片段(bold/italic/code 等),
+ 供 DocumentCreationStage 创建多 run 段落时使用。
+ """
+ md = mistune.create_markdown(
+ renderer=None, plugins=["table", "strikethrough", "url"]
+ )
+ ast = md(md_text)
+ result: list[dict[str, Any]] = []
+ _walk_blocks(ast, result)
+ return result
+
+
+# ---------------------------------------------------------------------------
+# 内部辅助
+# ---------------------------------------------------------------------------
+
+
+def _walk_blocks(blocks: list[dict], result: list[dict]) -> None:
+ for block in blocks:
+ btype = block["type"]
+
+ if btype == "blank_line" or btype == "thematic_break":
+ continue
+
+ if btype == "heading":
+ result.append(_make_heading(block))
+ elif btype in ("paragraph", "block_text"):
+ item = _make_paragraph(block)
+ if item:
+ result.append(item)
+ elif btype == "table":
+ result.append(_make_table(block))
+ elif btype == "block_code":
+ result.append(_make_code_block(block))
+ elif btype == "list":
+ _walk_list(block, result)
+ elif btype == "block_quote":
+ _walk_blocks(block.get("children", []), result)
+
+
+def _make_heading(block: dict) -> dict:
+ level = block["attrs"]["level"]
+ category = f"heading_level_{min(level, 3)}"
+ text = _extract_text(block.get("children", []))
+ segments = _extract_segments(block.get("children", []))
+ return {
+ "category": category,
+ "paragraph": text,
+ "score": 1.0,
+ "inline_marks": segments,
+ }
+
+
+def _make_paragraph(block: dict) -> dict | None:
+ children = block.get("children", [])
+
+ # 仅含图片的段落 → figure_image
+ non_empty = [c for c in children if c["type"] != "softbreak"]
+ if len(non_empty) == 1 and non_empty[0]["type"] == "image":
+ return {
+ "category": "figure_image",
+ "paragraph": non_empty[0]["attrs"].get("url", ""),
+ "score": 1.0,
+ "inline_marks": [],
+ }
+
+ text = _extract_text(children)
+ if not text.strip():
+ return None
+ segments = _extract_segments(children)
+ return {
+ "category": "body_text",
+ "paragraph": text,
+ "score": 1.0,
+ "inline_marks": segments,
+ }
+
+
+def _make_table(block: dict) -> dict:
+ rows: list[str] = []
+ for child in block.get("children", []):
+ if child["type"] in ("table_head", "table_body"):
+ for row in child.get("children", []):
+ cells = [
+ _extract_text(cell.get("children", []))
+ for cell in row.get("children", [])
+ ]
+ rows.append(" | ".join(cells))
+ text = "\n".join(rows)
+ return {
+ "category": "body_text",
+ "paragraph": text,
+ "score": 1.0,
+ "inline_marks": [],
+ }
+
+
+def _make_code_block(block: dict) -> dict:
+ text = block.get("raw", "")
+ return {
+ "category": "body_text",
+ "paragraph": text,
+ "score": 1.0,
+ "inline_marks": [{"text": text, "code": True}],
+ }
+
+
+def _walk_list(block: dict, result: list[dict]) -> None:
+ for item in block.get("children", []):
+ if item["type"] == "list_item":
+ for child in item.get("children", []):
+ _walk_blocks([child], result)
+
+
+def _extract_text(children: list[dict]) -> str:
+ """从 inline children 提取纯文本。"""
+ parts: list[str] = []
+
+ def walk(nodes):
+ for node in nodes:
+ if node["type"] == "text":
+ parts.append(node["raw"])
+ elif node["type"] == "softbreak":
+ parts.append(" ")
+ elif node["type"] == "linebreak":
+ parts.append("\n")
+ elif "children" in node:
+ walk(node["children"])
+
+ walk(children)
+ return "".join(parts)
+
+
+def _extract_segments(children: list[dict]) -> list[dict]: # noqa: C901
+ """从 inline children 提取带格式标记的文本片段。"""
+ segments: list[dict] = []
+
+ def walk(nodes, attrs): # noqa: C901
+ for node in nodes:
+ ntype = node["type"]
+ if ntype == "text":
+ if node["raw"]:
+ segments.append({"text": node["raw"], **attrs})
+ elif ntype in ("strong", "emphasis", "strikethrough"):
+ extra = {
+ "strong": "bold",
+ "emphasis": "italic",
+ "strikethrough": "strikethrough",
+ }[ntype]
+ walk(node["children"], {**attrs, extra: True})
+ elif ntype == "link":
+ walk(node["children"], {**attrs, "link_url": node["attrs"]["url"]})
+ elif ntype == "image":
+ segments.append(
+ {"text": "", **attrs, "image_url": node["attrs"]["url"]}
+ )
+ elif ntype == "codespan":
+ segments.append({"text": node.get("raw", ""), **attrs, "code": True})
+ elif ntype == "softbreak":
+ segments.append({"text": " ", **attrs})
+ elif ntype == "linebreak":
+ segments.append({"text": "\n", **attrs})
+
+ walk(children, {})
+ return segments
diff --git a/src/wordformat/pipeline/context.py b/src/wordformat/pipeline/context.py
index 5562626..36cb943 100644
--- a/src/wordformat/pipeline/context.py
+++ b/src/wordformat/pipeline/context.py
@@ -14,11 +14,15 @@
@dataclass
class FormatContext:
- json_path: str
- docx_path: str
- check: bool
- config_path: str
+ json_path: str = ""
+ docx_path: str = ""
+ check: bool = False
+ config_path: str = ""
save_dir: str = "/output"
+ # MD → Docx 专用
+ md_path: str = ""
+ md_text: str = ""
+ paragraphs: list = field(default_factory=list)
# 运行时对象(由各阶段填充)
document: DocumentObject | None = None
root_node: FormatNode = None
diff --git a/src/wordformat/pipeline/orchestrate.py b/src/wordformat/pipeline/orchestrate.py
index 4b513e0..9713649 100644
--- a/src/wordformat/pipeline/orchestrate.py
+++ b/src/wordformat/pipeline/orchestrate.py
@@ -9,6 +9,7 @@
if TYPE_CHECKING:
from wordformat.pipeline import PipelineStage
+from wordformat.log_config import logger
from wordformat.pipeline.context import FormatContext
from wordformat.pipeline.stages import (
DocumentSavingStage,
@@ -22,6 +23,11 @@
TreeBuildingStage,
TreeNormalizationStage,
)
+from wordformat.pipeline.stages_md import (
+ DocumentCreationStage,
+ LoadMarkdownStage,
+ MarkdownParseStage,
+)
def auto_format_thesis_document(
@@ -90,3 +96,62 @@ def auto_format_thesis_document(
for stage in pipeline:
ctx = stage.process(ctx)
return ctx.output_path
+
+
+def md_to_docx(
+ md_path: str,
+ config_path: str | None = None,
+ save_dir: str = "output/",
+):
+ """将 Markdown 文件转换为格式化后的 .docx 文档。
+
+ 流程:
+ 1. 加载 YAML 格式配置(可选);
+ 2. 读取并解析 Markdown 文件为扁平段落列表;
+ 3. 构建 FormatNode 文档树;
+ 4. 创建新的 Word 文档,逐节点生成段落并填入文本;
+ 5. 修正样式定义;
+ 6. 应用格式规则;
+ 7. 后处理(标题编号、引用超链接);
+ 8. 保存输出 .docx 文件。
+
+ Args:
+ md_path: Markdown 源文件路径。
+ config_path: YAML 格式规范配置文件路径,为 None 时使用内置默认配置。
+ save_dir: 输出目录。
+
+ Returns:
+ 生成的 .docx 文件路径。
+ """
+ from pathlib import Path
+
+ from wordformat.utils import ensure_directory_exists, get_file_name
+
+ ctx = FormatContext(
+ md_path=md_path,
+ config_path=config_path or "",
+ save_dir=save_dir,
+ check=False,
+ )
+
+ pipeline: list[PipelineStage] = [
+ LoadConfigStage(),
+ LoadMarkdownStage(),
+ MarkdownParseStage(),
+ TreeBuildingStage(),
+ DocumentCreationStage(),
+ StyleDefinitionFixStage(),
+ FormattingExecutionStage(),
+ PostProcessingStage(),
+ ]
+
+ for stage in pipeline:
+ ctx = stage.process(ctx)
+
+ # 重命名输出文件
+ ensure_directory_exists(save_dir)
+ filename = get_file_name(md_path)
+ out_path = Path(save_dir) / f"{filename}--生成版.docx"
+ ctx.document.save(str(out_path))
+ logger.info(f"保存文件到 {out_path}")
+ return str(out_path)
diff --git a/src/wordformat/pipeline/stages.py b/src/wordformat/pipeline/stages.py
index 8675562..e6cb928 100644
--- a/src/wordformat/pipeline/stages.py
+++ b/src/wordformat/pipeline/stages.py
@@ -83,12 +83,14 @@ def process(self, ctx: FormatContext) -> FormatContext:
class TreeBuildingStage:
- """构建tree pipline"""
+ """构建tree pipline。
+ 优先使用 ctx.paragraphs(内存中的段落列表,如 MD 解析结果),
+ 否则从 ctx.json_path 加载 JSON 文件。
+ """
def process(self, ctx: FormatContext) -> FormatContext:
- ctx.root_node = DocumentBuilder.build_from_json(
- ctx.json_path, config=ctx.config_model
- )
+ source = ctx.paragraphs if ctx.paragraphs else ctx.json_path
+ ctx.root_node = DocumentBuilder.build_from_json(source, config=ctx.config_model)
return ctx
@@ -304,6 +306,9 @@ def process(self, ctx: FormatContext) -> FormatContext:
class FormattingExecutionStage:
"""执行格式化/检查(核心遍历)"""
+ def __init__(self, skip_comments: bool = False):
+ self.skip_comments = skip_comments
+
def apply_format_check_to_all_nodes(
self, root_node: FormatNode, document, config, check=True
):
@@ -376,6 +381,8 @@ def traverse(node, parent_category="", current_chapter: int = 0):
node.apply_replace(document)
if check:
node.check_format(document)
+ elif self.skip_comments:
+ node.apply_style(document)
else:
node.apply_format(document)
except Exception as e:
@@ -542,13 +549,14 @@ def process(self, ctx: FormatContext) -> FormatContext:
return ctx
config_model = ctx.config_model
# 标题编号
- if hasattr(config_model, "numbering") and config_model.numbering.enabled:
+ numbering = config_model.numbering
+ if numbering and getattr(numbering, "enabled", False):
from wordformat.numbering import process_heading_numbering
process_heading_numbering(
ctx.root_node,
ctx.document,
- config_model.numbering,
+ numbering,
config_model.headings,
)
diff --git a/src/wordformat/pipeline/stages_md.py b/src/wordformat/pipeline/stages_md.py
new file mode 100644
index 0000000..4fbcafd
--- /dev/null
+++ b/src/wordformat/pipeline/stages_md.py
@@ -0,0 +1,74 @@
+#! /usr/bin/env python
+# @Time : 2026/7/12
+# @Author : afish
+# @File : stages_md.py
+"""Markdown → Docx 转换的 pipeline 阶段。
+
+Markdown 仅提供文本内容和文档结构(标题层级、段落划分),
+所有格式(字体、字号、段落样式)由 YAML 配置 + FormatNode 统一处理。
+"""
+
+from pathlib import Path
+
+from docx import Document
+
+from wordformat.log_config import logger
+from wordformat.markdown.parser import parse_markdown
+from wordformat.rules.node import FormatNode
+
+from .context import FormatContext
+
+
+class LoadMarkdownStage:
+ """读取 Markdown 文件到内存。"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ md_path = Path(ctx.md_path)
+ if not md_path.exists():
+ raise FileNotFoundError(f"Markdown 文件不存在: {md_path}")
+ ctx.md_text = md_path.read_text(encoding="utf-8")
+ logger.info(f"已加载 Markdown 文件: {md_path.resolve()}")
+ return ctx
+
+
+class MarkdownParseStage:
+ """解析 Markdown 为扁平段落列表(仅提取纯文本,行内标记丢弃)。"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ ctx.paragraphs = parse_markdown(ctx.md_text)
+ logger.info(f"解析完成,共 {len(ctx.paragraphs)} 个段落")
+ return ctx
+
+
+class DocumentCreationStage:
+ """从 FormatNode 树创建新的 .docx Document。
+
+ 每个节点创建一个段落,单 run 填充纯文本。
+ 格式由后续的 FormattingExecutionStage 统一应用。
+ """
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ ctx.document = Document()
+ nodes = self._flatten_tree_nodes(ctx.root_node)
+ logger.info(f"开始创建文档,共 {len(nodes)} 个节点")
+
+ for node in nodes:
+ value = node.value
+ text = value.get("paragraph", "") if isinstance(value, dict) else str(value)
+ para = ctx.document.add_paragraph()
+ para.add_run(text.strip())
+ node.paragraph = para
+
+ return ctx
+
+ @staticmethod
+ def _flatten_tree_nodes(root_node: FormatNode) -> list[FormatNode]:
+ result: list[FormatNode] = []
+
+ def dfs(node):
+ for child in node.children:
+ result.append(child)
+ dfs(child)
+
+ dfs(root_node)
+ return result
diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py
index dce3fdd..1e8a8c2 100644
--- a/src/wordformat/rules/body.py
+++ b/src/wordformat/rules/body.py
@@ -138,7 +138,7 @@ def _check_punctuation(self, doc, rule_cfg, p: bool = False):
else self.paragraph.runs
)
msg = (
- f"{self.NODE_LABEL}-提醒-标点符号问题:"
+ f"{self.NODE_LABEL}-提醒-标点问题:"
f'使用了半角英文标点符号"{half}(英文)",'
f'规范:应使用全角中文标点符号"{full}(中文)"'
)
diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py
index 9049fb1..af86cd7 100644
--- a/src/wordformat/rules/node.py
+++ b/src/wordformat/rules/node.py
@@ -177,14 +177,22 @@ def _handle_paragraph_style(self, doc, rule_cfg, p: bool):
ps = ParagraphStyle.from_config(cfg)
if p:
issues = ps.diff_from_paragraph(self.paragraph)
+ if issues:
+ self.add_comment(
+ doc=doc,
+ runs=self.paragraph.runs,
+ text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL),
+ )
else:
- issues = ps.apply_to_paragraph(self.paragraph)
- if issues:
- self.add_comment(
- doc=doc,
- runs=self.paragraph.runs,
- text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL),
- )
+ ps.apply_to_paragraph(self.paragraph)
+ # apply 后再次 diff,只报告修正失败的残留差异
+ remaining = ps.diff_from_paragraph(self.paragraph)
+ if remaining:
+ self.add_comment(
+ doc=doc,
+ runs=self.paragraph.runs,
+ text=ParagraphStyle.to_string(remaining, target=self.NODE_LABEL),
+ )
def _handle_character_style(self, doc, rule_cfg, p: bool):
"""默认字符样式检查/应用。配置需包含 chinese_font_name 等格式字段。"""
@@ -209,14 +217,24 @@ def _handle_character_style(self, doc, rule_cfg, p: bool):
continue
if p:
diff = cstyle.diff_from_run(run)
+ if diff:
+ self.add_comment(
+ doc=doc,
+ runs=run,
+ text=CharacterStyle.to_string(diff, target=self.NODE_LABEL),
+ )
else:
- diff = cstyle.apply_to_run(run)
- if diff:
- self.add_comment(
- doc=doc,
- runs=run,
- text=CharacterStyle.to_string(diff, target=self.NODE_LABEL),
- )
+ cstyle.apply_to_run(run)
+ # apply 后再次 diff,只报告修正失败的残留差异
+ remaining = cstyle.diff_from_run(run)
+ if remaining:
+ self.add_comment(
+ doc=doc,
+ runs=run,
+ text=CharacterStyle.to_string(
+ remaining, target=self.NODE_LABEL
+ ),
+ )
def _collect_comment(self, runs, text: str) -> None:
"""将批注文本加入缓冲区,(runs, text) 成对存储。"""
@@ -290,12 +308,19 @@ def check_format(self, doc: Document):
self._flush_comments(doc)
def apply_format(self, doc: Document):
- """格式应用:先清理、应用样式,再自动调度业务规则"""
+ """格式应用:先清理、应用样式,再写入批注记录变更。"""
self._clean_paragraph_edge_spaces()
self._base(doc, p=False, r=False)
self._run_rules(doc, p=False)
self._flush_comments(doc)
+ def apply_style(self, doc: Document):
+ """格式应用(无批注):仅应用样式,不生成批注。
+ 用于 md→docx 等从零生成文档的场景。"""
+ self._clean_paragraph_edge_spaces()
+ self._base(doc, p=False, r=False)
+ self._run_rules(doc, p=False)
+
def apply_replace(self, doc: Document = None) -> bool:
"""替换段落文本内容(由 JSON 的 replace 字段驱动)。
diff --git a/src/wordformat/style/comments.py b/src/wordformat/style/comments.py
index ca7d72e..488ff75 100644
--- a/src/wordformat/style/comments.py
+++ b/src/wordformat/style/comments.py
@@ -54,6 +54,7 @@
"字体颜色错误": "提醒",
"中文字体错误": "提醒",
"英文字体错误": "提醒",
+ "标点问题": "提醒",
"标点错误": "提醒",
}
@@ -67,11 +68,18 @@
def get_severity(comment_text: str) -> str:
- """从批注文本中提取严重等级。格式:位置-问题类型:..."""
+ """从批注文本中提取严重等级。
+
+ 支持两种格式:
+ - 位置-问题类型:... → 问题类型 = 首个 - 与 : 之间
+ - 位置-严重等级-问题类型:... → 问题类型 = 最后一段
+ """
try:
prop = comment_text.split("-", 1)[1].split(":")[0]
except (IndexError, AttributeError):
return _DEFAULT_SEVERITY
+ if "-" in prop:
+ prop = prop.rsplit("-", 1)[-1]
return SEVERITY_MAP.get(prop, _DEFAULT_SEVERITY)
@@ -138,6 +146,8 @@ def split_comment_line(line: str) -> list[Segment]:
if not sep:
return [(line, None)]
prop = prefix.split("-", 1)[1] if "-" in prefix else prefix
+ if "-" in prop:
+ prop = prop.rsplit("-", 1)[-1]
color = severity_color(prop)
segments: list[Segment] = [(prefix + sep, {"color": color} if color else None)]
if tail:
diff --git a/tests/api/test_api.py b/tests/api/test_api.py
index b5bca21..04a42de 100644
--- a/tests/api/test_api.py
+++ b/tests/api/test_api.py
@@ -3,6 +3,7 @@
覆盖模块:cli.py, config, agent, set_style.py, set_tag.py, word_structure
"""
+
import argparse
import io
import os
@@ -47,7 +48,9 @@
from wordformat.rules.body import BodyText
from wordformat.api import save_upload_file
-apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+)
# ==================== (h) CLI 集成测试 ====================
@@ -100,7 +103,14 @@ def test_main_gj_mode(self, mock_argv, mock_json, mock_set_tag):
out_dir = tempfile.mkdtemp()
try:
mock_argv.__getitem__.side_effect = lambda i: [
- "wf", "gj", "-d", docx_path, "-c", cfg_path, "-o", out_dir
+ "wf",
+ "gj",
+ "-d",
+ docx_path,
+ "-c",
+ cfg_path,
+ "-o",
+ out_dir,
][i]
mock_argv.__len__.return_value = 8
main()
@@ -123,13 +133,25 @@ def test_main_cf_mode(self, mock_argv, mock_auto):
out_dir = tempfile.mkdtemp()
try:
mock_argv.__getitem__.side_effect = lambda i: [
- "wf", "cf", "-d", docx_path, "-c", cfg_path, "-f", json_path, "-o", out_dir
+ "wf",
+ "cf",
+ "-d",
+ docx_path,
+ "-c",
+ cfg_path,
+ "-f",
+ json_path,
+ "-o",
+ out_dir,
][i]
mock_argv.__len__.return_value = 10
main()
mock_auto.assert_called_once_with(
- jsonpath=json_path, docxpath=docx_path,
- configpath=cfg_path, savepath=out_dir, check=True,
+ jsonpath=json_path,
+ docxpath=docx_path,
+ configpath=cfg_path,
+ savepath=out_dir,
+ check=True,
)
finally:
for p in [docx_path, cfg_path, json_path]:
@@ -148,13 +170,25 @@ def test_main_af_mode(self, mock_argv, mock_auto):
out_dir = tempfile.mkdtemp()
try:
mock_argv.__getitem__.side_effect = lambda i: [
- "wf", "af", "-d", docx_path, "-c", cfg_path, "-f", json_path, "-o", out_dir
+ "wf",
+ "af",
+ "-d",
+ docx_path,
+ "-c",
+ cfg_path,
+ "-f",
+ json_path,
+ "-o",
+ out_dir,
][i]
mock_argv.__len__.return_value = 10
main()
mock_auto.assert_called_once_with(
- jsonpath=json_path, docxpath=docx_path,
- configpath=cfg_path, savepath=out_dir, check=False,
+ jsonpath=json_path,
+ docxpath=docx_path,
+ configpath=cfg_path,
+ savepath=out_dir,
+ check=False,
)
finally:
for p in [docx_path, cfg_path, json_path]:
@@ -163,14 +197,15 @@ def test_main_af_mode(self, mock_argv, mock_auto):
@mock.patch("sys.argv")
def test_main_startapi_rejects_invalid_port(self, mock_argv):
- mock_argv.__getitem__.side_effect = lambda i: ["wf", "startapi", "-p", "99999"][i]
+ mock_argv.__getitem__.side_effect = lambda i: ["wf", "startapi", "-p", "99999"][
+ i
+ ]
mock_argv.__len__.return_value = 4
with mock.patch.dict("sys.modules", {"uvicorn": mock.MagicMock()}):
with pytest.raises(SystemExit):
main()
-
# ==================== (u) api/__init__.py 覆盖测试 ====================
@@ -179,10 +214,13 @@ class TestSaveUploadFile:
def test_save_upload_file_normal(self, tmp_path):
"""正常保存上传文件"""
- with mock.patch("wordformat.api.BASE_DIR", tmp_path), \
- mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), \
- mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"):
+ with (
+ mock.patch("wordformat.api.BASE_DIR", tmp_path),
+ mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"),
+ mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"),
+ ):
from wordformat.api import save_upload_file
+
wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True)
mock_file = mock.MagicMock()
@@ -196,9 +234,11 @@ def test_save_upload_file_normal(self, tmp_path):
def test_save_upload_file_name_conflict(self, tmp_path):
"""文件名冲突时自动重命名"""
- with mock.patch("wordformat.api.BASE_DIR", tmp_path), \
- mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), \
- mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"):
+ with (
+ mock.patch("wordformat.api.BASE_DIR", tmp_path),
+ mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"),
+ mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"),
+ ):
wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True)
# 预先创建同名文件
@@ -219,10 +259,13 @@ def test_save_upload_file_name_conflict(self, tmp_path):
def test_save_upload_file_multiple_conflicts(self, tmp_path):
"""多次冲突时递增后缀"""
- with mock.patch("wordformat.api.BASE_DIR", tmp_path), \
- mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), \
- mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"):
+ with (
+ mock.patch("wordformat.api.BASE_DIR", tmp_path),
+ mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"),
+ mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"),
+ ):
from wordformat.api import save_upload_file
+
wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True)
(wordformat.api.TEMP_DIR / "test.docx").write_bytes(b"a")
@@ -237,7 +280,6 @@ def test_save_upload_file_multiple_conflicts(self, tmp_path):
assert result.endswith("test_3.docx")
-
class TestAPIEndpoints:
"""覆盖 api/__init__.py 所有 API 端点"""
@@ -249,10 +291,13 @@ def api_client(self, tmp_path):
temp_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
- with mock.patch("wordformat.api.BASE_DIR", tmp_path), \
- mock.patch("wordformat.api.TEMP_DIR", temp_dir), \
- mock.patch("wordformat.api.OUTPUT_DIR", output_dir):
+ with (
+ mock.patch("wordformat.api.BASE_DIR", tmp_path),
+ mock.patch("wordformat.api.TEMP_DIR", temp_dir),
+ mock.patch("wordformat.api.OUTPUT_DIR", output_dir),
+ ):
from wordformat.api import app
+
client = TestClient(app)
yield client, temp_dir, output_dir
@@ -260,7 +305,14 @@ def test_generate_json_success(self, api_client):
"""POST /generate-json 成功调用 set_tag_main"""
client, temp_dir, output_dir = api_client
- mock_result = [{"category": "body_text", "score": 0.9, "paragraph": "test", "fingerprint": "fp1"}]
+ mock_result = [
+ {
+ "category": "body_text",
+ "score": 0.9,
+ "paragraph": "test",
+ "fingerprint": "fp1",
+ }
+ ]
with mock.patch("wordformat.api.set_tag_main", return_value=mock_result):
docx_bytes = io.BytesIO(b"fake docx")
yaml_bytes = io.BytesIO(b"key: value")
@@ -269,7 +321,11 @@ def test_generate_json_success(self, api_client):
"/generate-json",
files={
"docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ "config_file": (
+ "config.yaml",
+ yaml_bytes,
+ "application/octet-stream",
+ ),
},
)
@@ -303,7 +359,9 @@ def test_check_format_success(self, api_client):
client, temp_dir, output_dir = api_client
mock_result_path = str(output_dir / "test--标注版.docx")
- with mock.patch("wordformat.api.auto_format_thesis_document", return_value=mock_result_path):
+ with mock.patch(
+ "wordformat.api.auto_format_thesis_document", return_value=mock_result_path
+ ):
docx_bytes = io.BytesIO(b"fake docx")
yaml_bytes = io.BytesIO(b"key: value")
json_str = '[{"category": "body_text", "score": 0.9}]'
@@ -312,7 +370,11 @@ def test_check_format_success(self, api_client):
"/check-format",
files={
"docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ "config_file": (
+ "config.yaml",
+ yaml_bytes,
+ "application/octet-stream",
+ ),
},
data={"json_data": json_str},
)
@@ -328,7 +390,9 @@ def test_apply_format_success(self, api_client):
client, temp_dir, output_dir = api_client
mock_result_path = str(output_dir / "test--修改版.docx")
- with mock.patch("wordformat.api.auto_format_thesis_document", return_value=mock_result_path):
+ with mock.patch(
+ "wordformat.api.auto_format_thesis_document", return_value=mock_result_path
+ ):
docx_bytes = io.BytesIO(b"fake docx")
yaml_bytes = io.BytesIO(b"key: value")
json_str = '[{"category": "body_text", "score": 0.9}]'
@@ -337,7 +401,11 @@ def test_apply_format_success(self, api_client):
"/apply-format",
files={
"docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ "config_file": (
+ "config.yaml",
+ yaml_bytes,
+ "application/octet-stream",
+ ),
},
data={"json_data": json_str},
)
@@ -372,7 +440,9 @@ def test_generate_json_exception_returns_500(self, api_client):
"""POST /generate-json 异常时返回 500"""
client, temp_dir, output_dir = api_client
- with mock.patch("wordformat.api.set_tag_main", side_effect=RuntimeError("test error")):
+ with mock.patch(
+ "wordformat.api.set_tag_main", side_effect=RuntimeError("test error")
+ ):
docx_bytes = io.BytesIO(b"fake docx")
yaml_bytes = io.BytesIO(b"key: value")
@@ -380,20 +450,24 @@ def test_generate_json_exception_returns_500(self, api_client):
"/generate-json",
files={
"docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ "config_file": (
+ "config.yaml",
+ yaml_bytes,
+ "application/octet-stream",
+ ),
},
)
assert response.status_code == 500
-
class TestCLIStartApiMode:
"""覆盖 cli.py startapi 模式(lines 151-169)"""
def test_startapi_mode_calls_uvicorn(self):
"""startapi 模式调用 uvicorn.run"""
import unittest.mock as um
+
# uvicorn 在函数内部动态导入,需要 mock import
with um.patch("sys.argv", ["wf", "startapi"]):
with um.patch.dict("sys.modules", {"uvicorn": um.MagicMock()}):
@@ -401,6 +475,6 @@ def test_startapi_mode_calls_uvicorn(self):
main()
# 验证 uvicorn.run 被调用
import sys
+
if "uvicorn" in sys.modules:
sys.modules["uvicorn"].run.assert_called_once()
-
diff --git a/tests/classify/test_tag.py b/tests/classify/test_tag.py
index fe66ef9..e439af1 100644
--- a/tests/classify/test_tag.py
+++ b/tests/classify/test_tag.py
@@ -3,6 +3,7 @@
覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py
"""
+
import os
import pytest
from io import StringIO
@@ -101,13 +102,16 @@ def test_parse_low_score_forced_to_body_text(self, tmp_path):
assert result[1]["category"] == "body_text"
assert "强制设为" in result[1]["comment"]
-
def test_parse_batch_failure_fallback_to_single(self, temp_docx):
"""测试批量推理失败时降级到单条推理"""
mock_single_result = {"label": "body_text", "score": 0.9}
- with patch("wordformat.base.onnx_batch_infer", side_effect=RuntimeError("ONNX error")):
- with patch("wordformat.base.onnx_single_infer", return_value=mock_single_result):
+ with patch(
+ "wordformat.base.onnx_batch_infer", side_effect=RuntimeError("ONNX error")
+ ):
+ with patch(
+ "wordformat.base.onnx_single_infer", return_value=mock_single_result
+ ):
base = DocxBase(temp_docx, "/fake/config.yaml")
result = base.parse()
@@ -212,7 +216,12 @@ def test_parse_includes_numbering_text(self, tmp_path):
path = str(tmp_path / "numbered.docx")
doc.save(path)
- with patch("wordformat.base.onnx_batch_infer", return_value=[{"text": "1. 绪论", "label": "body_text", "pred_id": 0, "score": 0.9}]):
+ with patch(
+ "wordformat.base.onnx_batch_infer",
+ return_value=[
+ {"text": "1. 绪论", "label": "body_text", "pred_id": 0, "score": 0.9}
+ ],
+ ):
base = DocxBase(path, "/fake/config.yaml")
result = base.parse()
@@ -224,6 +233,3 @@ def test_parse_includes_numbering_text(self, tmp_path):
# ============================================================
# utils.py — _format_number 额外覆盖测试
# ============================================================
-
-
-
diff --git a/tests/conftest.py b/tests/conftest.py
index 26e6a0b..be605ec 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -3,6 +3,7 @@
将所有测试文件中重复定义的 fixture 集中管理。
"""
+
import os
import json
import pytest
@@ -18,6 +19,7 @@
# ==================== Document Fixtures ====================
+
@pytest.fixture
def doc():
"""创建一个空的 Document 对象"""
@@ -202,6 +204,7 @@ def temp_json(tmp_path):
enabled: false
"""
+
@pytest.fixture
def config_path(tmp_path):
"""用内联 YAML 生成临时配置文件,不依赖外部文件。"""
@@ -215,19 +218,26 @@ def mock_config():
"""创建一个 mock 配置对象,模拟 NodeConfigRoot"""
config = MagicMock()
config.style_checks_warning = MagicMock(
- bold=True, italic=True, underline=True,
- font_size=True, font_name=True, font_color=True,
- alignment=True, space_before=True, space_after=True,
- line_spacing=True, line_spacingrule=True,
- left_indent=True, right_indent=True,
- first_line_indent=True, builtin_style_name=True,
+ bold=True,
+ italic=True,
+ underline=True,
+ font_size=True,
+ font_name=True,
+ font_color=True,
+ alignment=True,
+ space_before=True,
+ space_after=True,
+ line_spacing=True,
+ line_spacingrule=True,
+ left_indent=True,
+ right_indent=True,
+ first_line_indent=True,
+ builtin_style_name=True,
)
config.numbering = MagicMock(enabled=False)
return config
-
-
@pytest.fixture
def sample_yaml_config(tmp_path):
"""用内联 YAML 生成临时配置文件。"""
@@ -238,16 +248,18 @@ def sample_yaml_config(tmp_path):
# ==================== Mock Fixtures ====================
+
@pytest.fixture
def mock_onnx_infer():
"""Mock ONNX 推理函数,返回可控结果"""
+
def _infer(texts):
if isinstance(texts, str):
texts = [texts]
return [
- {"text": t, "label": "body_text", "pred_id": 0, "score": 0.9}
- for t in texts
+ {"text": t, "label": "body_text", "pred_id": 0, "score": 0.9} for t in texts
]
+
return _infer
@@ -261,6 +273,7 @@ def mock_onnx_single():
def reset_config():
"""每个测试前后自动清理配置状态"""
from wordformat.config.loader import clear_config
+
clear_config()
yield
clear_config()
@@ -270,6 +283,7 @@ def reset_config():
def reset_style_warning():
"""每个测试前后重置警告缓存。"""
from wordformat.style import diff
+
diff._warnings = None
yield
diff._warnings = None
diff --git a/tests/markdown/__init__.py b/tests/markdown/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/tests/markdown/test_parser.py b/tests/markdown/test_parser.py
new file mode 100644
index 0000000..0ecbe4d
--- /dev/null
+++ b/tests/markdown/test_parser.py
@@ -0,0 +1,132 @@
+#! /usr/bin/env python
+"""Markdown 解析器单元测试。"""
+
+import pytest
+
+from wordformat.markdown.parser import parse_markdown
+
+
+class TestParseMarkdown:
+ """基础解析测试。"""
+
+ def test_empty_text(self):
+ result = parse_markdown("")
+ assert result == []
+
+ def test_blank_lines_only(self):
+ result = parse_markdown("\n\n\n")
+ assert result == []
+
+ def test_single_heading_h1(self):
+ result = parse_markdown("# 第一章 绪论")
+ assert len(result) == 1
+ assert result[0]["category"] == "heading_level_1"
+ assert result[0]["paragraph"] == "第一章 绪论"
+ assert result[0]["score"] == 1.0
+
+ def test_single_heading_h2(self):
+ result = parse_markdown("## 1.1 研究背景")
+ assert len(result) == 1
+ assert result[0]["category"] == "heading_level_2"
+ assert result[0]["paragraph"] == "1.1 研究背景"
+
+ def test_heading_h4_maps_to_level_3(self):
+ result = parse_markdown("#### 四级标题")
+ assert len(result) == 1
+ assert result[0]["category"] == "heading_level_3"
+
+ def test_paragraph(self):
+ result = parse_markdown("这是一段正文。")
+ assert len(result) == 1
+ assert result[0]["category"] == "body_text"
+ assert result[0]["paragraph"] == "这是一段正文。"
+
+ def test_multiple_paragraphs(self):
+ text = "第一段。\n\n第二段。"
+ result = parse_markdown(text)
+ assert len(result) == 2
+ assert result[0]["category"] == "body_text"
+ assert result[0]["paragraph"] == "第一段。"
+ assert result[1]["category"] == "body_text"
+ assert result[1]["paragraph"] == "第二段。"
+
+ def test_heading_and_paragraphs(self):
+ text = "# 标题\n\n正文内容。\n\n更多正文。"
+ result = parse_markdown(text)
+ assert len(result) == 3
+ assert result[0]["category"] == "heading_level_1"
+ assert result[1]["category"] == "body_text"
+ assert result[2]["category"] == "body_text"
+
+
+class TestHeadingHierarchy:
+ """测试多级标题的层级映射。"""
+
+ def test_deep_hierarchy(self):
+ text = "# 一级\n\n## 二级\n\n### 三级\n\n正文。"
+ result = parse_markdown(text)
+ assert len(result) == 4
+ assert result[0]["category"] == "heading_level_1"
+ assert result[1]["category"] == "heading_level_2"
+ assert result[2]["category"] == "heading_level_3"
+ assert result[3]["category"] == "body_text"
+
+
+class TestInlineFormatting:
+ """测试行内格式标记(inline_marks)的提取。"""
+
+ def test_bold_text(self):
+ result = parse_markdown("这是**粗体**文字。")
+ assert len(result) == 1
+ item = result[0]
+ marks = item["inline_marks"]
+ # 应有三个片段:普通 + 粗体 + 普通
+ assert len(marks) >= 2
+ bold_segs = [m for m in marks if m.get("bold")]
+ assert len(bold_segs) == 1
+ assert bold_segs[0]["text"] == "粗体"
+
+ def test_italic_text(self):
+ result = parse_markdown("这是*斜体*文字。")
+ marks = result[0]["inline_marks"]
+ italic_segs = [m for m in marks if m.get("italic")]
+ assert len(italic_segs) == 1
+ assert italic_segs[0]["text"] == "斜体"
+
+ def test_plain_text_has_no_marks(self):
+ result = parse_markdown("普通正文。")
+ marks = result[0]["inline_marks"]
+ for m in marks:
+ assert not m.get("bold")
+ assert not m.get("italic")
+
+ def test_heading_no_inline_marks_used(self):
+ """标题中的 inline_marks 也存在但不影响使用。"""
+ result = parse_markdown("# 标题")
+ assert "inline_marks" in result[0]
+
+
+class TestSpecialBlocks:
+ """测试特殊块的处理。"""
+
+ def test_code_block_becomes_body_text(self):
+ result = parse_markdown('```\nprint("hello")\n```')
+ assert len(result) == 1
+ assert result[0]["category"] == "body_text"
+ assert 'print("hello")' in result[0]["paragraph"]
+
+ def test_list_flattens_to_paragraphs(self):
+ text = "- 第一项\n- 第二项"
+ result = parse_markdown(text)
+ assert len(result) == 2
+ assert all(r["category"] == "body_text" for r in result)
+
+ def test_standalone_image_becomes_figure(self):
+ result = parse_markdown("")
+ assert len(result) == 1
+ assert result[0]["category"] == "figure_image"
+ assert result[0]["paragraph"] == "test.png"
+
+ def test_thematic_break_skipped(self):
+ result = parse_markdown("---")
+ assert len(result) == 0
diff --git a/tests/rules/test_abstract.py b/tests/rules/test_abstract.py
index d7a41bb..c0fee4c 100644
--- a/tests/rules/test_abstract.py
+++ b/tests/rules/test_abstract.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,7 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
class TestAbstractTitleCNBase:
"""覆盖 abstract.py AbstractTitleCN._base 的 diff 和 apply 分支。"""
@@ -104,7 +106,7 @@ def test_check_with_wrong_format_triggers_comments(self, root_config):
assert mock_comment.call_count >= 2
def test_apply_fixes_wrong_format(self, root_config):
- """apply 模式:应调用 apply_to_paragraph 和 apply_to_run。"""
+ """apply 模式:格式应用成功,不产生批注。"""
doc = Document()
p = doc.add_paragraph()
r = p.add_run("摘要")
@@ -114,8 +116,8 @@ def test_apply_fixes_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- # apply 模式也会调用 add_comment
- assert mock_comment.call_count >= 2
+ # apply 模式格式应用成功,不产生批注(check 模式才写批注)
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_no_runs_skips_without_error(self, root_config):
"""段落无 run 时(空段),check_format 安全跳过不报错。"""
@@ -131,7 +133,6 @@ def test_check_no_runs_skips_without_error(self, root_config):
# ---------------------------------------------------------------------------
-
class TestAbstractTitleContentCNBase:
"""覆盖 abstract.py AbstractTitleContentCN._base 的 check_title 分支。"""
@@ -192,7 +193,6 @@ def test_check_mode_does_not_mutate_run_text(self, root_config):
# ---------------------------------------------------------------------------
-
class TestAbstractContentCNBase:
"""覆盖 abstract.py AbstractContentCN._base 的 diff/apply 逻辑。"""
@@ -241,7 +241,6 @@ def test_check_multiple_runs(self, root_config):
# ---------------------------------------------------------------------------
-
class TestAbstractTitleENBase:
"""覆盖 abstract.py AbstractTitleEN._base 的 diff/apply 逻辑。
注意:AbstractTitleEN 只在 diff_result 非空时才 add_comment。"""
@@ -280,7 +279,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
# ---------------------------------------------------------------------------
@@ -288,7 +287,6 @@ def test_apply_with_wrong_format(self, root_config):
# ---------------------------------------------------------------------------
-
class TestAbstractTitleContentENBase:
"""覆盖 abstract.py AbstractTitleContentEN._base 的 check_title 分支。"""
@@ -353,7 +351,7 @@ def test_check_title_normalizes_case_upper(self, root_config):
assert r.text.startswith("Abstract")
def test_split_abstract_across_runs(self, root_config):
- """"Abstract" 被拆分到两个 run 时仍能正确识别标题部分。"""
+ """ "Abstract" 被拆分到两个 run 时仍能正确识别标题部分。"""
doc = Document()
p = doc.add_paragraph()
r1 = p.add_run("Abst")
@@ -368,7 +366,7 @@ def test_split_abstract_across_runs(self, root_config):
assert "body text" in r2.text
def test_split_abstract_across_three_runs(self, root_config):
- """"Abstract" 被拆分到三个 run 时仍能正确识别。"""
+ """ "Abstract" 被拆分到三个 run 时仍能正确识别。"""
doc = Document()
p = doc.add_paragraph()
r1 = p.add_run("Abs")
@@ -392,7 +390,6 @@ def test_split_abstract_across_three_runs(self, root_config):
# ---------------------------------------------------------------------------
-
class TestAbstractContentENBase:
"""覆盖 abstract.py AbstractContentEN._base 的 diff/apply 逻辑。
注意:AbstractContentEN 只在 diff_result/issues 非空时才 add_comment。"""
@@ -421,7 +418,7 @@ def test_apply_with_wrong_format(self, root_config):
# 配置中 line_spacing 为 "0倍",现会触发 ValueError,mock 掉该步
with patch("wordformat.style.diff.LineSpacing.format"):
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 2
def test_check_multiple_runs(self, root_config):
"""多个 run 都应被检查。"""
diff --git a/tests/rules/test_acknowledgement.py b/tests/rules/test_acknowledgement.py
index 3bc742e..b868ad7 100644
--- a/tests/rules/test_acknowledgement.py
+++ b/tests/rules/test_acknowledgement.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,7 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
class TestAcknowledgementsBase:
"""覆盖 acknowledgement.py Acknowledgements._base 的 diff/apply 逻辑。"""
@@ -123,7 +125,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_no_diffs_no_comment(self, root_config):
"""格式完全正确时,不应调用 add_comment。"""
@@ -144,7 +146,6 @@ def test_check_no_diffs_no_comment(self, root_config):
# ---------------------------------------------------------------------------
-
class TestAcknowledgementsCNBase:
"""覆盖 acknowledgement.py AcknowledgementsCN._base 的 diff/apply 逻辑。"""
@@ -180,7 +181,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_first_line_indent(self, root_config):
"""验证 first_line_indent 配置被正确使用。"""
diff --git a/tests/rules/test_body.py b/tests/rules/test_body.py
index 5a9338d..37caa5e 100644
--- a/tests/rules/test_body.py
+++ b/tests/rules/test_body.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,8 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
-
class TestBodyTextCitationSuperscript:
"""测试 BodyText.apply_format 中的引用上标自动设置。"""
@@ -104,8 +105,9 @@ def test_single_citation_gets_superscript(self):
"""单引用 [1] 应被设为上标。"""
doc = Document()
p = doc.add_paragraph("这是一篇论文[1]的引用。")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp1"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp1"}, level=0, paragraph=p
+ )
# 直接调用 apply_format(跳过 load_config,不依赖完整配置)
node._apply_citation_superscript()
# "[1]" 应在独立的上标 run 中
@@ -119,8 +121,9 @@ def test_multiple_citations(self):
"""多个引用 [1] 和 [2,3] 都应被设为上标。"""
doc = Document()
p = doc.add_paragraph("参见文献[1]和[2,3]的讨论。")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp2"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp2"}, level=0, paragraph=p
+ )
node._apply_citation_superscript()
superscript_texts = [
r.text for r in p.runs if self._get_vertAlign(r) == "superscript"
@@ -132,8 +135,9 @@ def test_range_citation(self):
"""范围引用 [1-3] 应被设为上标。"""
doc = Document()
p = doc.add_paragraph("文献[1-3]提供了详细分析。")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp3"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp3"}, level=0, paragraph=p
+ )
node._apply_citation_superscript()
superscript_texts = [
r.text for r in p.runs if self._get_vertAlign(r) == "superscript"
@@ -144,8 +148,9 @@ def test_chinese_comma_citation(self):
"""中文逗号分隔的引用 [1,2] 应被设为上标。"""
doc = Document()
p = doc.add_paragraph("见[1,2,3]的研究。")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp4"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp4"}, level=0, paragraph=p
+ )
node._apply_citation_superscript()
superscript_texts = [
r.text for r in p.runs if self._get_vertAlign(r) == "superscript"
@@ -156,8 +161,9 @@ def test_no_citation_leaves_runs_unchanged(self):
"""无引用的段落应保持原样。"""
doc = Document()
p = doc.add_paragraph("这是一段没有引用的正文。")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp5"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp5"}, level=0, paragraph=p
+ )
original_text = p.text
node._apply_citation_superscript()
# 文本不变,且无上标 run
@@ -168,8 +174,9 @@ def test_non_citation_brackets_not_affected(self):
"""非数字方括号如 [注] 不应被设为上标。"""
doc = Document()
p = doc.add_paragraph("这是一个[注]释说明。")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp6"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp6"}, level=0, paragraph=p
+ )
node._apply_citation_superscript()
assert all(self._get_vertAlign(r) is None for r in p.runs)
@@ -179,8 +186,9 @@ def test_citation_split_across_runs(self):
p = doc.add_paragraph()
p.add_run("前面文字[1")
p.add_run("2]后面文字")
- node = BodyText(value={"category": "body_text", "fingerprint": "fp7"},
- level=0, paragraph=p)
+ node = BodyText(
+ value={"category": "body_text", "fingerprint": "fp7"}, level=0, paragraph=p
+ )
node._apply_citation_superscript()
superscript_texts = [
r.text for r in p.runs if self._get_vertAlign(r) == "superscript"
@@ -193,4 +201,3 @@ def test_citation_split_across_runs(self):
# ---------------------------------------------------------------------------
# 8. AbstractTitleCN._base 完整 diff/apply 覆盖
# ---------------------------------------------------------------------------
-
diff --git a/tests/rules/test_caption.py b/tests/rules/test_caption.py
index 39d46f7..d22c176 100644
--- a/tests/rules/test_caption.py
+++ b/tests/rules/test_caption.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,7 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
class TestCaptionFigureBase:
"""覆盖 caption.py CaptionFigure._base 的 diff/apply 逻辑。"""
@@ -112,7 +114,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
# ---------------------------------------------------------------------------
@@ -120,7 +122,6 @@ def test_apply_with_wrong_format(self, root_config):
# ---------------------------------------------------------------------------
-
class TestCaptionTableBase:
"""覆盖 caption.py CaptionTable._base 的 diff/apply 逻辑。"""
@@ -146,7 +147,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
# ---------------------------------------------------------------------------
@@ -167,7 +168,6 @@ def test_apply_with_wrong_format(self, root_config):
# ======================== _from_roman ========================
-
class TestApplyCaptionNumbering:
"""直接测试 _apply_caption_numbering 函数。"""
@@ -265,6 +265,7 @@ def test_continued_table_corrects_separator(self):
# ======================== apply_format_check_to_all_nodes 集成 ========================
+
class TestCaptionNumberingIntegration:
"""测试 caption numbering 在 apply_format_check_to_all_nodes 中的集成。"""
@@ -347,7 +348,11 @@ def _make_paragraph(self, text):
def test_check_mode_injects_chapter_and_seq(self, caption_yaml):
"""验证遍历时章节号和顺序号被注入到 node.value 中。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p = self._make_paragraph("图1.1 系统架构图")
@@ -371,19 +376,26 @@ def _suppress_format_comments(self):
mock_cs = MagicMock()
mock_cs.diff_from_run.return_value = {}
mock_cs.apply_to_run.return_value = {}
- with patch(
- "wordformat.style.diff.ParagraphStyle.from_config",
- return_value=mock_ps,
- ), patch(
- "wordformat.style.diff.CharacterStyle",
- return_value=mock_cs,
+ with (
+ patch(
+ "wordformat.style.diff.ParagraphStyle.from_config",
+ return_value=mock_ps,
+ ),
+ patch(
+ "wordformat.style.diff.CharacterStyle",
+ return_value=mock_cs,
+ ),
):
yield
@pytest.mark.usefixtures("_suppress_format_comments")
def test_check_mode_correct_no_comment(self, caption_yaml):
"""check 模式:格式正确,不添加批注。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p = self._make_paragraph("图1.1 系统架构图")
@@ -399,7 +411,11 @@ def test_check_mode_correct_no_comment(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_check_mode_wrong_chapter_adds_comment(self, caption_yaml):
"""check 模式:章节号错误应添加批注。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p = self._make_paragraph("图2.1 测试图")
@@ -416,7 +432,11 @@ def test_check_mode_wrong_chapter_adds_comment(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_apply_mode_rewrites_caption(self, caption_yaml):
"""apply 模式:重写题注文本。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p = self._make_paragraph("图2-1 旧名称")
@@ -431,7 +451,11 @@ def test_apply_mode_rewrites_caption(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_per_type_counters(self, caption_yaml):
"""图和表使用独立计数器。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p_fig = self._make_paragraph("图1.1 图")
@@ -442,8 +466,10 @@ def test_per_type_counters(self, caption_yaml):
heading = self._make_heading_node(children=[fig, tab])
config = self._init_config(caption_yaml)
- with patch.object(fig, "add_comment") as mc1, \
- patch.object(tab, "add_comment") as mc2:
+ with (
+ patch.object(fig, "add_comment") as mc1,
+ patch.object(tab, "add_comment") as mc2,
+ ):
apply_format_check_to_all_nodes(heading, doc, config, check=True)
mc1.assert_not_called()
@@ -454,7 +480,11 @@ def test_per_type_counters(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_multi_chapter_counters_reset(self, caption_yaml):
"""不同章节的题注计数器独立重置。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p1 = self._make_paragraph("图1.1 第一章图")
@@ -472,8 +502,7 @@ def test_multi_chapter_counters_reset(self, caption_yaml):
config = self._init_config(caption_yaml)
- with patch.object(fig1, "add_comment"), \
- patch.object(fig2, "add_comment"):
+ with patch.object(fig1, "add_comment"), patch.object(fig2, "add_comment"):
apply_format_check_to_all_nodes(root, doc, config, check=True)
assert fig1.value["chapter_number"] == 1
@@ -484,7 +513,11 @@ def test_multi_chapter_counters_reset(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_disabled_skips_numbering_check(self, caption_yaml):
"""rules.caption_numbering.enabled=False 时不检查编号(但仍注入 chapter/seq)。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p = self._make_paragraph("图2.1 测试")
@@ -503,7 +536,11 @@ def test_disabled_skips_numbering_check(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_continued_table_does_not_increment_counter(self, caption_yaml):
"""续表不消耗题注编号,后续普通题注编号正确递增。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p1 = self._make_paragraph("表5.1 岗位信息表")
@@ -516,9 +553,11 @@ def test_continued_table_does_not_increment_counter(self, caption_yaml):
heading = self._make_heading_node(children=[tab1, tab_continued, tab2])
config = self._init_config(caption_yaml)
- with patch.object(tab1, "add_comment"), \
- patch.object(tab_continued, "add_comment"), \
- patch.object(tab2, "add_comment"):
+ with (
+ patch.object(tab1, "add_comment"),
+ patch.object(tab_continued, "add_comment"),
+ patch.object(tab2, "add_comment"),
+ ):
apply_format_check_to_all_nodes(heading, doc, config, check=True)
# 表5.1: 章节号5,序号1
@@ -534,7 +573,11 @@ def test_continued_table_does_not_increment_counter(self, caption_yaml):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_continued_table_apply_mode_preserves_prefix(self, caption_yaml):
"""apply 模式:续表文本保留续前缀。"""
- from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+ from wordformat.pipeline.stages import FormattingExecutionStage
+
+ apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+ )
doc = Document()
p = self._make_paragraph("续表5.3 API接口测试结果")
@@ -546,6 +589,7 @@ def test_continued_table_apply_mode_preserves_prefix(self, caption_yaml):
assert p.text == "续表5.3 API接口测试结果"
+
class TestCheckCaptionNumbering:
"""直接测试 _check_caption_numbering 函数。"""
@@ -744,6 +788,7 @@ def test_continued_table_label_space_wrong(self):
# ======================== _apply_caption_numbering ========================
+
class TestReplaceParagraphText:
def test_single_run(self):
from wordformat.rules.caption import _replace_paragraph_text
@@ -785,6 +830,7 @@ def test_replace_paragraph_text_empty_runs(self):
"""空 runs 列表不抛异常。"""
from docx import Document
from wordformat.rules.caption import _replace_paragraph_text
+
doc = Document()
p = doc.add_paragraph()
# 没有 runs — 不应抛异常
@@ -793,17 +839,26 @@ def test_replace_paragraph_text_empty_runs(self):
def test_check_caption_numbering_no_paragraph(self):
"""paragraph 为 None 时直接返回。"""
from wordformat.rules.caption import _check_caption_numbering, CaptionFigure
+
node = CaptionFigure(value={"category": "test"}, level=0)
node.paragraph = None
# 不应抛异常
- _check_caption_numbering(node, None, "图", {"enabled": True, "separator": ".", "label_number_space": False})
+ _check_caption_numbering(
+ node,
+ None,
+ "图",
+ {"enabled": True, "separator": ".", "label_number_space": False},
+ )
def test_apply_caption_numbering_no_paragraph(self):
"""paragraph 为 None 时直接返回。"""
from wordformat.rules.caption import _apply_caption_numbering, CaptionFigure
+
node = CaptionFigure(value={"category": "test"}, level=0)
node.paragraph = None
- _apply_caption_numbering(node, "图", {"enabled": True, "separator": ".", "label_number_space": False})
+ _apply_caption_numbering(
+ node, "图", {"enabled": True, "separator": ".", "label_number_space": False}
+ )
class TestNodeConfigRoot:
@@ -811,16 +866,19 @@ class TestNodeConfigRoot:
def test_setattr(self):
from wordformat.config.models import NodeConfigRoot
+
cfg = NodeConfigRoot()
cfg.template_name = "测试"
assert cfg["template_name"] == "测试"
def test_getattr_missing_returns_none(self):
from wordformat.config.models import NodeConfigRoot
+
cfg = NodeConfigRoot()
assert cfg.nonexistent is None
def test_model_dump(self):
from wordformat.config.models import NodeConfigRoot
+
cfg = NodeConfigRoot(a=1, b=2)
assert cfg.model_dump() == {"a": 1, "b": 2}
diff --git a/tests/rules/test_heading.py b/tests/rules/test_heading.py
index 554de80..cd2d137 100644
--- a/tests/rules/test_heading.py
+++ b/tests/rules/test_heading.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,8 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
-
class TestHeadingBug:
"""
NODE_TYPE 现在自动回退为 CONFIG_PATH,
@@ -114,8 +115,6 @@ def test_heading_level_configs(self, root_config):
# ---------------------------------------------------------------------------
-
-
class TestHeadingLevel1NodeBase:
"""覆盖 heading.py HeadingLevel1Node._base 的 diff/apply 逻辑。"""
@@ -142,7 +141,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_returns_issues_list(self, root_config):
"""返回值应为包含 issue 字典的列表。"""
@@ -168,8 +167,7 @@ def test_check_skips_empty_runs(self, root_config):
node.check_format(doc)
# 空白 run 不应触发 comment
run_comments = [
- c for c in mock_comment.call_args_list
- if c.kwargs.get("runs") is r1
+ c for c in mock_comment.call_args_list if c.kwargs.get("runs") is r1
]
assert len(run_comments) == 0
@@ -179,8 +177,6 @@ def test_check_skips_empty_runs(self, root_config):
# ---------------------------------------------------------------------------
-
-
class TestHeadingLevel2NodeBase:
"""覆盖 heading.py HeadingLevel2Node._base 的 diff/apply 逻辑。"""
@@ -206,7 +202,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_returns_issues(self, root_config):
"""返回值应包含 issue 字典。"""
@@ -224,8 +220,6 @@ def test_check_returns_issues(self, root_config):
# ---------------------------------------------------------------------------
-
-
class TestHeadingLevel3NodeBase:
"""覆盖 heading.py HeadingLevel3Node._base 的 diff/apply 逻辑。"""
@@ -251,7 +245,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_returns_issues(self, root_config):
"""返回值应包含 issue 字典。"""
@@ -275,6 +269,7 @@ class TestHeadingLevelNodes:
def test_heading_level1_load_config_dict(self):
"""HeadingLevel1Node.load_config with dict (lines 36-41)"""
from wordformat.rules.heading import HeadingLevel1Node
+
node = HeadingLevel1Node(
value={"category": "headings.level_1", "fingerprint": "fp"},
level=1,
@@ -294,6 +289,7 @@ def test_heading_level1_load_config_dict(self):
def test_heading_level2_load_config_dict(self):
"""HeadingLevel2Node.load_config with dict (lines 52-58)"""
from wordformat.rules.heading import HeadingLevel2Node
+
node = HeadingLevel2Node(
value={"category": "headings.level_2", "fingerprint": "fp"},
level=2,
@@ -312,6 +308,7 @@ def test_heading_level2_load_config_dict(self):
def test_heading_level3_load_config_dict(self):
"""HeadingLevel3Node.load_config with dict"""
from wordformat.rules.heading import HeadingLevel3Node
+
node = HeadingLevel3Node(
value={"category": "headings.level_3", "fingerprint": "fp"},
level=3,
@@ -331,6 +328,7 @@ def test_heading_base_with_config(self, sample_yaml_config):
"""_base method with loaded config"""
from wordformat.config.loader import init_config, get_config
from wordformat.rules.heading import HeadingLevel1Node
+
init_config(sample_yaml_config)
config = get_config()
@@ -346,6 +344,4 @@ def test_heading_base_with_config(self, sample_yaml_config):
node.check_format(doc) # 通过 RULES handler 执行格式检查
-
# ==================== (o) set_style.py 额外覆盖测试 ====================
-
diff --git a/tests/rules/test_keywords.py b/tests/rules/test_keywords.py
index 0c02284..74f3ba3 100644
--- a/tests/rules/test_keywords.py
+++ b/tests/rules/test_keywords.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,8 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
-
class TestKeywordsLogic:
"""关键词节点的标签识别、数量校验、标点校验。"""
@@ -174,6 +175,7 @@ def test_en_count_validation_too_few(self, root_config):
texts = [c.kwargs["text"] for c in mock_comment.call_args_list]
assert any("数量过少" in t for t in texts)
+
# ---------------------------------------------------------------------------
# 6. _base 实现实际调用 diff/apply 逻辑
# ---------------------------------------------------------------------------
@@ -185,6 +187,7 @@ class TestKeywordsENBase:
def _make_en_node(self, config_dict=None):
"""Helper to create a KeywordsEN node with config loaded"""
from wordformat.rules.keywords import KeywordsEN
+
node = KeywordsEN(
value={"category": "abstract.keywords.english", "fingerprint": "fp"},
level=1,
@@ -196,6 +199,7 @@ def _make_en_node(self, config_dict=None):
def test_empty_run_skip(self, sample_yaml_config):
"""Empty run text is skipped (line 114)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -211,6 +215,7 @@ def test_empty_run_skip(self, sample_yaml_config):
def test_label_style_check(self, sample_yaml_config):
"""Label run style is checked (line 121)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -226,6 +231,7 @@ def test_label_style_check(self, sample_yaml_config):
def test_content_style_check(self, sample_yaml_config):
"""Content run style is checked (lines 130-135)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -242,6 +248,7 @@ def test_content_style_check(self, sample_yaml_config):
def test_keyword_count_validation_min(self, sample_yaml_config):
"""Keyword count < count_min triggers warning (via _run_rules)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -258,6 +265,7 @@ def test_keyword_count_validation_min(self, sample_yaml_config):
def test_keyword_count_validation_max(self, sample_yaml_config):
"""Keyword count > count_max triggers warning (via _run_rules)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -269,4 +277,4 @@ def test_keyword_count_validation_max(self, sample_yaml_config):
content_run = p.add_run("AI, ML, NLP, CV, DB, SE")
node.paragraph = p
node.check_format(doc)
- # count_max is 5, 6 keywords -> should trigger count warning
\ No newline at end of file
+ # count_max is 5, 6 keywords -> should trigger count warning
diff --git a/tests/rules/test_node.py b/tests/rules/test_node.py
index 05b3bbf..5043210 100644
--- a/tests/rules/test_node.py
+++ b/tests/rules/test_node.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,8 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
-
class TestFormatNodeBase:
"""FormatNode 基类的核心契约。"""
@@ -100,39 +101,46 @@ def test_base_is_noop(self, doc, para):
def test_check_format_calls_base_with_true(self, doc, para):
"""check_format 应以 p=True, r=True 调用 _base。"""
node = FormatNodeBase(value=para, level=0, paragraph=para)
- with patch.object(node, "_base") as mock_base, \
- patch.object(node, "_run_rules"):
+ with patch.object(node, "_base") as mock_base, patch.object(node, "_run_rules"):
node.check_format(doc)
mock_base.assert_called_once_with(doc, p=True, r=True)
def test_apply_format_calls_base_with_false(self, doc, para):
"""apply_format 应以 p=False, r=False 调用 _base。"""
node = FormatNodeBase(value=para, level=0, paragraph=para)
- with patch.object(node, "_base") as mock_base, \
- patch.object(node, "_run_rules"):
+ with patch.object(node, "_base") as mock_base, patch.object(node, "_run_rules"):
node.apply_format(doc)
mock_base.assert_called_once_with(doc, p=False, r=False)
+
# ---------------------------------------------------------------------------
# 2. 所有节点类型可实例化
# ---------------------------------------------------------------------------
NODE_CLASSES = [
- AbstractTitleCN, AbstractTitleContentCN, AbstractContentCN,
- AbstractTitleEN, AbstractTitleContentEN, AbstractContentEN,
- KeywordsCN, KeywordsEN,
- HeadingLevel1Node, HeadingLevel2Node, HeadingLevel3Node,
+ AbstractTitleCN,
+ AbstractTitleContentCN,
+ AbstractContentCN,
+ AbstractTitleEN,
+ AbstractTitleContentEN,
+ AbstractContentEN,
+ KeywordsCN,
+ KeywordsEN,
+ HeadingLevel1Node,
+ HeadingLevel2Node,
+ HeadingLevel3Node,
BodyText,
- CaptionFigure, CaptionTable,
- References, ReferenceEntry,
- Acknowledgements, AcknowledgementsCN,
+ CaptionFigure,
+ CaptionTable,
+ References,
+ ReferenceEntry,
+ Acknowledgements,
+ AcknowledgementsCN,
]
@pytest.mark.parametrize("cls", NODE_CLASSES, ids=lambda c: c.__name__)
-
-
class TestNodeInstantiation:
"""每个节点类型都能正确实例化。"""
@@ -156,8 +164,6 @@ def test_has_node_type(self, cls):
# ---------------------------------------------------------------------------
-
-
class TestLoadConfig:
"""验证 load_config 正确合并 DEFAULTS 与 YAML 配置。"""
@@ -202,7 +208,9 @@ def test_caption_table_content_font_size(self, root_config):
node = _make_node(CaptionTable)
node.load_config(root_config)
# YAML: tables.content.font_size = '五号'
- assert node.pydantic_config.font_size == "小四" # DEFAULTS 值(非 content 子对象)
+ assert (
+ node.pydantic_config.font_size == "小四"
+ ) # DEFAULTS 值(非 content 子对象)
def test_references(self, root_config):
node = _make_node(References)
@@ -247,8 +255,6 @@ def test_keywords_en_loads_label(self, root_config):
# ---------------------------------------------------------------------------
-
-
class TestBaseImplementation:
"""验证 _base 实现确实执行了 diff_from_run / apply_to_run 逻辑。"""
@@ -300,4 +306,3 @@ def test_references_check_runs(self, root_config):
# ---------------------------------------------------------------------------
# 7. BodyText._apply_citation_superscript
# ---------------------------------------------------------------------------
-
diff --git a/tests/rules/test_references.py b/tests/rules/test_references.py
index f8234ba..caece61 100644
--- a/tests/rules/test_references.py
+++ b/tests/rules/test_references.py
@@ -1,6 +1,7 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
+
from unittest.mock import patch
import pytest
@@ -51,6 +52,7 @@ def _load_root_config(config_path):
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -59,6 +61,7 @@ def _load_yaml(path):
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
from wordformat.config.loader import init_config
+
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -86,7 +89,6 @@ def run_with_text(para):
# ---------------------------------------------------------------------------
-
class TestReferencesBase:
"""覆盖 references.py References._base 的 diff/apply 逻辑。"""
@@ -123,7 +125,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
# ---------------------------------------------------------------------------
@@ -131,7 +133,6 @@ def test_apply_with_wrong_format(self, root_config):
# ---------------------------------------------------------------------------
-
class TestReferenceEntryBase:
"""覆盖 references.py ReferenceEntry._base 的 diff/apply 逻辑。"""
@@ -167,7 +168,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
node.apply_format(doc)
- assert mock_comment.call_count >= 1
+ assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异
def test_check_alignment_and_indent(self, root_config):
"""验证 alignment 和 first_line_indent 配置被正确使用。"""
diff --git a/tests/style/test_defs.py b/tests/style/test_defs.py
index 4dded93..a2340a8 100644
--- a/tests/style/test_defs.py
+++ b/tests/style/test_defs.py
@@ -15,21 +15,45 @@
from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle
from wordformat.style.reader import (
- paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after,
- paragraph_get_line_spacing, paragraph_get_first_line_indent,
- paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt,
- run_get_font_color, run_get_font_bold, run_get_font_italic,
- run_get_font_underline, GetIndent,
+ paragraph_get_alignment,
+ paragraph_get_space_before,
+ paragraph_get_space_after,
+ paragraph_get_line_spacing,
+ paragraph_get_first_line_indent,
+ paragraph_get_builtin_style_name,
+ run_get_font_name,
+ run_get_font_size_pt,
+ run_get_font_color,
+ run_get_font_bold,
+ run_get_font_italic,
+ run_get_font_underline,
+ GetIndent,
)
from wordformat.style.writer import (
- run_set_font_name, set_paragraph_space_before_by_lines,
- set_paragraph_space_after_by_lines, _paragraph_space_by_lines,
- SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent,
+ run_set_font_name,
+ set_paragraph_space_before_by_lines,
+ set_paragraph_space_after_by_lines,
+ _paragraph_space_by_lines,
+ SetSpacing,
+ SetLineSpacing,
+ SetIndent,
+ SetFirstLineIndent,
)
from wordformat.style.defs import (
- FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
- FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
- Spacing, UnitLabelEnum,
+ FontName,
+ FontSize,
+ FontColor,
+ Alignment,
+ LineSpacingRule,
+ LineSpacing,
+ FirstLineIndent,
+ LeftIndent,
+ RightIndent,
+ BuiltInStyle,
+ SpaceBefore,
+ SpaceAfter,
+ Spacing,
+ UnitLabelEnum,
)
from wordformat.style.units import extract_unit_from_string, UnitResult
@@ -38,6 +62,7 @@
# fixtures & helpers
# ---------------------------------------------------------------------------
+
@pytest.fixture
def doc():
return Document()
@@ -46,10 +71,23 @@ def doc():
@pytest.fixture
def mock_warning():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, True)
return w
@@ -57,21 +95,36 @@ def mock_warning():
@pytest.fixture
def mock_warning_off():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, False)
return w
def _set_warning(w):
import wordformat.style.diff as m
+
m.style_checks_warning = w
def _clear_warning():
import wordformat.style.diff as m
+
m.__dict__.pop("style_checks_warning", None)
@@ -79,10 +132,19 @@ def _clear_warning():
# style_enum - FontSize
# ===========================================================================
+
class TestFontSize:
- @pytest.mark.parametrize("label,expected", [
- ("小四", 12), ("四号", 14), ("三号", 16), ("五号", 10.5), ("一号", 26), ("七号", 5.5),
- ])
+ @pytest.mark.parametrize(
+ "label,expected",
+ [
+ ("小四", 12),
+ ("四号", 14),
+ ("三号", 16),
+ ("五号", 10.5),
+ ("一号", 26),
+ ("七号", 5.5),
+ ],
+ )
def test_label_map(self, label, expected):
assert FontSize(label).rel_value == expected
@@ -100,18 +162,27 @@ def test_base_set_invalid_raises(self, doc):
FontSize("abc").base_set(doc.add_paragraph().add_run("x"))
-
# ===========================================================================
# style_enum - FontColor
# ===========================================================================
+
class TestFontColor:
- @pytest.mark.parametrize("spec,expected", [
- ("BLACK", (0, 0, 0)), ("black", (0, 0, 0)), ("黑色", (0, 0, 0)),
- ("red", (255, 0, 0)), ("红色", (255, 0, 0)),
- ("#FF0000", (255, 0, 0)), ("#f00", (255, 0, 0)), ("FF0000", (255, 0, 0)),
- ("white", (255, 255, 255)), ("白色", (255, 255, 255)),
- ])
+ @pytest.mark.parametrize(
+ "spec,expected",
+ [
+ ("BLACK", (0, 0, 0)),
+ ("black", (0, 0, 0)),
+ ("黑色", (0, 0, 0)),
+ ("red", (255, 0, 0)),
+ ("红色", (255, 0, 0)),
+ ("#FF0000", (255, 0, 0)),
+ ("#f00", (255, 0, 0)),
+ ("FF0000", (255, 0, 0)),
+ ("white", (255, 255, 255)),
+ ("白色", (255, 255, 255)),
+ ],
+ )
def test_parse_color(self, spec, expected):
assert FontColor(spec).rel_value == expected
@@ -137,7 +208,9 @@ def test_non_string_raises(self):
with pytest.raises(TypeError):
FontColor(123).rel_value
- @pytest.mark.parametrize("h,ok", [("#f00", True), ("#FF0000", True), ("FF0000", True), ("#GGG", False)])
+ @pytest.mark.parametrize(
+ "h,ok", [("#f00", True), ("#FF0000", True), ("FF0000", True), ("#GGG", False)]
+ )
def test_is_hex(self, h, ok):
assert FontColor._is_hex(h) == ok
@@ -146,16 +219,21 @@ def test_normalize_hex(self):
assert FontColor._normalize_hex("AABBCC") == "#aabbcc"
-
# ===========================================================================
# style_enum - Alignment / LineSpacingRule / LineSpacing / BuiltInStyle / FontName
# ===========================================================================
+
class TestAlignmentEnum:
- @pytest.mark.parametrize("label,val", [
- ("左对齐", WD_ALIGN_PARAGRAPH.LEFT), ("居中对齐", WD_ALIGN_PARAGRAPH.CENTER),
- ("右对齐", WD_ALIGN_PARAGRAPH.RIGHT), ("两端对齐", WD_ALIGN_PARAGRAPH.JUSTIFY),
- ])
+ @pytest.mark.parametrize(
+ "label,val",
+ [
+ ("左对齐", WD_ALIGN_PARAGRAPH.LEFT),
+ ("居中对齐", WD_ALIGN_PARAGRAPH.CENTER),
+ ("右对齐", WD_ALIGN_PARAGRAPH.RIGHT),
+ ("两端对齐", WD_ALIGN_PARAGRAPH.JUSTIFY),
+ ],
+ )
def test_label_map(self, label, val):
assert Alignment(label).rel_value == val
@@ -166,25 +244,32 @@ def test_base_set_and_get(self, doc):
assert Alignment("左对齐").get_from_paragraph(p) == WD_ALIGN_PARAGRAPH.CENTER
def test_get_fallback_left(self, doc):
- assert Alignment("左对齐").get_from_paragraph(doc.add_paragraph()) == WD_ALIGN_PARAGRAPH.LEFT
+ assert (
+ Alignment("左对齐").get_from_paragraph(doc.add_paragraph())
+ == WD_ALIGN_PARAGRAPH.LEFT
+ )
def test_invalid_raises(self, doc):
with pytest.raises(ValueError, match="无效的对齐方式"):
Alignment("无效").base_set(doc.add_paragraph())
-
class TestLineSpacingRuleEnum:
- @pytest.mark.parametrize("label,val", [
- ("单倍行距", WD_LINE_SPACING.SINGLE), ("1.5倍行距", WD_LINE_SPACING.ONE_POINT_FIVE),
- ("2倍行距", WD_LINE_SPACING.DOUBLE), ("固定值", WD_LINE_SPACING.EXACTLY),
- ("最小值", WD_LINE_SPACING.AT_LEAST), ("多倍行距", WD_LINE_SPACING.MULTIPLE),
- ])
+ @pytest.mark.parametrize(
+ "label,val",
+ [
+ ("单倍行距", WD_LINE_SPACING.SINGLE),
+ ("1.5倍行距", WD_LINE_SPACING.ONE_POINT_FIVE),
+ ("2倍行距", WD_LINE_SPACING.DOUBLE),
+ ("固定值", WD_LINE_SPACING.EXACTLY),
+ ("最小值", WD_LINE_SPACING.AT_LEAST),
+ ("多倍行距", WD_LINE_SPACING.MULTIPLE),
+ ],
+ )
def test_label_map(self, label, val):
assert LineSpacingRule(label).rel_value == val
-
class TestLineSpacingEnum:
def test_rel_value(self):
assert LineSpacing("1.5倍").rel_value == 1.5
@@ -205,9 +290,11 @@ def test_zero_should_raise(self, doc):
LineSpacing("0倍").base_set(doc.add_paragraph())
-
class TestBuiltInStyleEnum:
- @pytest.mark.parametrize("label,expected", [("正文", "Normal"), ("标题", "Title"), ("Heading 1", "Heading 1")])
+ @pytest.mark.parametrize(
+ "label,expected",
+ [("正文", "Normal"), ("标题", "Title"), ("Heading 1", "Heading 1")],
+ )
def test_label_map(self, label, expected):
assert BuiltInStyle(label).rel_value == expected
@@ -226,11 +313,16 @@ def test_base_set_invalid_raises(self, doc):
assert p.style.name == "NonExistent"
-
class TestFontNameEnum:
- @pytest.mark.parametrize("name,is_cn", [
- ("宋体", True), ("黑体", True), ("Times New Roman", False), ("Arial", False),
- ])
+ @pytest.mark.parametrize(
+ "name,is_cn",
+ [
+ ("宋体", True),
+ ("黑体", True),
+ ("Times New Roman", False),
+ ("Arial", False),
+ ],
+ )
def test_is_chinese(self, name, is_cn):
assert FontName(name).is_chinese(name) == is_cn
@@ -245,7 +337,6 @@ def test_base_set_english(self, doc):
assert run.font.name == "Arial"
-
class TestUnitLabelEnumEq:
def test_same_class_same_rel(self):
assert FontSize("小四") == FontSize("小四")
@@ -263,19 +354,29 @@ def test_different_class(self):
assert FontSize("小四") != Alignment("左对齐")
-
# ===========================================================================
# utils
# ===========================================================================
+
class TestExtractUnitFromString:
- @pytest.mark.parametrize("text,val,unit", [
- ("12pt", 12.0, "pt"), ("1.5磅", 1.5, "pt"), ("2cm", 2.0, "cm"),
- ("3.5厘米", 3.5, "cm"), ("1inch", 1.0, "inch"), ("0.5英寸", 0.5, "inch"),
- ("10mm", 10.0, "mm"), ("2毫米", 2.0, "mm"),
- ("1.5行", 1.5, "hang"), ("2字符", 2.0, "char"), ("1.5倍", 1.5, "bei"),
- ("100emu", 100.0, "emu"),
- ])
+ @pytest.mark.parametrize(
+ "text,val,unit",
+ [
+ ("12pt", 12.0, "pt"),
+ ("1.5磅", 1.5, "pt"),
+ ("2cm", 2.0, "cm"),
+ ("3.5厘米", 3.5, "cm"),
+ ("1inch", 1.0, "inch"),
+ ("0.5英寸", 0.5, "inch"),
+ ("10mm", 10.0, "mm"),
+ ("2毫米", 2.0, "mm"),
+ ("1.5行", 1.5, "hang"),
+ ("2字符", 2.0, "char"),
+ ("1.5倍", 1.5, "bei"),
+ ("100emu", 100.0, "emu"),
+ ],
+ )
def test_valid(self, text, val, unit):
r = extract_unit_from_string(text)
assert r.is_valid and r.value == val and r.standard_unit == unit
@@ -288,32 +389,58 @@ def test_case_insensitive(self):
assert extract_unit_from_string("12PT").standard_unit == "pt"
-
class TestUnitResult:
def test_to_dict(self):
- r = UnitResult(original_unit="pt", standard_unit="pt", value=12.0, is_valid=True)
+ r = UnitResult(
+ original_unit="pt", standard_unit="pt", value=12.0, is_valid=True
+ )
assert r.to_dict()["value"] == 12.0
- @pytest.mark.parametrize("unit,val,emu", [
- ("pt", 1, 12700), ("cm", 1, 360000), ("inch", 1, 914400), ("mm", 1, 36000), ("emu", 1, 1),
- ])
+ @pytest.mark.parametrize(
+ "unit,val,emu",
+ [
+ ("pt", 1, 12700),
+ ("cm", 1, 360000),
+ ("inch", 1, 914400),
+ ("mm", 1, 36000),
+ ("emu", 1, 1),
+ ],
+ )
def test_convert_to_emu(self, unit, val, emu):
- assert UnitResult(standard_unit=unit, value=val, is_valid=True).convert_to_emu() == emu
+ assert (
+ UnitResult(standard_unit=unit, value=val, is_valid=True).convert_to_emu()
+ == emu
+ )
def test_convert_to_emu_hang_bug(self):
"""hang 单位现在返回 None 而非 0。"""
- assert UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu() is None
+ assert (
+ UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu()
+ is None
+ )
def test_convert_to_emu_hang_should_be_none(self):
- assert UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu() is None
+ assert (
+ UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu()
+ is None
+ )
def test_convert_to_emu_invalid(self):
assert UnitResult(is_valid=False).convert_to_emu() is None
- @pytest.mark.parametrize("unit,ch", [
- ("pt", "磅"), ("cm", "厘米"), ("inch", "英寸"), ("mm", "毫米"),
- ("emu", "emu"), ("hang", "行"), ("char", "字符"), ("bei", "倍"),
- ])
+ @pytest.mark.parametrize(
+ "unit,ch",
+ [
+ ("pt", "磅"),
+ ("cm", "厘米"),
+ ("inch", "英寸"),
+ ("mm", "毫米"),
+ ("emu", "emu"),
+ ("hang", "行"),
+ ("char", "字符"),
+ ("bei", "倍"),
+ ],
+ )
def test_unit_ch(self, unit, ch):
assert UnitResult(standard_unit=unit).unit_ch == ch
@@ -325,7 +452,6 @@ def test_unit_ch_invalid_raises(self):
UnitResult(standard_unit="xyz").unit_ch
-
# ===========================================================================
# Coverage: style_enum.py uncovered lines
# ===========================================================================
@@ -342,12 +468,12 @@ def test_rel_value_setter(self):
assert e.rel_value == 99
-
class TestUnitLabelEnumBaseSetDefault:
"""Cover line 135: base_set default implementation (just logs debug)"""
def test_base_set_default_no_crash(self, doc):
"""UnitLabelEnum.base_set default just logs, no crash"""
+
# Use a bare UnitLabelEnum instance - but it's abstract due to get_from_paragraph
# So we create a concrete subclass that doesn't override base_set
class MinimalEnum(UnitLabelEnum):
@@ -359,7 +485,6 @@ def get_from_paragraph(self, paragraph):
e.base_set(doc.add_paragraph())
-
class TestUnitLabelEnumFormatWithRun:
"""Cover lines 152-155: format() method with Run object (not Paragraph)"""
@@ -382,12 +507,12 @@ def test_format_no_fun_falls_to_base_set(self, doc):
assert run_get_font_name(run) == "宋体"
-
class TestUnitLabelEnumGetFromParagraphAbstract:
"""Cover line 163: get_from_paragraph abstract method raises NotImplementedError"""
def test_abstract_raises_not_implemented(self, doc):
"""Calling get_from_paragraph on base UnitLabelEnum raises NotImplementedError"""
+
# Can't instantiate UnitLabelEnum directly due to ABC, use a minimal subclass
class MinimalEnum(UnitLabelEnum):
pass
@@ -397,7 +522,6 @@ class MinimalEnum(UnitLabelEnum):
e.get_from_paragraph(doc.add_paragraph())
-
class TestFontColorParseColorValueError:
"""Cover lines 304-305: FontColor._parse_color ValueError for invalid hex"""
@@ -409,6 +533,7 @@ def test_parse_color_invalid_hex_raises(self):
# Actually, all valid 6-digit hex are valid RGB colors, so this path is hard to trigger
# We'll test with a mock to force the ValueError
import webcolors
+
original = webcolors.hex_to_rgb
webcolors.hex_to_rgb = MagicMock(side_effect=ValueError("mock error"))
try:
@@ -418,7 +543,6 @@ def test_parse_color_invalid_hex_raises(self):
webcolors.hex_to_rgb = original
-
class TestFontColorBaseSetNonString:
"""Cover line 330: FontColor.base_set with non-string value raises TypeError"""
@@ -436,16 +560,15 @@ def test_base_set_non_string_raises(self, doc):
fc.base_set(run)
-
class TestFontColorEqEdgeCases:
"""Cover lines 344-345: FontColor.__eq__ with non-tuple or wrong length tuple"""
def test_eq_non_tuple_returns_false(self):
"""__eq__ with non-color values returns False"""
fc = FontColor("red")
- assert fc == "red" # 字符串颜色名现在被正确比较
- assert fc != 42 # 非颜色应返回 False
- assert fc != None # None 应返回 False
+ assert fc == "red" # 字符串颜色名现在被正确比较
+ assert fc != 42 # 非颜色应返回 False
+ assert fc != None # None 应返回 False
def test_eq_wrong_length_tuple_returns_false(self):
"""__eq__ with tuple of wrong length returns False (line 339-340)"""
@@ -466,24 +589,30 @@ def test_eq_parse_error_returns_false(self):
assert fc != (255, 0, 0)
-
class TestSpacingBaseSetHangNotImplemented:
"""Cover lines 400-404: Spacing.base_set raises NotImplementedError for 'hang' unit"""
def test_spacing_get_from_paragraph_hang_raises(self, doc):
"""Spacing.get_from_paragraph with 'hang' unit raises NotImplementedError"""
s = Spacing("1行")
- with pytest.raises(NotImplementedError, match="Spacing 需要知道是 before 还是 after"):
+ with pytest.raises(
+ NotImplementedError, match="Spacing 需要知道是 before 还是 after"
+ ):
s.get_from_paragraph(doc.add_paragraph())
-
class TestSpaceBeforeGetFromParagraphUnits:
"""Cover lines 412-416: SpaceBefore.get_from_paragraph with pt/mm/cm/inch units"""
- @pytest.mark.parametrize("unit,val,attr", [
- ("pt", 12, "pt"), ("mm", 5, "mm"), ("cm", 1, "cm"), ("inch", 0.5, "inches"),
- ])
+ @pytest.mark.parametrize(
+ "unit,val,attr",
+ [
+ ("pt", 12, "pt"),
+ ("mm", 5, "mm"),
+ ("cm", 1, "cm"),
+ ("inch", 0.5, "inches"),
+ ],
+ )
def test_get_from_paragraph_physical_units(self, doc, unit, val, attr):
"""SpaceBefore.get_from_paragraph returns value in specified physical unit"""
p = doc.add_paragraph()
@@ -491,6 +620,7 @@ def test_get_from_paragraph_physical_units(self, doc, unit, val, attr):
if unit != "pt":
# Set via EMU conversion
from docx.shared import Emu
+
if unit == "mm":
p.paragraph_format.space_before = Emu(36000 * val)
elif unit == "cm":
@@ -519,17 +649,23 @@ def test_get_from_paragraph_none(self, doc):
assert result is None
-
class TestSpaceAfterGetFromParagraphUnits:
"""Cover lines 424-428: SpaceAfter.get_from_paragraph with pt/mm/cm/inch units"""
- @pytest.mark.parametrize("unit,val", [
- ("pt", 12), ("mm", 5), ("cm", 1), ("inch", 0.5),
- ])
+ @pytest.mark.parametrize(
+ "unit,val",
+ [
+ ("pt", 12),
+ ("mm", 5),
+ ("cm", 1),
+ ("inch", 0.5),
+ ],
+ )
def test_get_from_paragraph_physical_units(self, doc, unit, val):
"""SpaceAfter.get_from_paragraph returns value in specified physical unit"""
p = doc.add_paragraph()
from docx.shared import Emu
+
if unit == "pt":
p.paragraph_format.space_after = Pt(val)
elif unit == "mm":
@@ -559,7 +695,6 @@ def test_get_from_paragraph_none(self, doc):
assert result is None
-
class TestLineSpacingRuleBaseSetInvalid:
"""Cover lines 447-451: LineSpacingRule.base_set with invalid value raises ValueError"""
@@ -577,7 +712,6 @@ def test_base_set_invalid_raises(self, doc):
lsr.base_set(p)
-
class TestLineSpacingBaseSetInvalidValue:
"""Cover line 489: LineSpacing.base_set with invalid value raises ValueError"""
@@ -608,17 +742,23 @@ def test_base_set_none_raises(self, doc):
ls.base_set(p)
-
class TestLeftIndentGetFromParagraphUnits:
"""Cover lines 513-517: LeftIndent.get_from_paragraph with pt/mm/cm/inch units"""
- @pytest.mark.parametrize("unit,val", [
- ("pt", 12), ("mm", 5), ("cm", 1), ("inch", 0.5),
- ])
+ @pytest.mark.parametrize(
+ "unit,val",
+ [
+ ("pt", 12),
+ ("mm", 5),
+ ("cm", 1),
+ ("inch", 0.5),
+ ],
+ )
def test_get_from_paragraph_physical_units(self, doc, unit, val):
"""LeftIndent.get_from_paragraph returns value in specified physical unit"""
p = doc.add_paragraph()
from docx.shared import Emu
+
if unit == "pt":
p.paragraph_format.left_indent = Pt(val)
elif unit == "mm":
@@ -640,17 +780,23 @@ def test_get_from_paragraph_none(self, doc):
assert result is None
-
class TestRightIndentGetFromParagraphUnits:
"""Cover lines 525-529: RightIndent.get_from_paragraph with pt/mm/cm/inch units"""
- @pytest.mark.parametrize("unit,val", [
- ("pt", 12), ("mm", 5), ("cm", 1), ("inch", 0.5),
- ])
+ @pytest.mark.parametrize(
+ "unit,val",
+ [
+ ("pt", 12),
+ ("mm", 5),
+ ("cm", 1),
+ ("inch", 0.5),
+ ],
+ )
def test_get_from_paragraph_physical_units(self, doc, unit, val):
"""RightIndent.get_from_paragraph returns value in specified physical unit"""
p = doc.add_paragraph()
from docx.shared import Emu
+
if unit == "pt":
p.paragraph_format.right_indent = Pt(val)
elif unit == "mm":
@@ -672,17 +818,23 @@ def test_get_from_paragraph_none(self, doc):
assert result is None
-
class TestFirstLineIndentGetFromParagraphUnits:
"""Cover lines 548-552: FirstLineIndent.get_from_paragraph with pt/mm/cm/inch units"""
- @pytest.mark.parametrize("unit,val", [
- ("pt", 24), ("mm", 5), ("cm", 1), ("inch", 0.5),
- ])
+ @pytest.mark.parametrize(
+ "unit,val",
+ [
+ ("pt", 24),
+ ("mm", 5),
+ ("cm", 1),
+ ("inch", 0.5),
+ ],
+ )
def test_get_from_paragraph_physical_units(self, doc, unit, val):
"""FirstLineIndent.get_from_paragraph returns value in specified physical unit"""
p = doc.add_paragraph()
from docx.shared import Emu
+
if unit == "pt":
p.paragraph_format.first_line_indent = Pt(val)
elif unit == "mm":
@@ -704,10 +856,9 @@ def test_get_from_paragraph_none(self, doc):
assert result is None
-
class TestBuiltInStyleBaseSetElseBranch:
"""Cover lines 594-595: BuiltInStyle.base_set with value NOT in _LABEL_MAP (else branch)
- Cover line 600: BuiltInStyle.base_set with invalid style raises ValueError"""
+ Cover line 600: BuiltInStyle.base_set with invalid style raises ValueError"""
def test_base_set_else_branch_valid_style(self, doc):
"""base_set with value not in _LABEL_MAP but valid docx style (else branch, line 596-600)"""
@@ -770,4 +921,3 @@ def test_base_set_if_branch_invalid_style_raises(self, doc):
# This should succeed since Normal is a valid style
bis.base_set(p)
assert p.style.name == "Normal"
-
diff --git a/tests/style/test_diff.py b/tests/style/test_diff.py
index 85b17d8..70f2aca 100644
--- a/tests/style/test_diff.py
+++ b/tests/style/test_diff.py
@@ -15,21 +15,45 @@
from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle
from wordformat.style.reader import (
- paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after,
- paragraph_get_line_spacing, paragraph_get_first_line_indent,
- paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt,
- run_get_font_color, run_get_font_bold, run_get_font_italic,
- run_get_font_underline, GetIndent,
+ paragraph_get_alignment,
+ paragraph_get_space_before,
+ paragraph_get_space_after,
+ paragraph_get_line_spacing,
+ paragraph_get_first_line_indent,
+ paragraph_get_builtin_style_name,
+ run_get_font_name,
+ run_get_font_size_pt,
+ run_get_font_color,
+ run_get_font_bold,
+ run_get_font_italic,
+ run_get_font_underline,
+ GetIndent,
)
from wordformat.style.writer import (
- run_set_font_name, set_paragraph_space_before_by_lines,
- set_paragraph_space_after_by_lines, _paragraph_space_by_lines,
- SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent,
+ run_set_font_name,
+ set_paragraph_space_before_by_lines,
+ set_paragraph_space_after_by_lines,
+ _paragraph_space_by_lines,
+ SetSpacing,
+ SetLineSpacing,
+ SetIndent,
+ SetFirstLineIndent,
)
from wordformat.style.defs import (
- FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
- FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
- Spacing, UnitLabelEnum,
+ FontName,
+ FontSize,
+ FontColor,
+ Alignment,
+ LineSpacingRule,
+ LineSpacing,
+ FirstLineIndent,
+ LeftIndent,
+ RightIndent,
+ BuiltInStyle,
+ SpaceBefore,
+ SpaceAfter,
+ Spacing,
+ UnitLabelEnum,
)
from wordformat.style.units import extract_unit_from_string, UnitResult
@@ -38,6 +62,7 @@
# fixtures & helpers
# ---------------------------------------------------------------------------
+
@pytest.fixture
def doc():
return Document()
@@ -46,10 +71,23 @@ def doc():
@pytest.fixture
def mock_warning():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, True)
return w
@@ -57,21 +95,36 @@ def mock_warning():
@pytest.fixture
def mock_warning_off():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, False)
return w
def _set_warning(w):
import wordformat.style.diff as m
+
m._warnings = w
def _clear_warning():
import wordformat.style.diff as m
+
m._warnings = m._load_warnings()
@@ -79,6 +132,7 @@ def _clear_warning():
# DIFFResult
# ===========================================================================
+
class TestDIFFResult:
def test_defaults_and_str(self):
d = DIFFResult()
@@ -92,11 +146,11 @@ def test_level(self, lvl):
assert DIFFResult(level=lvl).level == lvl
-
# ===========================================================================
# CharacterStyle
# ===========================================================================
+
class TestCharacterStyle:
def test_defaults(self, mock_warning):
_set_warning(mock_warning)
@@ -116,13 +170,22 @@ def test_diff_from_run_no_diff_when_matching(self, doc, mock_warning):
run_set_font_name(run, "宋体")
run.font.name = "Times New Roman"
types = [d.diff_type for d in cs.diff_from_run(run)]
- assert "bold" not in types and "italic" not in types and "underline" not in types
+ assert (
+ "bold" not in types and "italic" not in types and "underline" not in types
+ )
_clear_warning()
- @pytest.mark.parametrize("prop,expected_val,current_val", [
- ("bold", False, True), ("italic", False, True), ("underline", False, True),
- ])
- def test_diff_boolean_mismatch(self, doc, mock_warning, prop, expected_val, current_val):
+ @pytest.mark.parametrize(
+ "prop,expected_val,current_val",
+ [
+ ("bold", False, True),
+ ("italic", False, True),
+ ("underline", False, True),
+ ],
+ )
+ def test_diff_boolean_mismatch(
+ self, doc, mock_warning, prop, expected_val, current_val
+ ):
_set_warning(mock_warning)
cs = CharacterStyle(**{prop: expected_val})
run = doc.add_paragraph().add_run("t")
@@ -139,7 +202,12 @@ def test_diff_font_size_and_name_cn(self, doc, mock_warning):
run_set_font_name(run, "黑体")
types = [d.diff_type for d in cs.diff_from_run(run)]
assert "font_size" in types
- assert next(d for d in cs.diff_from_run(run) if d.diff_type == "font_name_cn").current_value == "黑体"
+ assert (
+ next(
+ d for d in cs.diff_from_run(run) if d.diff_type == "font_name_cn"
+ ).current_value
+ == "黑体"
+ )
_clear_warning()
def test_apply_to_run_fixes_bold(self, doc, mock_warning):
@@ -160,11 +228,11 @@ def test_to_string_filters_by_warning(self, mock_warning, mock_warning_off):
_clear_warning()
-
# ===========================================================================
# ParagraphStyle
# ===========================================================================
+
class TestParagraphStyle:
def test_defaults(self, mock_warning):
_set_warning(mock_warning)
@@ -183,14 +251,20 @@ def test_diff_detects_alignment(self, doc, mock_warning):
_set_warning(mock_warning)
p = doc.add_paragraph()
p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
- assert "alignment" in [d.diff_type for d in ParagraphStyle(alignment="左对齐").diff_from_paragraph(p)]
+ assert "alignment" in [
+ d.diff_type
+ for d in ParagraphStyle(alignment="左对齐").diff_from_paragraph(p)
+ ]
_clear_warning()
def test_diff_builtin_style_name_match(self, doc, mock_warning):
"""BuiltInStyle('正文').rel_value='Normal' 与 'normal' 应视为一致。"""
_set_warning(mock_warning)
p = doc.add_paragraph()
- types = [d.diff_type for d in ParagraphStyle(builtin_style_name="正文").diff_from_paragraph(p)]
+ types = [
+ d.diff_type
+ for d in ParagraphStyle(builtin_style_name="正文").diff_from_paragraph(p)
+ ]
assert "builtin_style_name" not in types # 英文"normal" = 中文"正文"
_clear_warning()
@@ -214,7 +288,6 @@ def test_from_config(self, mock_warning):
_clear_warning()
-
# ===========================================================================
# Additional coverage tests for check_format.py
# ===========================================================================
@@ -228,7 +301,6 @@ def test_level_string(self):
assert d.level == "warning"
-
class TestParagraphStyleApplyToParagraph:
"""Cover lines 315-358: ParagraphStyle.apply_to_paragraph with various indent types"""
@@ -266,9 +338,12 @@ def test_apply_unknown_diff_type_skipped(self, doc, mock_warning):
ps = ParagraphStyle()
# Force an unknown diff_type by monkey-patching
from wordformat.style.diff import DIFFResult
+
original_diff = ps.diff_from_paragraph
+
def fake_diff(paragraph):
return [DIFFResult(diff_type="unknown_type", comment="test")]
+
ps.diff_from_paragraph = fake_diff
result = ps.apply_to_paragraph(p)
# unknown_type should be skipped (continue), so result should be empty
@@ -286,7 +361,6 @@ def test_apply_builtin_style_name(self, doc, mock_warning):
_clear_warning()
-
class TestParagraphStyleFromConfigExtended:
"""Cover line 478: ParagraphStyle.from_config with real config"""
@@ -327,7 +401,6 @@ def test_from_config_partial_fields(self, mock_warning):
_clear_warning()
-
# ===========================================================================
# Additional coverage tests for check_format.py (CharacterStyle / ParagraphStyle)
# ===========================================================================
@@ -350,7 +423,6 @@ def test_init_loads_warning_from_config(self, config_path):
m._warnings = m._load_warnings()
-
class TestCharacterStyleDiffFontColor:
"""Cover line 157: CharacterStyle.diff_from_run font_color diff"""
@@ -365,7 +437,6 @@ def test_diff_font_color_mismatch(self, doc, mock_warning):
_clear_warning()
-
class TestCharacterStyleApplyToRunItalic:
"""Cover lines 207-208: CharacterStyle.apply_to_run 'italic' case"""
@@ -383,7 +454,6 @@ def test_apply_to_run_fixes_italic(self, doc, mock_warning):
_clear_warning()
-
class TestCharacterStyleApplyToRunUnderline:
"""Cover lines 212-213: CharacterStyle.apply_to_run 'underline' case"""
@@ -401,7 +471,6 @@ def test_apply_to_run_fixes_underline(self, doc, mock_warning):
_clear_warning()
-
class TestCharacterStyleApplyToRunFontColor:
"""Cover lines 218-219: CharacterStyle.apply_to_run 'font_color' case"""
@@ -418,7 +487,6 @@ def test_apply_to_run_fixes_font_color(self, doc, mock_warning):
_clear_warning()
-
class TestCharacterStyleApplyToRunFontNameCn:
"""Cover lines 226-227: CharacterStyle.apply_to_run 'font_name_cn' case"""
@@ -436,7 +504,6 @@ def test_apply_to_run_fixes_font_name_cn(self, doc, mock_warning):
_clear_warning()
-
class TestCharacterStyleToStringNone:
"""Cover line 244: CharacterStyle.to_string with style_checks_warning is None"""
@@ -445,7 +512,9 @@ def test_to_string_all_warnings_enabled(self):
import dataclasses
import wordformat.style.diff as m
- m._warnings = m.WarningConfig(**{f.name: True for f in dataclasses.fields(m.WarningConfig)})
+ m._warnings = m.WarningConfig(
+ **{f.name: True for f in dataclasses.fields(m.WarningConfig)}
+ )
diffs = [
DIFFResult(diff_type="bold", current_value=True, expected_value=False),
DIFFResult(diff_type="italic", current_value=True, expected_value=False),
@@ -456,7 +525,6 @@ def test_to_string_all_warnings_enabled(self):
assert "测试" in result
-
class TestCharacterStyleToStringBoldFilter:
"""Cover line 250: CharacterStyle.to_string with style_checks_warning.bold = True"""
@@ -469,14 +537,15 @@ def test_to_string_bold_filtered_in(self, mock_warning):
_clear_warning()
-
class TestCharacterStyleToStringVariousFilters:
"""Cover lines 252, 254, 256: CharacterStyle.to_string with italic/underline/font_size/font_color/font_name filters"""
def test_to_string_italic_filtered(self, mock_warning):
"""warning.italic=True 时 italic diff 被包含。"""
_set_warning(mock_warning)
- diffs = [DIFFResult(diff_type="italic", current_value=True, expected_value=False)]
+ diffs = [
+ DIFFResult(diff_type="italic", current_value=True, expected_value=False)
+ ]
result = CharacterStyle.to_string(diffs)
assert "斜体错误" in result
_clear_warning()
@@ -484,7 +553,9 @@ def test_to_string_italic_filtered(self, mock_warning):
def test_to_string_font_size_filtered(self, mock_warning):
"""warning.font_size=True 时 font_size diff 被包含。"""
_set_warning(mock_warning)
- diffs = [DIFFResult(diff_type="font_size", current_value=10.0, expected_value=12.0)]
+ diffs = [
+ DIFFResult(diff_type="font_size", current_value=10.0, expected_value=12.0)
+ ]
result = CharacterStyle.to_string(diffs)
assert "字号错误" in result
_clear_warning()
@@ -492,7 +563,11 @@ def test_to_string_font_size_filtered(self, mock_warning):
def test_to_string_font_color_filtered(self, mock_warning):
"""warning.font_color=True 时 font_color diff 被包含。"""
_set_warning(mock_warning)
- diffs = [DIFFResult(diff_type="font_color", current_value="红色", expected_value="黑色")]
+ diffs = [
+ DIFFResult(
+ diff_type="font_color", current_value="红色", expected_value="黑色"
+ )
+ ]
result = CharacterStyle.to_string(diffs)
assert "字体颜色错误" in result
_clear_warning()
@@ -500,13 +575,16 @@ def test_to_string_font_color_filtered(self, mock_warning):
def test_to_string_font_name_filtered(self, mock_warning):
"""warning.font_name=True 时 font_name_cn diff 被包含。"""
_set_warning(mock_warning)
- diffs = [DIFFResult(diff_type="font_name_cn", current_value="宋体", expected_value="黑体")]
+ diffs = [
+ DIFFResult(
+ diff_type="font_name_cn", current_value="宋体", expected_value="黑体"
+ )
+ ]
result = CharacterStyle.to_string(diffs)
assert "中文字体错误" in result
_clear_warning()
-
class TestParagraphStyleToStringAllEnabled:
"""ParagraphStyle.to_string — warnings 全开时不过滤任何 diff。"""
@@ -515,12 +593,17 @@ def test_to_string_all_warnings_enabled(self):
import dataclasses
import wordformat.style.diff as m
- m._warnings = m.WarningConfig(**{f.name: True for f in dataclasses.fields(m.WarningConfig)})
+ m._warnings = m.WarningConfig(
+ **{f.name: True for f in dataclasses.fields(m.WarningConfig)}
+ )
diffs = [
- DIFFResult(diff_type="alignment", current_value="左对齐", expected_value="居中对齐"),
- DIFFResult(diff_type="space_before", current_value="0行", expected_value="0.5行"),
+ DIFFResult(
+ diff_type="alignment", current_value="左对齐", expected_value="居中对齐"
+ ),
+ DIFFResult(
+ diff_type="space_before", current_value="0行", expected_value="0.5行"
+ ),
]
result = ParagraphStyle.to_string(diffs)
assert "对齐错误" in result
assert "段前间距错误" in result
-
diff --git a/tests/style/test_inheritance.py b/tests/style/test_inheritance.py
index 22c9939..88f9a5f 100644
--- a/tests/style/test_inheritance.py
+++ b/tests/style/test_inheritance.py
@@ -211,7 +211,9 @@ def test_multiple_custom(self, doc):
assert paragraph_get_line_spacing(p) == 2.3
assert paragraph_get_line_spacing_rule(p) == WD_LINE_SPACING.MULTIPLE
- @pytest.mark.parametrize("rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST])
+ @pytest.mark.parametrize(
+ "rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST]
+ )
def test_fixed_returns_none(self, doc, rule):
p = doc.add_paragraph()
p.paragraph_format.line_spacing_rule = rule
@@ -274,6 +276,7 @@ def test_size_invalid(self, doc):
sz.set(qn("w:val"), "abc")
rPr.append(sz)
from wordformat.style.inheritance import _MISS
+
assert x_size_pt(rPr) is _MISS
def test_theme_ref_font(self, doc):
@@ -284,6 +287,7 @@ def test_theme_ref_font(self, doc):
rFonts.set(qn("w:asciiTheme"), "majorHAnsi")
rPr.append(rFonts)
from wordformat.style.inheritance import x_font_ascii
+
assert isinstance(x_font_ascii(rPr), ThemeRef)
assert run_get_font_name_en(r) == "Calibri"
diff --git a/tests/style/test_reader.py b/tests/style/test_reader.py
index 554cae1..a47f0a1 100644
--- a/tests/style/test_reader.py
+++ b/tests/style/test_reader.py
@@ -15,21 +15,45 @@
from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle
from wordformat.style.reader import (
- paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after,
- paragraph_get_line_spacing, paragraph_get_first_line_indent,
- paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt,
- run_get_font_color, run_get_font_bold, run_get_font_italic,
- run_get_font_underline, GetIndent,
+ paragraph_get_alignment,
+ paragraph_get_space_before,
+ paragraph_get_space_after,
+ paragraph_get_line_spacing,
+ paragraph_get_first_line_indent,
+ paragraph_get_builtin_style_name,
+ run_get_font_name,
+ run_get_font_size_pt,
+ run_get_font_color,
+ run_get_font_bold,
+ run_get_font_italic,
+ run_get_font_underline,
+ GetIndent,
)
from wordformat.style.writer import (
- run_set_font_name, set_paragraph_space_before_by_lines,
- set_paragraph_space_after_by_lines, _paragraph_space_by_lines,
- SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent,
+ run_set_font_name,
+ set_paragraph_space_before_by_lines,
+ set_paragraph_space_after_by_lines,
+ _paragraph_space_by_lines,
+ SetSpacing,
+ SetLineSpacing,
+ SetIndent,
+ SetFirstLineIndent,
)
from wordformat.style.defs import (
- FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
- FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
- Spacing, UnitLabelEnum,
+ FontName,
+ FontSize,
+ FontColor,
+ Alignment,
+ LineSpacingRule,
+ LineSpacing,
+ FirstLineIndent,
+ LeftIndent,
+ RightIndent,
+ BuiltInStyle,
+ SpaceBefore,
+ SpaceAfter,
+ Spacing,
+ UnitLabelEnum,
)
from wordformat.style.units import extract_unit_from_string, UnitResult
@@ -38,6 +62,7 @@
# fixtures & helpers
# ---------------------------------------------------------------------------
+
@pytest.fixture
def doc():
return Document()
@@ -46,10 +71,23 @@ def doc():
@pytest.fixture
def mock_warning():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, True)
return w
@@ -57,21 +95,36 @@ def mock_warning():
@pytest.fixture
def mock_warning_off():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, False)
return w
def _set_warning(w):
import wordformat.style.diff as m
+
m.style_checks_warning = w
def _clear_warning():
import wordformat.style.diff as m
+
m.__dict__.pop("style_checks_warning", None)
@@ -79,8 +132,12 @@ def _clear_warning():
# get_some
# ===========================================================================
+
class TestGetSomeAlignment:
- @pytest.mark.parametrize("align", [WD_ALIGN_PARAGRAPH.LEFT, WD_ALIGN_PARAGRAPH.CENTER, WD_ALIGN_PARAGRAPH.RIGHT])
+ @pytest.mark.parametrize(
+ "align",
+ [WD_ALIGN_PARAGRAPH.LEFT, WD_ALIGN_PARAGRAPH.CENTER, WD_ALIGN_PARAGRAPH.RIGHT],
+ )
def test_direct(self, doc, align):
p = doc.add_paragraph()
p.paragraph_format.alignment = align
@@ -90,12 +147,15 @@ def test_no_alignment_returns_none(self, doc):
assert paragraph_get_alignment(doc.add_paragraph()) is None
-
class TestGetSomeLineSpacing:
- @pytest.mark.parametrize("rule,expected", [
- (WD_LINE_SPACING.SINGLE, 1.0), (WD_LINE_SPACING.ONE_POINT_FIVE, 1.5),
- (WD_LINE_SPACING.DOUBLE, 2.0),
- ])
+ @pytest.mark.parametrize(
+ "rule,expected",
+ [
+ (WD_LINE_SPACING.SINGLE, 1.0),
+ (WD_LINE_SPACING.ONE_POINT_FIVE, 1.5),
+ (WD_LINE_SPACING.DOUBLE, 2.0),
+ ],
+ )
def test_preset(self, doc, rule, expected):
p = doc.add_paragraph()
p.paragraph_format.line_spacing_rule = rule
@@ -107,14 +167,15 @@ def test_multiple_custom(self, doc):
p.paragraph_format.line_spacing = 2.3
assert paragraph_get_line_spacing(p) == 2.3
- @pytest.mark.parametrize("rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST])
+ @pytest.mark.parametrize(
+ "rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST]
+ )
def test_fixed_returns_none(self, doc, rule):
p = doc.add_paragraph()
p.paragraph_format.line_spacing_rule = rule
assert paragraph_get_line_spacing(p) is None
-
class TestGetSomeRun:
def test_font_name_default_none(self, doc):
assert run_get_font_name(doc.add_paragraph().add_run("x")) is None
@@ -141,10 +202,14 @@ def test_font_color_set(self, doc):
run.font.color.rgb = RGBColor(0xFF, 0, 0)
assert run_get_font_color(run) == (255, 0, 0)
- @pytest.mark.parametrize("getter,attr", [
- (run_get_font_bold, "bold"), (run_get_font_italic, "italic"),
- (run_get_font_underline, "underline"),
- ])
+ @pytest.mark.parametrize(
+ "getter,attr",
+ [
+ (run_get_font_bold, "bold"),
+ (run_get_font_italic, "italic"),
+ (run_get_font_underline, "underline"),
+ ],
+ )
def test_boolean_props(self, doc, getter, attr):
run = doc.add_paragraph().add_run("x")
assert getter(run) is False
@@ -152,7 +217,6 @@ def test_boolean_props(self, doc, getter, attr):
assert getter(run) is True
-
class TestGetSomeFirstLineIndent:
def test_default_none(self, doc):
assert paragraph_get_first_line_indent(doc.add_paragraph()) is None
@@ -169,14 +233,12 @@ def test_ignores_firstLine_twips(self, doc):
assert paragraph_get_first_line_indent(p) is None
-
class TestGetSomeBuiltinStyle:
def test_default_normal_lowercase(self, doc):
name = paragraph_get_builtin_style_name(doc.add_paragraph())
assert name == "normal"
-
class TestGetIndent:
def test_default_none(self, doc):
p = doc.add_paragraph()
@@ -193,7 +255,6 @@ def test_left_after_set_char(self, doc):
assert GetIndent.left_indent(p) == 3.0
-
# ===========================================================================
# Additional coverage tests for get_some.py
# ===========================================================================
@@ -213,6 +274,7 @@ def test_alignment_from_style(self, doc):
def test_alignment_from_base_style(self, doc):
"""沿真实 basedOn 链解析对齐:Child(basedOn=Base) 继承 Base 的对齐。"""
from docx.enum.style import WD_STYLE_TYPE
+
base = doc.styles.add_style("BaseAlign", WD_STYLE_TYPE.PARAGRAPH)
base.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.RIGHT
child = doc.styles.add_style("ChildAlign", WD_STYLE_TYPE.PARAGRAPH)
@@ -229,7 +291,6 @@ def test_alignment_no_base_style_returns_none(self, doc):
assert paragraph_get_alignment(p) is None
-
class TestGetSomeSpaceBeforeAfterInheritance:
"""Cover lines 147-148, 157-158: space_before/after with style inheritance"""
@@ -238,6 +299,7 @@ def test_space_before_invalid_attr_returns_none(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:beforeLines"), "abc")
@@ -250,6 +312,7 @@ def test_space_after_invalid_attr_returns_none(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:afterLines"), "xyz")
@@ -262,7 +325,9 @@ def test_space_before_from_style(self, doc):
mock_style = MagicMock()
mock_style.element = None
mock_style.base_style = None
- with mock_patch.object(type(p), 'style', new_callable=PropertyMock, return_value=mock_style):
+ with mock_patch.object(
+ type(p), "style", new_callable=PropertyMock, return_value=mock_style
+ ):
# _get_style_spacing returns None for None element
assert paragraph_get_space_before(p) is None
@@ -272,11 +337,12 @@ def test_space_after_from_style(self, doc):
mock_style = MagicMock()
mock_style.element = None
mock_style.base_style = None
- with mock_patch.object(type(p), 'style', new_callable=PropertyMock, return_value=mock_style):
+ with mock_patch.object(
+ type(p), "style", new_callable=PropertyMock, return_value=mock_style
+ ):
assert paragraph_get_space_after(p) is None
-
class TestGetSomeLineSpacingInheritance:
"""Cover lines 233-235: paragraph_get_line_spacing with style inheritance"""
@@ -297,7 +363,6 @@ def test_line_spacing_type_error_returns_none(self):
assert result is None
-
class TestGetSomeFirstLineIndentPhysicalUnit:
"""Cover lines 264-266: paragraph_get_first_line_indent with firstLine (physical unit)"""
@@ -306,20 +371,22 @@ def test_first_line_indent_exception_returns_none(self, doc):
p = doc.add_paragraph()
# Force an exception by making pPr raise
original = p._element.find
+
def bad_find(qname):
raise RuntimeError("test error")
+
p._element.find = bad_find
assert paragraph_get_first_line_indent(p) is None
p._element.find = original
-
class TestGetSomeRunExtended:
"""Cover lines 317, 338: run_get_font_size_pt with style, run_get_font_color with theme"""
def test_font_size_from_style(self, doc):
"""run 无直接字号时,沿段落样式链解析到样式字号。"""
from docx.enum.style import WD_STYLE_TYPE
+
st = doc.styles.add_style("SizeStyle", WD_STYLE_TYPE.PARAGRAPH)
st.font.size = Pt(16)
run = doc.add_paragraph(style="SizeStyle").add_run("x")
@@ -341,7 +408,6 @@ def test_font_color_none(self, doc):
assert run_get_font_color(mock_run) == (0, 0, 0)
-
class TestGetIndentWithRealElement:
"""Cover lines 408-423: GetIndent.line_indent with real indent element"""
@@ -350,6 +416,7 @@ def test_line_indent_left_with_chars(self, doc):
p = doc.add_paragraph()
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+
pPr = p._element.get_or_add_pPr()
ind = OxmlElement("w:ind")
ind.set(qn("w:leftChars"), "300")
@@ -361,6 +428,7 @@ def test_line_indent_right_with_chars(self, doc):
p = doc.add_paragraph()
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+
pPr = p._element.get_or_add_pPr()
ind = OxmlElement("w:ind")
ind.set(qn("w:rightChars"), "200")
@@ -372,6 +440,7 @@ def test_line_indent_invalid_chars_value(self, doc):
p = doc.add_paragraph()
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+
pPr = p._element.get_or_add_pPr()
ind = OxmlElement("w:ind")
ind.set(qn("w:leftChars"), "abc")
@@ -391,6 +460,7 @@ def test_line_indent_no_ind(self, doc):
"""pPr exists but no ind element -> returns None (line 404-405)"""
p = doc.add_paragraph()
from docx.oxml.ns import qn
+
pPr = p._element.get_or_add_pPr()
ind = pPr.find(qn("w:ind"))
if ind is not None:
@@ -398,7 +468,6 @@ def test_line_indent_no_ind(self, doc):
assert GetIndent.line_indent(p, "left") is None
-
class TestParagraphGetSpaceBeforeWithValidLines:
"""Cover lines 157-158: paragraph_get_space_before with valid beforeLines in XML"""
@@ -407,6 +476,7 @@ def test_space_before_valid_lines(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:beforeLines"), "100")
@@ -418,6 +488,7 @@ def test_space_before_valid_lines_rounded(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:beforeLines"), "33")
@@ -425,7 +496,6 @@ def test_space_before_valid_lines_rounded(self, doc):
assert paragraph_get_space_before(p) == 0.3 # round(0.33, 1)
-
class TestParagraphGetSpaceAfterWithValidLines:
"""Cover lines 190-191: paragraph_get_space_after with valid afterLines in XML"""
@@ -434,6 +504,7 @@ def test_space_after_valid_lines(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:afterLines"), "150")
@@ -445,6 +516,7 @@ def test_space_after_valid_lines_rounded(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:afterLines"), "67")
@@ -452,7 +524,6 @@ def test_space_after_valid_lines_rounded(self, doc):
assert paragraph_get_space_after(p) == 0.7 # round(0.67, 1)
-
class TestRunGetFontColorValidRGB:
"""Cover line 280: run_get_font_color when color.rgb is a valid RGBColor"""
@@ -465,7 +536,6 @@ def test_font_color_valid_rgb_tuple_access(self, doc):
assert result == (0x12, 0x34, 0x56)
-
class TestRunGetFontColorFalsyRGB:
"""Cover line 338: run_get_font_color when color.rgb is falsy (empty string)"""
@@ -489,7 +559,6 @@ def test_font_color_zero_rgb(self):
assert result == (0, 0, 0)
-
class TestGetIndentLineIndentWithCharsValues:
"""Cover lines 419-423: GetIndent.line_indent with valid leftChars/rightChars values"""
@@ -498,6 +567,7 @@ def test_line_indent_left_chars_valid(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
ind = OxmlElement("w:ind")
ind.set(qn("w:leftChars"), "150")
@@ -509,6 +579,7 @@ def test_line_indent_right_chars_valid(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
ind = OxmlElement("w:ind")
ind.set(qn("w:rightChars"), "250")
@@ -520,6 +591,7 @@ def test_line_indent_left_chars_zero(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
ind = OxmlElement("w:ind")
ind.set(qn("w:leftChars"), "0")
@@ -531,4 +603,3 @@ def test_line_indent_exception_returns_none(self, doc):
mock_para = MagicMock()
mock_para._element.pPr = None
assert GetIndent.line_indent(mock_para, "left") is None
-
diff --git a/tests/style/test_writer.py b/tests/style/test_writer.py
index 2728ce8..58f4568 100644
--- a/tests/style/test_writer.py
+++ b/tests/style/test_writer.py
@@ -15,21 +15,45 @@
from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle
from wordformat.style.reader import (
- paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after,
- paragraph_get_line_spacing, paragraph_get_first_line_indent,
- paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt,
- run_get_font_color, run_get_font_bold, run_get_font_italic,
- run_get_font_underline, GetIndent,
+ paragraph_get_alignment,
+ paragraph_get_space_before,
+ paragraph_get_space_after,
+ paragraph_get_line_spacing,
+ paragraph_get_first_line_indent,
+ paragraph_get_builtin_style_name,
+ run_get_font_name,
+ run_get_font_size_pt,
+ run_get_font_color,
+ run_get_font_bold,
+ run_get_font_italic,
+ run_get_font_underline,
+ GetIndent,
)
from wordformat.style.writer import (
- run_set_font_name, set_paragraph_space_before_by_lines,
- set_paragraph_space_after_by_lines, _paragraph_space_by_lines,
- SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent,
+ run_set_font_name,
+ set_paragraph_space_before_by_lines,
+ set_paragraph_space_after_by_lines,
+ _paragraph_space_by_lines,
+ SetSpacing,
+ SetLineSpacing,
+ SetIndent,
+ SetFirstLineIndent,
)
from wordformat.style.defs import (
- FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
- FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
- Spacing, UnitLabelEnum,
+ FontName,
+ FontSize,
+ FontColor,
+ Alignment,
+ LineSpacingRule,
+ LineSpacing,
+ FirstLineIndent,
+ LeftIndent,
+ RightIndent,
+ BuiltInStyle,
+ SpaceBefore,
+ SpaceAfter,
+ Spacing,
+ UnitLabelEnum,
)
from wordformat.style.units import extract_unit_from_string, UnitResult
@@ -38,6 +62,7 @@
# fixtures & helpers
# ---------------------------------------------------------------------------
+
@pytest.fixture
def doc():
return Document()
@@ -46,10 +71,23 @@ def doc():
@pytest.fixture
def mock_warning():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, True)
return w
@@ -57,21 +95,36 @@ def mock_warning():
@pytest.fixture
def mock_warning_off():
w = MagicMock()
- for attr in ("bold", "italic", "underline", "font_size", "font_color",
- "font_name", "alignment", "space_before", "space_after",
- "line_spacing", "line_spacingrule", "first_line_indent",
- "left_indent", "right_indent", "builtin_style_name"):
+ for attr in (
+ "bold",
+ "italic",
+ "underline",
+ "font_size",
+ "font_color",
+ "font_name",
+ "alignment",
+ "space_before",
+ "space_after",
+ "line_spacing",
+ "line_spacingrule",
+ "first_line_indent",
+ "left_indent",
+ "right_indent",
+ "builtin_style_name",
+ ):
setattr(w, attr, False)
return w
def _set_warning(w):
import wordformat.style.diff as m
+
m.style_checks_warning = w
def _clear_warning():
import wordformat.style.diff as m
+
m.__dict__.pop("style_checks_warning", None)
@@ -79,6 +132,7 @@ def _clear_warning():
# set_some
# ===========================================================================
+
class TestSetSomeFontName:
def test_set_and_verify_xml(self, doc):
run = doc.add_paragraph().add_run("x")
@@ -86,7 +140,6 @@ def test_set_and_verify_xml(self, doc):
assert run._element.rPr.rFonts.get(qn("w:eastAsia")) == "黑体"
-
class TestSetSomeSpaceByLines:
def test_set_before_and_after(self, doc):
p = doc.add_paragraph()
@@ -108,7 +161,6 @@ def test_zero_preserves_existing(self, doc):
assert paragraph_get_space_after(p) == 1.0
-
class TestSetSpacingHang:
def test_set_and_clamp(self, doc):
p = doc.add_paragraph()
@@ -118,7 +170,6 @@ def test_set_and_clamp(self, doc):
assert paragraph_get_space_before(p) == 10.0
-
class TestSetLineSpacing:
@pytest.mark.parametrize("method,val", [("set_pt", 20), ("set_cm", 1.0)])
def test_sets_exactly_rule(self, doc, method, val):
@@ -127,7 +178,6 @@ def test_sets_exactly_rule(self, doc, method, val):
assert p.paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY
-
class TestSetIndent:
def test_set_char_left(self, doc):
p = doc.add_paragraph()
@@ -140,9 +190,15 @@ def test_set_char_returns_true(self, doc):
def test_set_char_invalid_returns_false(self, doc):
assert SetIndent.set_char(doc.add_paragraph(), "Z", 1) is False
- @pytest.mark.parametrize("method,indent_type", [
- ("set_pt", "R"), ("set_cm", "X"), ("set_inch", "R"), ("set_mm", "R"),
- ])
+ @pytest.mark.parametrize(
+ "method,indent_type",
+ [
+ ("set_pt", "R"),
+ ("set_cm", "X"),
+ ("set_inch", "R"),
+ ("set_mm", "R"),
+ ],
+ )
def test_physical_units_set_indent(self, doc, method, indent_type):
p = doc.add_paragraph()
getattr(SetIndent, method)(p, indent_type, 1.0)
@@ -154,7 +210,6 @@ def test_apply_indent_invalid_raises(self, doc):
SetIndent._apply_indent(doc.add_paragraph(), "Z", 10)
-
class TestSetFirstLineIndent:
def test_set_and_clear(self, doc):
p = doc.add_paragraph()
@@ -190,7 +245,6 @@ def test_physical_units_no_firstLineChars(self, doc, method):
assert paragraph_get_first_line_indent(p) is None
-
# ===========================================================================
# Additional coverage tests for set_some.py
# ===========================================================================
@@ -252,6 +306,7 @@ def test_set_hang_sets_twips_to_zero(self, doc):
p = doc.add_paragraph()
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
+
pPr = p._element.get_or_add_pPr()
spacing = OxmlElement("w:spacing")
spacing.set(qn("w:before"), "200")
@@ -261,7 +316,6 @@ def test_set_hang_sets_twips_to_zero(self, doc):
assert spacing_after.get(qn("w:before")) == "0"
-
class TestSetLineSpacingUnits:
"""Cover lines 234-235, 245-246: _SetLineSpacing additional unit methods"""
@@ -278,7 +332,6 @@ def test_set_mm(self, doc):
assert p.paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY
-
class TestSetIndentUnits:
"""Cover lines 288-291, 300-302, 461-463: _SetIndent and _SetFirstLineIndent unit methods"""
@@ -328,7 +381,6 @@ def test_set_mm(self, doc):
assert p.paragraph_format.right_indent is not None
-
class TestSetFirstLineIndentUnits:
"""Cover lines 478, 494, 510, 526: _SetFirstLineIndent physical unit methods"""
@@ -376,13 +428,14 @@ def test_set_char_exception_returns_false(self, doc):
# clear() calls get_or_add_pPr first, then we make it fail.
call_count = [0]
original_get_or_add = p._element.get_or_add_pPr
+
def bad_get_or_add():
call_count[0] += 1
if call_count[0] > 1:
# First call is from clear(), second is from the try block
raise RuntimeError("test error")
return original_get_or_add()
+
p._element.get_or_add_pPr = bad_get_or_add
assert SetFirstLineIndent.set_char(p, 2) is False
p._element.get_or_add_pPr = original_get_or_add
-
diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py
index e306326..eb2413d 100644
--- a/tests/test_coverage_boost.py
+++ b/tests/test_coverage_boost.py
@@ -70,6 +70,7 @@
def _load_yaml(path):
import yaml
+
with open(path, encoding="utf-8") as f:
return yaml.safe_load(f)
@@ -170,7 +171,7 @@ def test_fix_all_style_definitions_handles_document(self, root_config):
def test_fix_style_run_properties_clears_theme_color(self, root_config):
"""_fix_style_run_properties 应清除样式定义中的主题色。"""
-
+
from wordformat.style.defs import ensure_style_exists
doc = Document()
@@ -197,7 +198,7 @@ def test_fix_style_run_properties_clears_theme_color(self, root_config):
def test_fix_style_run_properties_sets_font_name(self, root_config):
"""_fix_style_run_properties 应设置样式定义中的英文字体名。"""
-
+
from wordformat.style.defs import ensure_style_exists
doc = Document()
@@ -213,9 +214,9 @@ def test_fix_style_run_properties_sets_font_name(self, root_config):
def test_fix_style_run_properties_sets_bold(self, root_config):
"""_fix_style_run_properties 应设置样式定义中的加粗属性。"""
-
+
from wordformat.style.defs import ensure_style_exists
-
+
doc = Document()
cfg = NodeConfigRoot(bold=True)
ensure_style_exists(doc, "Heading 1")
@@ -247,9 +248,9 @@ def test_fix_style_run_properties_disables_italic(self, root_config):
def test_fix_style_paragraph_properties_sets_alignment(self, root_config):
"""_fix_style_paragraph_properties 应设置样式定义中的对齐方式。"""
-
+
from wordformat.style.defs import ensure_style_exists
-
+
doc = Document()
cfg = NodeConfigRoot(alignment="居中对齐")
ensure_style_exists(doc, "Heading 1")
@@ -631,6 +632,7 @@ def test_first_line_indent_pt_unit(self):
doc = Document()
p = doc.add_paragraph()
from docx.shared import Pt
+
p.paragraph_format.first_line_indent = Pt(24)
fli = FirstLineIndent("24pt")
result = fli.get_from_paragraph(p)
@@ -641,6 +643,7 @@ def test_first_line_indent_cm_unit(self):
doc = Document()
p = doc.add_paragraph()
from docx.shared import Cm
+
p.paragraph_format.first_line_indent = Cm(0.85)
fli = FirstLineIndent("0.85cm")
result = fli.get_from_paragraph(p)
@@ -651,6 +654,7 @@ def test_first_line_indent_inch_unit(self):
doc = Document()
p = doc.add_paragraph()
from docx.shared import Inches
+
p.paragraph_format.first_line_indent = Inches(0.5)
fli = FirstLineIndent("0.5inch")
result = fli.get_from_paragraph(p)
@@ -669,9 +673,7 @@ def test_ensure_style_exists_base_style_none(self):
ensure_style_exists(doc, "TestStyleNoBase")
# 清理
try:
- doc.styles.element.remove(
- doc.styles["TestStyleNoBase"].element
- )
+ doc.styles.element.remove(doc.styles["TestStyleNoBase"].element)
except Exception:
pass
@@ -756,7 +758,9 @@ def test_fix_all_style_definitions_theme_color_fix(self):
config_model = NodeConfigRoot(
global_format=NodeConfigRoot(),
headings=NodeConfigRoot(
- level_1=NodeConfigRoot(builtin_style_name="Heading 1", font_color="黑色"),
+ level_1=NodeConfigRoot(
+ builtin_style_name="Heading 1", font_color="黑色"
+ ),
),
)
@@ -811,6 +815,7 @@ def test_apply_format_check_to_all_nodes_no_check_format(self):
当节点没有 check_format 方法时,跳过。
"""
+
# 使用一个没有 check_format 的简单对象
class SimpleNode:
def __init__(self):
@@ -840,7 +845,9 @@ def test_apply_format_check_to_all_nodes_exception_handling(self):
config = MagicMock()
# load_config 会抛出异常
- with patch.object(root_node, "load_config", side_effect=ValueError("test error")):
+ with patch.object(
+ root_node, "load_config", side_effect=ValueError("test error")
+ ):
with pytest.raises(ValueError):
apply_format_check_to_all_nodes(root_node, doc, config, check=True)
@@ -951,6 +958,7 @@ def test_run_get_font_color_theme(self):
"""themeColor(主题色)返回 None:rgb 不确定。"""
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+
run = Document().add_paragraph().add_run("x")
rPr = run._element.get_or_add_rPr()
color = OxmlElement("w:color")
@@ -978,6 +986,7 @@ def test_paragraph_get_line_spacing_multiple(self):
p = doc.add_paragraph()
from docx.shared import Pt
from docx.enum.text import WD_LINE_SPACING
+
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
p.paragraph_format.line_spacing = 1.5
result = paragraph_get_line_spacing(p)
@@ -988,6 +997,7 @@ def test_paragraph_get_line_spacing_at_least(self):
doc = Document()
p = doc.add_paragraph()
from docx.enum.text import WD_LINE_SPACING
+
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST
result = paragraph_get_line_spacing(p)
assert result is None
@@ -1155,6 +1165,7 @@ def test_ensure_directory_exists_creates(self, tmp_path):
new_dir = str(tmp_path / "new_subdir" / "nested")
ensure_directory_exists(new_dir)
import os
+
assert os.path.isdir(new_dir)
def test_ensure_directory_exists_already_exists(self, tmp_path):
@@ -1183,6 +1194,7 @@ class TestCoverageBoostRound2:
def test_ensure_directory_exists_creates_dir(self, tmp_path):
"""确保目录不存在时创建。"""
from wordformat.utils import ensure_directory_exists
+
d = str(tmp_path / "new_dir")
ensure_directory_exists(d)
assert os.path.isdir(d)
@@ -1190,6 +1202,7 @@ def test_ensure_directory_exists_creates_dir(self, tmp_path):
def test_para_contains_image_false(self):
"""无图片的段落返回 False。"""
from wordformat.utils import para_contains_image
+
doc = Document()
p = doc.add_paragraph("text")
assert para_contains_image(p) is False
@@ -1197,6 +1210,7 @@ def test_para_contains_image_false(self):
def test_remove_all_numbering(self):
"""remove_all_numbering 对空白文档不抛异常。"""
from wordformat.utils import remove_all_numbering
+
doc = Document()
remove_all_numbering(doc)
@@ -1205,6 +1219,7 @@ def test_remove_all_numbering(self):
def test_config_not_loaded_error(self):
"""ConfigNotLoadedError 可正常抛出和捕获。"""
from wordformat.config.loader import ConfigNotLoadedError
+
with pytest.raises(ConfigNotLoadedError):
raise ConfigNotLoadedError("test")
@@ -1214,6 +1229,7 @@ def test_rPr_set_italic_remove(self):
"""rPr_set_italic 传入 False 时移除 w:i。"""
from wordformat.style.xml_ops import rPr_set_italic, ensure_rPr
from docx.oxml import OxmlElement
+
style = Document().styles["Normal"]
rPr = ensure_rPr(style.element)
rPr.append(OxmlElement("w:i"))
@@ -1224,6 +1240,7 @@ def test_rPr_set_underline_remove(self):
"""rPr_set_underline 传入 False 时移除 w:u。"""
from wordformat.style.xml_ops import rPr_set_underline, ensure_rPr
from docx.oxml import OxmlElement
+
style = Document().styles["Normal"]
rPr = ensure_rPr(style.element)
u = OxmlElement("w:u")
@@ -1238,10 +1255,15 @@ def test_print_tree_from_json(self, tmp_path):
"""print_tree 支持传入 JSON 文件路径。"""
import json
from wordformat.tree import print_tree
+
json_path = tmp_path / "test.json"
- json_path.write_text(json.dumps([
- {"category": "body_text", "paragraph": "hello"},
- ]))
+ json_path.write_text(
+ json.dumps(
+ [
+ {"category": "body_text", "paragraph": "hello"},
+ ]
+ )
+ )
# 不应抛异常
with patch("sys.stdout", io.StringIO()):
print_tree(str(json_path), show_index=True, show_confidence=True)
@@ -1252,6 +1274,7 @@ def test_process_heading_numbering_disabled(self):
"""numbering 禁用时不处理。"""
from wordformat.numbering import process_heading_numbering
from unittest.mock import MagicMock
+
config = MagicMock()
config.enabled = False
process_heading_numbering(None, Document(), config)
@@ -1263,6 +1286,7 @@ def test_formatting_stage_skips_void_nodes(self, doc):
from wordformat.pipeline.stages import FormattingExecutionStage
from wordformat.rules.node import FormatNode
from wordformat.settings import VOIDNODELIST
+
stage = FormattingExecutionStage()
root = FormatNode(value={"category": VOIDNODELIST[0]}, level=0)
root.children = []
@@ -1271,29 +1295,38 @@ def test_formatting_stage_skips_void_nodes(self, doc):
def test_summary_stage_adds_comment(self, doc):
"""SummaryGenerationStage 在 check 模式下添加批注。"""
- from wordformat.pipeline.stages import SummaryGenerationStage, FormattingExecutionStage
+ from wordformat.pipeline.stages import (
+ SummaryGenerationStage,
+ FormattingExecutionStage,
+ )
from wordformat.rules.node import FormatNode
+
p = doc.add_paragraph("")
root = FormatNode(value={"category": "top"}, level=0)
root.children = []
FormatNode.reset_stats()
stage = SummaryGenerationStage()
from wordformat.pipeline.context import FormatContext
+
ctx = FormatContext(
- json_path="", docx_path="", check=True,
- config_path="", save_dir="/tmp",
- document=doc, root_node=root,
+ json_path="",
+ docx_path="",
+ check=True,
+ config_path="",
+ save_dir="/tmp",
+ document=doc,
+ root_node=root,
)
stage.process(ctx)
# --- utils/_docx.py more ---
-
# --- style/xml_ops.py ---
def test_rPr_set_bold_remove(self):
"""rPr_set_bold 传入 False 移除 w:b。"""
from wordformat.style.xml_ops import rPr_set_bold, ensure_rPr
+
style = Document().styles["Normal"]
rPr = ensure_rPr(style.element)
rPr.append(OxmlElement("w:b"))
@@ -1303,6 +1336,7 @@ def test_rPr_set_bold_remove(self):
def test_line_rule_to_xml_unknown(self):
"""line_rule_to_xml 对未知值返回 auto。"""
from wordformat.style.xml_ops import line_rule_to_xml
+
assert line_rule_to_xml(999) == "auto"
# --- style/defs.py ---
@@ -1310,6 +1344,7 @@ def test_line_rule_to_xml_unknown(self):
def test_fontsize_base_set_numeric(self):
"""FontSize.base_set 处理数字字符串。"""
from wordformat.style.defs import FontSize
+
doc = Document()
p = doc.add_paragraph("test")
run = p.add_run("x")
@@ -1320,6 +1355,7 @@ def test_fontsize_base_set_numeric(self):
def test_fontname_base_set_english(self):
"""FontName.base_set 处理英文字体。"""
from wordformat.style.defs import FontName
+
doc = Document()
p = doc.add_paragraph("test")
run = p.add_run("x")
@@ -1330,6 +1366,7 @@ def test_fontname_base_set_english(self):
def test_unit_label_enum_eq_none(self):
"""UnitLabelEnum __eq__ 对 None 返回 rel_value == 0。"""
from wordformat.style.defs import FirstLineIndent
+
indent = FirstLineIndent("0字符")
assert indent == None # rel_value is 0, so equal to None
@@ -1338,6 +1375,7 @@ def test_unit_label_enum_eq_none(self):
def test_auto_strip_numbering_empty_runs(self):
"""空 run 的段落 _auto_strip_numbering 返回 False。"""
from wordformat.numbering import _auto_strip_numbering
+
doc = Document()
p = doc.add_paragraph("")
assert _auto_strip_numbering(p, 0) is False
@@ -1348,6 +1386,7 @@ def test_load_config_stage_no_config(self):
"""未提供配置文件时使用默认配置。"""
from wordformat.pipeline.stages import LoadConfigStage
from wordformat.pipeline.context import FormatContext
+
ctx = FormatContext(json_path="", docx_path="", check=True, config_path="")
stage = LoadConfigStage()
result = stage.process(ctx)
@@ -1355,9 +1394,9 @@ def test_load_config_stage_no_config(self):
# --- utils/_fs.py ---
-
def test_ensure_is_directory_missing(self, tmp_path):
"""ensure_is_directory 路径不存在时抛出 ValueError。"""
from wordformat.utils import ensure_is_directory
+
with pytest.raises(ValueError, match="不存在"):
ensure_is_directory(str(tmp_path / "nonexistent"))
diff --git a/tests/test_integration.py b/tests/test_integration.py
index ed3d2c8..b4b1f1f 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -3,6 +3,7 @@
覆盖模块:cli.py, config, agent, set_style.py, set_tag.py, word_structure
"""
+
import argparse
import io
import os
@@ -46,7 +47,9 @@
from wordformat.rules.body import BodyText
from wordformat.api import save_upload_file, TEMP_DIR
-apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes
+apply_format_check_to_all_nodes = (
+ FormattingExecutionStage().apply_format_check_to_all_nodes
+)
# ==================== (a) Config 模块集成测试 ====================
@@ -107,12 +110,9 @@ def create():
assert len(set(id(r) for r in results)) == 10
-
# ==================== (b) DataModel 验证测试 ====================
-
-
# ==================== (c) Agent/Message 测试 ====================
@@ -185,7 +185,6 @@ def clearer():
assert len(msgs) == 0
-
# ==================== (d) Agent/ONNX 推理测试 ====================
@@ -193,10 +192,14 @@ class TestONNXInferIntegration:
"""Mock 模型加载,验证推理流程与已知 bug"""
def test_single_infer_success(self):
- with mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, \
- mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, \
- mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text", 1: "heading"}), \
- mock.patch("wordformat.agent.onnx_infer._load_model"):
+ with (
+ mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok,
+ mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess,
+ mock.patch(
+ "wordformat.agent.onnx_infer._id2label", {0: "body_text", 1: "heading"}
+ ),
+ mock.patch("wordformat.agent.onnx_infer._load_model"),
+ ):
enc = mock.MagicMock()
enc.ids = [1, 2, 3]
enc.attention_mask = [1, 1, 1]
@@ -208,10 +211,12 @@ def test_single_infer_success(self):
assert result["score"] > 0.5
def test_single_infer_error_returns_empty(self):
- with mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, \
- mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, \
- mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}), \
- mock.patch("wordformat.agent.onnx_infer._load_model"):
+ with (
+ mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok,
+ mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess,
+ mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}),
+ mock.patch("wordformat.agent.onnx_infer._load_model"),
+ ):
enc = mock.MagicMock()
enc.ids = [1, 2, 3]
enc.attention_mask = [1, 1, 1]
@@ -223,10 +228,12 @@ def test_single_infer_error_returns_empty(self):
def test_batch_infer_error_format_matches_single(self):
"""batch 失败与 single 失败应返回相同结构"""
- with mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, \
- mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, \
- mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}), \
- mock.patch("wordformat.agent.onnx_infer._load_model"):
+ with (
+ mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok,
+ mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess,
+ mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}),
+ mock.patch("wordformat.agent.onnx_infer._load_model"),
+ ):
enc = mock.MagicMock()
enc.ids = [1, 2, 3]
enc.attention_mask = [1, 1, 1]
@@ -245,13 +252,19 @@ def test_safe_batch_splits_correctly(self, mock_batch):
assert mock_batch.call_count == 2
def test_provider_selection_priority(self):
- with mock.patch("onnxruntime.get_available_providers",
- return_value=["CUDAExecutionProvider", "CPUExecutionProvider"]):
+ with mock.patch(
+ "onnxruntime.get_available_providers",
+ return_value=["CUDAExecutionProvider", "CPUExecutionProvider"],
+ ):
assert _get_best_onnx_providers() == ["CUDAExecutionProvider"]
- with mock.patch("onnxruntime.get_available_providers",
- return_value=["DmlExecutionProvider", "CPUExecutionProvider"]):
+ with mock.patch(
+ "onnxruntime.get_available_providers",
+ return_value=["DmlExecutionProvider", "CPUExecutionProvider"],
+ ):
assert _get_best_onnx_providers() == ["DmlExecutionProvider"]
- with mock.patch("onnxruntime.get_available_providers", return_value=["CPUExecutionProvider"]):
+ with mock.patch(
+ "onnxruntime.get_available_providers", return_value=["CPUExecutionProvider"]
+ ):
assert _get_best_onnx_providers() == ["CPUExecutionProvider"]
def test_global_state_thread_safety(self):
@@ -272,7 +285,6 @@ def load():
assert len(errors) == 0
-
# ==================== (e) word_structure 集成测试 ====================
@@ -281,6 +293,7 @@ class TestNodeFactoryIntegration:
def test_create_known_category(self, sample_yaml_config):
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
item = {
@@ -294,6 +307,7 @@ def test_create_known_category(self, sample_yaml_config):
def test_create_unknown_category_returns_none(self, sample_yaml_config):
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
item = {"category": "nonexistent_type", "paragraph": "x", "fingerprint": "fp_x"}
@@ -302,13 +316,13 @@ def test_create_unknown_category_returns_none(self, sample_yaml_config):
def test_create_missing_category_raises(self, sample_yaml_config):
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
with pytest.raises(ValueError, match="missing 'category'"):
create_node({"paragraph": "x"}, level=1, config=config)
-
class TestTreeBuilderIntegration:
"""tree_builder 构建树 + 已知 bug"""
@@ -327,16 +341,20 @@ def test_body_text_not_treated_as_heading(self):
assert builder._is_heading_category("body_text") is False
-
class TestDocumentBuilderIntegration:
"""DocumentBuilder 加载 JSON 并构建树"""
def test_build_from_json_list(self, sample_yaml_config):
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
data = [
- {"category": "heading_level_1", "paragraph": "第一章", "fingerprint": "fp1"},
+ {
+ "category": "heading_level_1",
+ "paragraph": "第一章",
+ "fingerprint": "fp1",
+ },
{"category": "body_text", "paragraph": "正文", "fingerprint": "fp2"},
]
root = DocumentBuilder.build_from_json(data, config=config)
@@ -350,7 +368,6 @@ def test_config_not_global_state(self):
assert hasattr(builder, "_config")
-
# ==================== (f) set_tag 集成测试 ====================
@@ -361,7 +378,12 @@ class TestSetTagMainIntegration:
def test_set_tag_main_calls_parse(self, mock_docx_cls):
mock_instance = mock_docx_cls.return_value
mock_instance.parse.return_value = [
- {"category": "body_text", "score": 0.9, "paragraph": "test", "fingerprint": "fp1"}
+ {
+ "category": "body_text",
+ "score": 0.9,
+ "paragraph": "test",
+ "fingerprint": "fp1",
+ }
]
result = set_tag_main("dummy.docx", "dummy.yaml")
assert len(result) == 1
@@ -371,8 +393,9 @@ def test_set_tag_main_calls_parse(self, mock_docx_cls):
def test_set_tag_main_passes_args(self, mock_docx_cls):
mock_docx_cls.return_value.parse.return_value = []
set_tag_main("path/to/doc.docx", "path/to/cfg.yaml")
- mock_docx_cls.assert_called_once_with("path/to/doc.docx", configpath="path/to/cfg.yaml")
-
+ mock_docx_cls.assert_called_once_with(
+ "path/to/doc.docx", configpath="path/to/cfg.yaml"
+ )
# ==================== (g) set_style 集成测试 ====================
@@ -390,7 +413,9 @@ class SpyNode(FormatNode):
CONFIG_MODEL = type("C", (), {})()
def __init__(self, **kw):
- super().__init__(value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw)
+ super().__init__(
+ value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw
+ )
doc = Document()
self.paragraph = doc.add_paragraph("text")
@@ -420,7 +445,9 @@ class SpyNode(FormatNode):
CONFIG_MODEL = type("C", (), {})()
def __init__(self, **kw):
- super().__init__(value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw)
+ super().__init__(
+ value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw
+ )
doc = Document()
self.paragraph = doc.add_paragraph("text")
@@ -457,17 +484,18 @@ def test_flatten_tree_nodes_matches_doc_order(self):
assert nodes == [a, b, c, d]
-
# ==================== (i) set_style.py auto_format_thesis_document 覆盖测试 ====================
class TestAutoFormatThesisDocument:
"""覆盖 set_style.py lines 53-55, 120-176: auto_format_thesis_document 主流程"""
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_check_mode_returns_annotated_path(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""check=True 模式:返回 --标注版.docx 路径 (lines 170-176)"""
root_node = mock.MagicMock()
@@ -476,16 +504,22 @@ def test_check_mode_returns_annotated_path(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
result = auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
assert "--标注版.docx" in result
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_apply_mode_returns_modified_path(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""check=False 模式:返回 --修改版.docx 路径 (line 173)"""
root_node = mock.MagicMock()
@@ -494,16 +528,22 @@ def test_apply_mode_returns_modified_path(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
result = auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=False,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=False,
)
assert "--修改版.docx" in result
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_filters_body_text_nodes(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""body_text 节点不再被过滤,保留在 children 中"""
body_node = mock.MagicMock()
@@ -517,18 +557,24 @@ def test_filters_body_text_nodes(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
# body_text 不再被过滤,所有节点都保留
assert len(root_node.children) == 2
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
@mock.patch("wordformat.pipeline.stages.promote_bodytext_in_subtrees_of_type")
def test_promote_called_for_subtrees(
- self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""promote_bodytext_in_subtrees_of_type 应被调用 (lines 153-161)"""
root_node = mock.MagicMock()
@@ -537,16 +583,24 @@ def test_promote_called_for_subtrees(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
- assert mock_promote.call_count == 3 # AbstractTitleCN, AbstractTitleEN, References
+ assert (
+ mock_promote.call_count == 3
+ ) # AbstractTitleCN, AbstractTitleEN, References
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_exception_in_traverse_raises(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""节点处理异常时 raise e (lines 53-55)"""
root_node = mock.MagicMock()
@@ -555,16 +609,22 @@ def test_exception_in_traverse_raises(
mock_apply.side_effect = RuntimeError("test error")
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
with pytest.raises(RuntimeError, match="test error"):
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_config_load_failure_raises(
- self, mock_builder, mock_apply, temp_docx, tmp_path
+ self, mock_builder, mock_apply, temp_docx, tmp_path
):
"""配置加载失败时 raise (lines 126-128)"""
bad_config = str(tmp_path / "nonexistent.yaml")
@@ -573,16 +633,22 @@ def test_config_load_failure_raises(
mock_builder.build_from_json.return_value = root_node
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
with pytest.raises(Exception):
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=bad_config, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=bad_config,
+ savepath=str(tmp_path),
+ check=True,
)
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_apply_mode_lists_styles(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""check=False 时列出可用样式 (lines 139-143)"""
root_node = mock.MagicMock()
@@ -591,14 +657,17 @@ def test_apply_mode_lists_styles(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=False,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=False,
)
# Should not raise - the styles listing is just logging
-
# ==================== (j) word_structure/utils.py promote_bodytext 覆盖测试 ====================
@@ -615,8 +684,17 @@ class ParentType(FormatNode):
class TargetType(FormatNode):
pass
- parent = ParentType(value={"category": "parent", "fingerprint": "fp_parent"}, level=1)
- child = BodyText(value={"category": "body_text", "paragraph": "test", "fingerprint": "fp_child"}, level=2)
+ parent = ParentType(
+ value={"category": "parent", "fingerprint": "fp_parent"}, level=1
+ )
+ child = BodyText(
+ value={
+ "category": "body_text",
+ "paragraph": "test",
+ "fingerprint": "fp_child",
+ },
+ level=2,
+ )
parent.add_child_node(child)
promote_bodytext_in_subtrees_of_type(parent, ParentType, TargetType)
@@ -638,9 +716,18 @@ class TargetType(FormatNode):
class MidNode(FormatNode):
pass
- parent = ParentType(value={"category": "parent", "fingerprint": "fp_parent"}, level=1)
+ parent = ParentType(
+ value={"category": "parent", "fingerprint": "fp_parent"}, level=1
+ )
mid = MidNode(value={"category": "mid", "fingerprint": "fp_mid"}, level=2)
- child = BodyText(value={"category": "body_text", "paragraph": "deep", "fingerprint": "fp_child"}, level=3)
+ child = BodyText(
+ value={
+ "category": "body_text",
+ "paragraph": "deep",
+ "fingerprint": "fp_child",
+ },
+ level=3,
+ )
mid.add_child_node(child)
parent.add_child_node(mid)
@@ -663,10 +750,24 @@ class OtherType(FormatNode):
pass
root = FormatNode(value={"category": "top", "fingerprint": "fp_root"}, level=0)
- parent = ParentType(value={"category": "parent", "fingerprint": "fp_parent"}, level=1)
- other = OtherType(value={"category": "other", "fingerprint": "fp_other"}, level=1)
- child_in_parent = BodyText(value={"category": "body_text", "paragraph": "in", "fingerprint": "fp_in"}, level=2)
- child_in_other = BodyText(value={"category": "body_text", "paragraph": "out", "fingerprint": "fp_out"}, level=2)
+ parent = ParentType(
+ value={"category": "parent", "fingerprint": "fp_parent"}, level=1
+ )
+ other = OtherType(
+ value={"category": "other", "fingerprint": "fp_other"}, level=1
+ )
+ child_in_parent = BodyText(
+ value={"category": "body_text", "paragraph": "in", "fingerprint": "fp_in"},
+ level=2,
+ )
+ child_in_other = BodyText(
+ value={
+ "category": "body_text",
+ "paragraph": "out",
+ "fingerprint": "fp_out",
+ },
+ level=2,
+ )
parent.add_child_node(child_in_parent)
other.add_child_node(child_in_other)
root.add_child_node(parent)
@@ -678,7 +779,6 @@ class OtherType(FormatNode):
assert isinstance(other.children[0], BodyText) # unchanged
-
# ==================== (k) log_config.py 覆盖测试 ====================
@@ -688,7 +788,8 @@ class TestLogConfig:
def test_setup_logger_frozen_path(self):
"""sys.frozen=True 时使用 sys.executable.parent (line 14)"""
import sys
- original_frozen = getattr(sys, 'frozen', None)
+
+ original_frozen = getattr(sys, "frozen", None)
sys.frozen = True
try:
with mock.patch("sys.executable", "/fake/app"):
@@ -696,12 +797,13 @@ def test_setup_logger_frozen_path(self):
mock_logger.remove.return_value = None
mock_logger.add.return_value = None
from wordformat.log_config import setup_logger
+
setup_logger()
# Verify logger.remove was called (setup_logger ran)
mock_logger.remove.assert_called_once()
finally:
if original_frozen is None:
- delattr(sys, 'frozen')
+ delattr(sys, "frozen")
else:
sys.frozen = original_frozen
@@ -710,11 +812,15 @@ def test_setup_uvicorn_loguru(self):
# Both uvicorn and logging are imported inside the function
mock_logging_mod = mock.MagicMock()
mock_uvicorn_mod = mock.MagicMock()
- with mock.patch.dict("sys.modules", {"uvicorn": mock_uvicorn_mod, "logging": mock_logging_mod}):
+ with mock.patch.dict(
+ "sys.modules", {"uvicorn": mock_uvicorn_mod, "logging": mock_logging_mod}
+ ):
import importlib
import wordformat.log_config as log_mod
+
importlib.reload(log_mod)
from wordformat.log_config import setup_uvicorn_loguru
+
mock_uvicorn_logger = mock.MagicMock()
mock_logging_mod.getLogger.return_value = mock_uvicorn_logger
setup_uvicorn_loguru()
@@ -725,7 +831,6 @@ def test_setup_uvicorn_loguru(self):
assert mock_uvicorn_mod.config.LOGGING_CONFIG["version"] == 1
-
# ==================== (l) onnx_infer.py 额外覆盖测试 ====================
@@ -734,12 +839,15 @@ class TestONNXInferExceptionHandling:
def test_get_best_onnx_providers_exception_fallback(self):
"""get_available_providers raises -> fallback to CPU (lines 48-50)"""
- with mock.patch("onnxruntime.get_available_providers", side_effect=Exception("no runtime")):
+ with mock.patch(
+ "onnxruntime.get_available_providers", side_effect=Exception("no runtime")
+ ):
assert _get_best_onnx_providers() == ["CPUExecutionProvider"]
def test_load_model_early_return_when_tokenizer_set(self):
"""_tokenizer is not None -> early return (line 57)"""
import wordformat.agent.onnx_infer as m
+
original = m._tokenizer
try:
m._tokenizer = mock.MagicMock()
@@ -753,7 +861,12 @@ def test_load_model_early_return_when_tokenizer_set(self):
def test_load_model_fallback_to_cpu(self):
"""Best provider fails -> fallback to CPU (lines 88-90)"""
import wordformat.agent.onnx_infer as m
- original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label
+
+ original_tok, original_sess, original_id2 = (
+ m._tokenizer,
+ m._ort_sess,
+ m._id2label,
+ )
try:
m._tokenizer = None
m._ort_sess = None
@@ -763,13 +876,25 @@ def test_load_model_fallback_to_cpu(self):
"tokenizer": "/fake/tokenizer.json",
"id2label": "/fake/id2label.json",
}
- with mock.patch("wordformat.agent.onnx_infer._get_model_paths", return_value=mock_paths), \
- mock.patch("wordformat.agent.onnx_infer._get_best_onnx_providers",
- return_value=["CUDAExecutionProvider"]), \
- mock.patch("tokenizers.Tokenizer") as mock_tok_cls, \
- mock.patch("onnxruntime.InferenceSession") as mock_sess_cls, \
- mock.patch("builtins.open", mock.mock_open(read_data='{"0":"body_text"}')):
- mock_sess_cls.side_effect = [RuntimeError("CUDA fail"), mock.MagicMock()]
+ with (
+ mock.patch(
+ "wordformat.agent.onnx_infer._get_model_paths",
+ return_value=mock_paths,
+ ),
+ mock.patch(
+ "wordformat.agent.onnx_infer._get_best_onnx_providers",
+ return_value=["CUDAExecutionProvider"],
+ ),
+ mock.patch("tokenizers.Tokenizer") as mock_tok_cls,
+ mock.patch("onnxruntime.InferenceSession") as mock_sess_cls,
+ mock.patch(
+ "builtins.open", mock.mock_open(read_data='{"0":"body_text"}')
+ ),
+ ):
+ mock_sess_cls.side_effect = [
+ RuntimeError("CUDA fail"),
+ mock.MagicMock(),
+ ]
_load_model()
# Should have been called twice: first with CUDA (fail), then with CPU (success)
assert mock_sess_cls.call_count == 2
@@ -784,7 +909,12 @@ def test_load_model_fallback_to_cpu(self):
def test_load_model_cpu_core_num_zero_fallback(self):
"""os.cpu_count() returns 0 -> fallback to 4 (line 100)"""
import wordformat.agent.onnx_infer as m
- original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label
+
+ original_tok, original_sess, original_id2 = (
+ m._tokenizer,
+ m._ort_sess,
+ m._id2label,
+ )
try:
m._tokenizer = None
m._ort_sess = None
@@ -794,12 +924,19 @@ def test_load_model_cpu_core_num_zero_fallback(self):
"tokenizer": "/fake/tokenizer.json",
"id2label": "/fake/id2label.json",
}
- with mock.patch("wordformat.agent.onnx_infer._get_model_paths", return_value=mock_paths), \
- mock.patch("os.cpu_count", return_value=0), \
- mock.patch("tokenizers.Tokenizer") as mock_tok_cls, \
- mock.patch("onnxruntime.InferenceSession") as mock_sess_cls, \
- mock.patch("onnxruntime.SessionOptions") as mock_opts_cls, \
- mock.patch("builtins.open", mock.mock_open(read_data='{"0":"body_text"}')):
+ with (
+ mock.patch(
+ "wordformat.agent.onnx_infer._get_model_paths",
+ return_value=mock_paths,
+ ),
+ mock.patch("os.cpu_count", return_value=0),
+ mock.patch("tokenizers.Tokenizer") as mock_tok_cls,
+ mock.patch("onnxruntime.InferenceSession") as mock_sess_cls,
+ mock.patch("onnxruntime.SessionOptions") as mock_opts_cls,
+ mock.patch(
+ "builtins.open", mock.mock_open(read_data='{"0":"body_text"}')
+ ),
+ ):
mock_sess = mock.MagicMock()
mock_sess_cls.return_value = mock_sess
_load_model()
@@ -814,7 +951,12 @@ def test_load_model_cpu_core_num_zero_fallback(self):
def test_single_infer_truncation(self):
"""Input longer than MAX_LENGTH gets truncated (lines 115-117)"""
import wordformat.agent.onnx_infer as m
- original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label
+
+ original_tok, original_sess, original_id2 = (
+ m._tokenizer,
+ m._ort_sess,
+ m._id2label,
+ )
try:
m._tokenizer = mock.MagicMock()
m._ort_sess = mock.MagicMock()
@@ -843,6 +985,7 @@ def test_batch_infer_empty_texts(self):
def test_batch_infer_loads_model_when_tokenizer_none(self):
"""_tokenizer is None triggers _load_model (line 159)"""
import wordformat.agent.onnx_infer as m
+
original_tok = m._tokenizer
try:
m._tokenizer = None
@@ -857,7 +1000,12 @@ def test_batch_infer_loads_model_when_tokenizer_none(self):
def test_batch_infer_success_with_timing(self):
"""Batch inference success path with timing log (lines 198-199)"""
import wordformat.agent.onnx_infer as m
- original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label
+
+ original_tok, original_sess, original_id2 = (
+ m._tokenizer,
+ m._ort_sess,
+ m._id2label,
+ )
try:
m._tokenizer = mock.MagicMock()
m._ort_sess = mock.MagicMock()
@@ -882,7 +1030,12 @@ def test_batch_infer_success_with_timing(self):
def test_batch_infer_result_assembly(self):
"""Result assembly with pred_id and score (lines 212-233)"""
import wordformat.agent.onnx_infer as m
- original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label
+
+ original_tok, original_sess, original_id2 = (
+ m._tokenizer,
+ m._ort_sess,
+ m._id2label,
+ )
try:
m._tokenizer = mock.MagicMock()
m._ort_sess = mock.MagicMock()
@@ -893,11 +1046,15 @@ def test_batch_infer_result_assembly(self):
enc.type_ids = [0, 0, 0]
m._tokenizer.encode.return_value = enc
# 3 texts, 3 classes
- m._ort_sess.run.return_value = [np.array([
- [0.1, 0.7, 0.2],
- [0.8, 0.1, 0.1],
- [0.3, 0.3, 0.4],
- ])]
+ m._ort_sess.run.return_value = [
+ np.array(
+ [
+ [0.1, 0.7, 0.2],
+ [0.8, 0.1, 0.1],
+ [0.3, 0.3, 0.4],
+ ]
+ )
+ ]
result = onnx_batch_infer(["t1", "t2", "t3"])
assert len(result) == 3
assert result[0]["label"] == "heading"
@@ -918,7 +1075,6 @@ def test_safe_batch_infer_empty_texts(self):
result = safe_batch_infer([])
-
# ==================== (m) keywords.py 覆盖测试 ====================
@@ -945,13 +1101,13 @@ class TestNode(BaseKeywordsNode):
assert node.pydantic_config is not None
-
class TestKeywordsENCheckKeywordLabel:
"""覆盖 keywords.py line 83: KeywordsEN._check_keyword_label matching"""
def test_check_keyword_label_matches(self):
"""_check_keyword_label returns True for 'Keywords' (line 83)"""
from wordformat.rules.keywords import KeywordsEN
+
node = KeywordsEN(
value={"category": "abstract.keywords.english", "fingerprint": "fp"},
level=1,
@@ -963,6 +1119,7 @@ def test_check_keyword_label_matches(self):
def test_check_keyword_label_key_words(self):
"""_check_keyword_label matches 'KEY WORDS'"""
from wordformat.rules.keywords import KeywordsEN
+
node = KeywordsEN(
value={"category": "abstract.keywords.english", "fingerprint": "fp"},
level=1,
@@ -974,6 +1131,7 @@ def test_check_keyword_label_key_words(self):
def test_check_keyword_label_no_match(self):
"""_check_keyword_label returns False for content text"""
from wordformat.rules.keywords import KeywordsEN
+
node = KeywordsEN(
value={"category": "abstract.keywords.english", "fingerprint": "fp"},
level=1,
@@ -983,13 +1141,13 @@ def test_check_keyword_label_no_match(self):
assert node._check_keyword_label(mock_run) is False
-
class TestKeywordsENBase:
"""覆盖 keywords.py lines 114, 121, 130-135, 152-153"""
def _make_en_node(self, config_dict=None):
"""Helper to create a KeywordsEN node with config loaded"""
from wordformat.rules.keywords import KeywordsEN
+
node = KeywordsEN(
value={"category": "abstract.keywords.english", "fingerprint": "fp"},
level=1,
@@ -1001,6 +1159,7 @@ def _make_en_node(self, config_dict=None):
def test_empty_run_skip(self, sample_yaml_config):
"""Empty run text is skipped (line 114)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1016,6 +1175,7 @@ def test_empty_run_skip(self, sample_yaml_config):
def test_label_style_check(self, sample_yaml_config):
"""Label run style is checked (line 121)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1031,6 +1191,7 @@ def test_label_style_check(self, sample_yaml_config):
def test_content_style_check(self, sample_yaml_config):
"""Content run style is checked (lines 130-135)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1047,6 +1208,7 @@ def test_content_style_check(self, sample_yaml_config):
def test_keyword_count_validation_min(self, sample_yaml_config):
"""Keyword count < count_min triggers warning (via _run_rules)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1063,6 +1225,7 @@ def test_keyword_count_validation_min(self, sample_yaml_config):
def test_keyword_count_validation_max(self, sample_yaml_config):
"""Keyword count > count_max triggers warning (via _run_rules)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1077,13 +1240,13 @@ def test_keyword_count_validation_max(self, sample_yaml_config):
# count_max is 5, 6 keywords -> should trigger count warning
-
class TestKeywordsCNBase:
"""覆盖 keywords.py lines 177-180, 187, 218, 225, 234-239"""
def _make_cn_node(self, config_dict=None):
"""Helper to create a KeywordsCN node with config loaded"""
from wordformat.rules.keywords import KeywordsCN
+
node = KeywordsCN(
value={"category": "abstract.keywords.chinese", "fingerprint": "fp"},
level=1,
@@ -1095,13 +1258,16 @@ def _make_cn_node(self, config_dict=None):
def test_paragraph_style_check(self, sample_yaml_config):
"""Paragraph style is checked (line 187)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
node = self._make_cn_node(config)
doc = Document()
p = doc.add_paragraph()
- p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER # Wrong - should be left
+ p.paragraph_format.alignment = (
+ WD_ALIGN_PARAGRAPH.CENTER
+ ) # Wrong - should be left
run = p.add_run("关键词:人工智能")
node.paragraph = p
node.check_format(doc)
@@ -1109,6 +1275,7 @@ def test_paragraph_style_check(self, sample_yaml_config):
def test_label_style_check(self, sample_yaml_config):
"""Label run style is checked (line 218)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1123,6 +1290,7 @@ def test_label_style_check(self, sample_yaml_config):
def test_content_style_check(self, sample_yaml_config):
"""Content run style is checked (line 225)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1139,6 +1307,7 @@ def test_content_style_check(self, sample_yaml_config):
def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config):
"""Keyword count validation + trailing punctuation check (via _run_rules)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
@@ -1152,7 +1321,6 @@ def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config):
# Text ends with ; which should trigger trailing punctuation warning
-
# ==================== (n) heading.py 覆盖测试 ====================
@@ -1162,6 +1330,7 @@ class TestHeadingLevelNodes:
def test_heading_level1_load_config_dict(self):
"""HeadingLevel1Node.load_config with dict (lines 36-41)"""
from wordformat.rules.heading import HeadingLevel1Node
+
node = HeadingLevel1Node(
value={"category": "headings.level_1", "fingerprint": "fp"},
level=1,
@@ -1181,6 +1350,7 @@ def test_heading_level1_load_config_dict(self):
def test_heading_level2_load_config_dict(self):
"""HeadingLevel2Node.load_config with dict (lines 52-58)"""
from wordformat.rules.heading import HeadingLevel2Node
+
node = HeadingLevel2Node(
value={"category": "headings.level_2", "fingerprint": "fp"},
level=2,
@@ -1199,6 +1369,7 @@ def test_heading_level2_load_config_dict(self):
def test_heading_level3_load_config_dict(self):
"""HeadingLevel3Node.load_config with dict"""
from wordformat.rules.heading import HeadingLevel3Node
+
node = HeadingLevel3Node(
value={"category": "headings.level_3", "fingerprint": "fp"},
level=3,
@@ -1218,6 +1389,7 @@ def test_heading_base_with_config(self, sample_yaml_config):
"""_base method with loaded config"""
from wordformat.config.loader import init_config, get_config
from wordformat.rules.heading import HeadingLevel1Node
+
init_config(sample_yaml_config)
config = get_config()
@@ -1233,17 +1405,18 @@ def test_heading_base_with_config(self, sample_yaml_config):
node.check_format(doc) # 通过 RULES handler 执行格式检查
-
# ==================== (o) set_style.py 额外覆盖测试 ====================
class TestSetStyleAdditionalCoverage:
"""覆盖 set_style.py lines 53-55, 147, 150, 167-168"""
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_exception_in_traverse_logs_and_raises(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""Node exception: logs warning then raises (lines 53-55)"""
root_node = mock.MagicMock()
@@ -1252,16 +1425,22 @@ def test_exception_in_traverse_logs_and_raises(
mock_apply.side_effect = RuntimeError("traverse error")
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
with pytest.raises(RuntimeError, match="traverse error"):
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
def test_body_text_filtering(
- self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""body_text nodes are no longer filtered out"""
body_node = mock.MagicMock()
@@ -1274,18 +1453,24 @@ def test_body_text_filtering(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
# body_text 不再被过滤
assert len(root_node.children) == 2
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
@mock.patch("wordformat.pipeline.stages.promote_bodytext_in_subtrees_of_type")
def test_promote_called(
- self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""promote_bodytext_in_subtrees_of_type is called (line 150)"""
root_node = mock.MagicMock()
@@ -1294,17 +1479,23 @@ def test_promote_called(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=True,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=True,
)
assert mock_promote.call_count == 3
- @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes")
+ @mock.patch(
+ "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes"
+ )
@mock.patch("wordformat.pipeline.stages.DocumentBuilder")
@mock.patch("wordformat.numbering.process_heading_numbering")
def test_numbering_processing(
- self, mock_numbering, mock_builder, mock_apply, temp_docx, config_path, tmp_path
+ self, mock_numbering, mock_builder, mock_apply, temp_docx, config_path, tmp_path
):
"""Numbering processing when enabled (lines 167-168)"""
root_node = mock.MagicMock()
@@ -1313,19 +1504,22 @@ def test_numbering_processing(
mock_apply.return_value = None
from wordformat.pipeline.orchestrate import auto_format_thesis_document
+
# Mock config with numbering.enabled = True
with mock.patch("wordformat.pipeline.stages.load_config") as mock_get_cfg:
mock_cfg = mock.MagicMock()
mock_cfg.numbering.enabled = True
mock_get_cfg.return_value = mock_cfg
auto_format_thesis_document(
- jsonpath=temp_docx, docxpath=temp_docx,
- configpath=config_path, savepath=str(tmp_path), check=False,
+ jsonpath=temp_docx,
+ docxpath=temp_docx,
+ configpath=config_path,
+ savepath=str(tmp_path),
+ check=False,
)
mock_numbering.assert_called_once()
-
# ==================== (p) rules/body.py 额外覆盖测试 ====================
@@ -1336,6 +1530,7 @@ def test_body_text_apply_format(self, sample_yaml_config):
"""BodyText._base with p=False, r=False (line 27)"""
from wordformat.config.loader import init_config, get_config
from wordformat.rules.body import BodyText
+
init_config(sample_yaml_config)
config = get_config()
@@ -1355,6 +1550,7 @@ def test_body_text_apply_to_run(self, sample_yaml_config):
"""apply_format 通过 handler 修正字符格式。"""
from wordformat.config.loader import init_config, get_config
from wordformat.rules.body import BodyText
+
init_config(sample_yaml_config)
config = get_config()
@@ -1372,7 +1568,6 @@ def test_body_text_apply_to_run(self, sample_yaml_config):
assert run.font.bold is False # Should have been fixed
-
# ==================== (q) rules/node.py 额外覆盖测试 ====================
@@ -1382,11 +1577,13 @@ class TestFormatNodeAdditional:
def test_load_config_key_error_path(self):
"""load_config with non-dict intermediate path raises KeyError (line 52)"""
from wordformat.rules.node import TreeNode
+
node = TreeNode(value={"category": "a.b.c", "fingerprint": "fp"})
config = {"a": "not_a_dict"}
node.load_config(config)
# Should not crash, returns empty config
+
# ==================== (r) tree.py 额外覆盖测试 ====================
@@ -1396,6 +1593,7 @@ class TestTreeAdditional:
def test_level_order_empty_root(self):
"""level_order with root that has None value (line 47)"""
from wordformat.tree import Tree
+
tree = Tree(None)
result = list(tree.level_order())
assert result == [None]
@@ -1405,13 +1603,14 @@ def test_print_tree_with_paragraph_value(self, capsys):
from wordformat.tree import print_tree
from wordformat.rules.node import TreeNode
- node = TreeNode(value={"category": "test", "paragraph": "hello", "fingerprint": "fp"})
+ node = TreeNode(
+ value={"category": "test", "paragraph": "hello", "fingerprint": "fp"}
+ )
print_tree(node)
captured = capsys.readouterr()
assert "test" in captured.out
-
# ==================== (s) settings.py 额外覆盖测试 ====================
@@ -1422,25 +1621,27 @@ def test_frozen_path(self):
"""sys.frozen=True sets BASE_DIR to sys.executable.parent (line 15)"""
import sys
from pathlib import Path
- original_frozen = getattr(sys, 'frozen', None)
+
+ original_frozen = getattr(sys, "frozen", None)
sys.frozen = True
try:
with mock.patch("sys.executable", "/fake/app/bin"):
# Reload settings module to pick up frozen state
import importlib
import wordformat.settings as settings_mod
+
importlib.reload(settings_mod)
assert settings_mod.BASE_DIR == Path("/fake/app")
finally:
if original_frozen is None:
- delattr(sys, 'frozen')
+ delattr(sys, "frozen")
else:
sys.frozen = original_frozen
# Reload back to normal
import importlib
import wordformat.settings as settings_mod
- importlib.reload(settings_mod)
+ importlib.reload(settings_mod)
# ==================== (t) config/config.py 额外覆盖测试 ====================
@@ -1465,7 +1666,6 @@ def test_load_then_get_returns_config(self, config_path):
assert cfg is get_config()
-
# ==================== 补充覆盖率:小缺口 ====================
@@ -1474,6 +1674,7 @@ class TestDocumentBuilderLoadFromFile:
def test_load_paragraphs_from_file(self, tmp_path):
import json
+
data = [{"category": "body_text", "paragraph": "test", "fingerprint": "fp1"}]
json_file = tmp_path / "data.json"
json_file.write_text(json.dumps(data), encoding="utf-8")
@@ -1484,6 +1685,7 @@ def test_load_paragraphs_from_file(self, tmp_path):
def test_load_paragraphs_invalid_json_falls_back_to_file(self, tmp_path):
"""无效 JSON 字符串回退到文件加载"""
import json
+
data = [{"category": "body_text", "paragraph": "test", "fingerprint": "fp1"}]
json_file = tmp_path / "data.json"
json_file.write_text(json.dumps(data), encoding="utf-8")
@@ -1492,13 +1694,13 @@ def test_load_paragraphs_invalid_json_falls_back_to_file(self, tmp_path):
assert len(result) == 1
-
class TestTreeBuilderAdditionalCoverage:
"""覆盖 tree_builder.py 剩余行"""
def test_build_tree_with_unknown_category_skipped(self, sample_yaml_config):
"""未知类别的节点被跳过(create_node 返回 None)"""
from wordformat.config.loader import init_config, get_config
+
init_config(sample_yaml_config)
config = get_config()
builder = DocumentTreeBuilder()
@@ -1523,13 +1725,13 @@ def test_build_tree_with_body_text_category(self):
assert len(root.children) == 1
-
class TestNumberingAdditionalCoverage:
"""覆盖 numbering.py 剩余行 (49, 262)"""
def test_auto_strip_numbering_empty_run_text(self, doc):
"""run 文本被完全清空后仍保留空 run"""
from wordformat.numbering import _auto_strip_numbering
+
p = doc.add_paragraph()
run = p.add_run("1.1 ")
_auto_strip_numbering(p, ilvl=1)
@@ -1544,4 +1746,3 @@ def test_process_heading_numbering_disabled(self, doc):
# 创建 disabled 的 numbering config
config = NumberingConfig(enabled=False)
process_heading_numbering(None, doc, config)
-
diff --git a/tests/test_numbering.py b/tests/test_numbering.py
index e98a8f7..532dcb4 100644
--- a/tests/test_numbering.py
+++ b/tests/test_numbering.py
@@ -3,6 +3,7 @@
覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py
"""
+
import os
import pytest
from io import StringIO
@@ -97,6 +98,7 @@ def test_adds_numPr_to_paragraph(self, doc):
p = doc.add_paragraph("test")
apply_auto_numbering(p, num_id="100", ilvl="0")
from docx.oxml.ns import qn
+
pPr = p._element.find(qn("w:pPr"))
assert pPr is not None
numPr = pPr.find(qn("w:numPr"))
@@ -111,6 +113,7 @@ def test_replaces_existing_numPr(self, doc):
apply_auto_numbering(p, num_id="50", ilvl="1")
apply_auto_numbering(p, num_id="99", ilvl="2")
from docx.oxml.ns import qn
+
pPr = p._element.find(qn("w:pPr"))
numPr = pPr.find(qn("w:numPr"))
assert numPr.find(qn("w:numId")).get(qn("w:val")) == "99"
@@ -211,6 +214,7 @@ def test_no_numPr_returns_empty(self, doc):
"""段落有 pPr 但没有 numPr 时返回空字符串"""
p = doc.add_paragraph("hello")
from docx.oxml import OxmlElement
+
pPr = OxmlElement("w:pPr")
p._element.insert(0, pPr)
assert get_paragraph_numbering_text(p) == ""
@@ -219,6 +223,7 @@ def test_no_numId_returns_empty(self, doc):
"""numPr 中没有 numId 时返回空字符串"""
p = doc.add_paragraph("hello")
from docx.oxml import OxmlElement
+
pPr = OxmlElement("w:pPr")
numPr = OxmlElement("w:numPr")
pPr.append(numPr)
@@ -229,6 +234,7 @@ def test_numId_zero_returns_empty(self, doc):
"""numId 为 0 时返回空字符串"""
p = doc.add_paragraph("hello")
from docx.oxml import OxmlElement
+
pPr = OxmlElement("w:pPr")
numPr = OxmlElement("w:numPr")
numId_elem = OxmlElement("w:numId")
@@ -242,6 +248,7 @@ def test_no_numbering_part_returns_empty(self, doc):
"""文档没有 numbering part 时返回空字符串(已修复:捕获 NotImplementedError)"""
p = doc.add_paragraph("hello")
from docx.oxml import OxmlElement
+
pPr = OxmlElement("w:pPr")
numPr = OxmlElement("w:numPr")
numId_elem = OxmlElement("w:numId")
@@ -254,6 +261,7 @@ def test_no_numbering_part_returns_empty(self, doc):
p._element.insert(0, pPr)
# 移除 numbering 关系使 numbering_part 访问抛出异常
from docx.opc.constants import RELATIONSHIP_TYPE as RT
+
rels = doc.part.rels
to_remove = [k for k, v in rels.items() if v.reltype == RT.NUMBERING]
for k in to_remove:
@@ -261,7 +269,9 @@ def test_no_numbering_part_returns_empty(self, doc):
# 修复后捕获 NotImplementedError 并返回空字符串
assert get_paragraph_numbering_text(p) == ""
- def _setup_numbering(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0"):
+ def _setup_numbering(
+ self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0"
+ ):
"""辅助方法:为文档创建 numbering part 和编号定义"""
from docx.oxml import OxmlElement
from docx.opc.constants import RELATIONSHIP_TYPE as RT
@@ -316,6 +326,7 @@ def _setup_numbering(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", a
def _add_numPr_to_paragraph(self, p, num_id="1", ilvl="0"):
"""辅助方法:为段落添加 numPr"""
from docx.oxml import OxmlElement
+
pPr = OxmlElement("w:pPr")
numPr = OxmlElement("w:numPr")
numId_elem = OxmlElement("w:numId")
@@ -515,31 +526,34 @@ class TestGetLevelFmt:
def test_existing_level_returns_fmt(self):
from lxml import etree
+
abstract_num = etree.fromstring(
''
' '
' '
- ' '
- ''
+ " "
+ ""
)
assert _get_level_fmt(abstract_num, 0) == "upperRoman"
def test_missing_level_returns_decimal(self):
from lxml import etree
+
abstract_num = etree.fromstring(
''
- ''
+ ""
)
assert _get_level_fmt(abstract_num, 0) == "decimal"
def test_no_numFmt_returns_decimal(self):
from lxml import etree
+
abstract_num = etree.fromstring(
''
' '
' '
- ' '
- ''
+ " "
+ ""
)
assert _get_level_fmt(abstract_num, 0) == "decimal"
@@ -621,7 +635,6 @@ def test_style_not_in_doc_no_error(self, doc):
remove_all_numbering(doc)
-
# ============================================================
# numbering.py — auto_strip_numbering (empty result)
# ============================================================
@@ -768,6 +781,7 @@ def test_no_template_skips_level(self, doc):
def test_creates_numbering_part_when_missing(self, tmp_path):
"""文档没有 numbering part 时自动创建"""
from docx.opc.constants import RELATIONSHIP_TYPE as RT
+
# 创建一个没有 numbering 关系的文档
doc = Document()
# 移除已有的 numbering 关系(如果有)
@@ -1160,26 +1174,32 @@ def test_reference_entry_strips_manual_then_applies_numbering(self, doc):
class TestParseRefNumbers:
def test_single_number(self):
from wordformat.hyperlinks import _parse_ref_numbers
+
assert _parse_ref_numbers("[1]") == [1]
def test_multiple_numbers(self):
from wordformat.hyperlinks import _parse_ref_numbers
+
assert _parse_ref_numbers("[1,2,3]") == [1, 2, 3]
def test_range(self):
from wordformat.hyperlinks import _parse_ref_numbers
+
assert _parse_ref_numbers("[1-3]") == [1, 2, 3]
def test_mixed(self):
from wordformat.hyperlinks import _parse_ref_numbers
+
assert _parse_ref_numbers("[1,3-5]") == [1, 3, 4, 5]
def test_chinese_comma(self):
from wordformat.hyperlinks import _parse_ref_numbers
+
assert _parse_ref_numbers("[1,2,3]") == [1, 2, 3]
def test_spaces(self):
from wordformat.hyperlinks import _parse_ref_numbers
+
assert _parse_ref_numbers("[1, 2, 3]") == [1, 2, 3]
@@ -1191,6 +1211,7 @@ def test_spaces(self):
class TestInsertBookmark:
def test_adds_bookmark_to_paragraph(self, doc):
from wordformat.hyperlinks import _insert_bookmark, _next_bookmark_id
+
p = doc.add_paragraph("A reference entry.")
bid = _next_bookmark_id()
_insert_bookmark(p, "_Ref1", bid)
@@ -1204,6 +1225,7 @@ def test_adds_bookmark_to_paragraph(self, doc):
def test_bookmark_before_first_run(self, doc):
from wordformat.hyperlinks import _insert_bookmark, _next_bookmark_id
+
p = doc.add_paragraph("Text")
bid = _next_bookmark_id()
_insert_bookmark(p, "_RefTest", bid)
@@ -1231,6 +1253,7 @@ def test_bookmark_before_first_run(self, doc):
class TestWrapCitationsInHyperlinks:
def test_wraps_citation_in_hyperlink(self, doc):
from wordformat.hyperlinks import _wrap_citations_in_hyperlinks
+
p = doc.add_paragraph("参见")
p.add_run("[1]").font.superscript = True
p.add_run("的研究。")
@@ -1244,6 +1267,7 @@ def test_wraps_citation_in_hyperlink(self, doc):
def test_regular_brackets_not_wrapped(self, doc):
from wordformat.hyperlinks import _wrap_citations_in_hyperlinks
+
p = doc.add_paragraph("这是一个[注]释说明。")
_wrap_citations_in_hyperlinks(p, ["_Ref1"])
@@ -1254,6 +1278,7 @@ def test_regular_brackets_not_wrapped(self, doc):
def test_run_retains_superscript_in_hyperlink(self, doc):
from wordformat.hyperlinks import _wrap_citations_in_hyperlinks
+
p = doc.add_paragraph("见")
r = p.add_run("[1]")
r.font.superscript = True
@@ -1272,7 +1297,6 @@ def test_run_retains_superscript_in_hyperlink(self, doc):
assert rStyle.get(qn("w:val")) == "Hyperlink"
-
# ============================================================
# utils.py — _format_number 额外覆盖测试
# ============================================================
@@ -1361,36 +1385,39 @@ class TestGetLevelFmtAdditional:
def test_level_missing_numFmt_returns_decimal(self):
"""lvl 存在但缺少 numFmt 元素时返回 decimal"""
from lxml import etree
+
abstract_num = etree.fromstring(
''
' '
' '
- ' '
- ''
+ " "
+ ""
)
assert _get_level_fmt(abstract_num, 0) == "decimal"
def test_level_missing_numFmt_val_returns_decimal(self):
"""numFmt 元素存在但缺少 w:val 属性时返回 decimal"""
from lxml import etree
+
abstract_num = etree.fromstring(
''
' '
- ' '
- ' '
- ''
+ " "
+ " "
+ ""
)
assert _get_level_fmt(abstract_num, 0) == "decimal"
def test_nonexistent_level_returns_decimal(self):
"""请求不存在的级别返回 decimal"""
from lxml import etree
+
abstract_num = etree.fromstring(
''
' '
' '
- ' '
- ''
+ " "
+ ""
)
assert _get_level_fmt(abstract_num, 5) == "decimal"
@@ -1403,7 +1430,9 @@ def test_nonexistent_level_returns_decimal(self):
class TestCountNumberingLevels:
"""测试 _count_numbering_levels 编号级别计数逻辑"""
- def _setup_numbering_doc(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0"):
+ def _setup_numbering_doc(
+ self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0"
+ ):
"""辅助方法:为文档创建 numbering part"""
from docx.oxml import OxmlElement
from docx.opc.constants import RELATIONSHIP_TYPE as RT
@@ -1460,6 +1489,7 @@ def _setup_numbering_doc(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1
def _add_numPr_to_paragraph(self, p, num_id="1", ilvl="0"):
"""辅助方法:为段落添加 numPr"""
from docx.oxml import OxmlElement
+
pPr = OxmlElement("w:pPr")
numPr = OxmlElement("w:numPr")
numId_elem = OxmlElement("w:numId")
@@ -1552,4 +1582,3 @@ def test_no_numbered_paragraphs_before_target(self, doc):
numbering_elm = numbering_part._element
result = _count_numbering_levels(numbering_elm, "0", p)
assert result == {0: 1}
-
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 186e3f8..f3ec7c5 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -3,6 +3,7 @@
覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py
"""
+
import os
import pytest
from io import StringIO
@@ -199,7 +200,6 @@ def test_nonexistent_file_raises(self):
# ============================================================
-
# ============================================================
# settings.py
# ============================================================
@@ -231,7 +231,6 @@ def test_server_host_format(self):
# ============================================================
-
# ============================================================
# utils.py — remove_all_numbering
# ============================================================
@@ -312,6 +311,3 @@ def test_style_not_in_doc_no_error(self, doc):
# ============================================================
# numbering.py — auto_strip_numbering (empty result)
# ============================================================
-
-
-
diff --git a/tests/utils/test_text.py b/tests/utils/test_text.py
index 428b922..3aa5921 100644
--- a/tests/utils/test_text.py
+++ b/tests/utils/test_text.py
@@ -1,7 +1,9 @@
"""utils/_text.py 测试 — 罗马数字、中文数字、题注解析。"""
+
import pytest
from wordformat.utils import _from_chinese_num, _from_roman, parse_caption_text
+
class TestFromChineseNum:
def test_single_digit(self):
assert _from_chinese_num("一") == 1
@@ -39,6 +41,7 @@ def test_invalid_raises(self):
# ======================== parse_caption_text ========================
+
class TestFromRoman:
def test_basic_singles(self):
assert _from_roman("I") == 1
@@ -69,6 +72,7 @@ def test_invalid_raises(self):
# ======================== _from_chinese_num ========================
+
class TestParseCaptionText:
def test_basic_figure_arabic(self):
result = parse_caption_text("图1.1 系统架构图")
From 169a1eee9ed17a177b8accfe6829cd56f6937c0c Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 18:09:08 +0800
Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20NODE=5FTYPE=20?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=B8=8D=E5=8C=B9=E9=85=8D=20+=20=E6=B8=85?=
=?UTF-8?q?=E7=90=86=E6=AD=BB=E9=85=8D=E7=BD=AE=20+=20=E7=A7=BB=E9=99=A4?=
=?UTF-8?q?=20wordformat-skill=20=E8=87=AA=E5=AE=9A=E4=B9=89=E8=84=9A?=
=?UTF-8?q?=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- FigureImage.NODE_TYPE: "figure_image" → "figures.image",与 YAML figures.image 对齐
- TableObject.NODE_TYPE: "table_object" → "tables.object",与 YAML tables.object 对齐
- 移除 tables.content 配置块(无对应 FormatNode 消费者)
- 移除 numbering.captions 配置块(与 figures/tables.rules.caption_numbering 重复)
- 移除 wordformat-skill/scripts/ 目录(自定义验证脚本)
---
example/undergrad_thesis.yaml | 17 -
src/wordformat/rules/object.py | 4 +-
wordformat-skill/scripts/setup_config.py | 234 -----------
wordformat-skill/scripts/validate_config.py | 431 --------------------
wordformat-skill/scripts/validate_json.py | 248 -----------
5 files changed, 2 insertions(+), 932 deletions(-)
delete mode 100644 wordformat-skill/scripts/setup_config.py
delete mode 100644 wordformat-skill/scripts/validate_config.py
delete mode 100644 wordformat-skill/scripts/validate_json.py
diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml
index b663f29..900f24c 100644
--- a/example/undergrad_thesis.yaml
+++ b/example/undergrad_thesis.yaml
@@ -227,17 +227,6 @@ tables:
<<: *global_format
alignment: '居中对齐'
first_line_indent: '0字符'
- # -- 表格内容格式(单元格内文字)--
- content:
- <<: *global_format
- chinese_font_name: '宋体'
- english_font_name: 'Times New Roman'
- font_size: '五号'
- line_spacingrule: '单倍行距'
- alignment: '居中对齐'
- first_line_indent: '0字符'
- space_before: "0行"
- space_after: "0行"
rules:
caption_numbering:
enabled: true
@@ -292,12 +281,6 @@ acknowledgements:
numbering:
enabled: true # 总开关:是否启用自动编号
- # -- 题注编号校验/修正 --
- captions:
- enabled: true # 是否启用题注编号校验/修正
- separator: '.' # 章节号与题注编号间的分隔符
- label_number_space: false # 标签与编号间加空格(图 1.1 vs 图1.1)
-
# -- 一级标题编号(如"第1章")--
level_1:
enabled: true
diff --git a/src/wordformat/rules/object.py b/src/wordformat/rules/object.py
index 6fbee27..5a2dd39 100644
--- a/src/wordformat/rules/object.py
+++ b/src/wordformat/rules/object.py
@@ -12,7 +12,7 @@ class FigureImage(FormatNode):
只检查对齐和首行缩进,不检查行距、字体等。
"""
- NODE_TYPE = "figure_image"
+ NODE_TYPE = "figures.image"
NODE_LABEL = "图片段落"
DEFAULTS = {"alignment": "居中对齐", "first_line_indent": "0字符"}
DEFAULT_RULES = {}
@@ -56,7 +56,7 @@ def _base(self, doc, p: bool, r: bool):
class TableObject(FormatNode):
"""表格对象节点(表格整体格式,非题注)。"""
- NODE_TYPE = "table_object"
+ NODE_TYPE = "tables.object"
NODE_LABEL = "表格对象"
DEFAULTS = {}
DEFAULT_RULES = {} # 表格对象格式由 Word 表格属性控制
diff --git a/wordformat-skill/scripts/setup_config.py b/wordformat-skill/scripts/setup_config.py
deleted file mode 100644
index c0c5fcc..0000000
--- a/wordformat-skill/scripts/setup_config.py
+++ /dev/null
@@ -1,234 +0,0 @@
-#!/usr/bin/env python3
-"""
-WordFormat 配置管理脚本
-
-用法:
- # 生成配置模板
- wordf config -o config.yaml
-
- # 查找已有预设
- python setup_config.py --list-presets
-
- # 从预设库复制配置
- python setup_config.py --use "清华大学_计算机学院_本科" --output config.yaml
-
- # 验证配置文件
- python setup_config.py --validate --config config.yaml
-
- # 保存配置到预设库
- python setup_config.py --save --config config.yaml --name "XX大学_XX学院_本科"
-"""
-
-import argparse
-import os
-import shutil
-import sys
-from pathlib import Path
-
-
-def _get_pip_mirror() -> list[str]:
- """根据用户地区自动选择 pip 镜像源"""
- import locale
-
- lang = locale.getdefaultlocale()[0] or ""
- # 中文环境优先使用清华镜像
- if lang.startswith("zh"):
- return [
- sys.executable,
- "-m",
- "pip",
- "install",
- "pyyaml",
- "--break-system-packages",
- "-q",
- "-i",
- "https://pypi.tuna.tsinghua.edu.cn/simple",
- ]
- return [
- sys.executable,
- "-m",
- "pip",
- "install",
- "pyyaml",
- "--break-system-packages",
- "-q",
- ]
-
-
-try:
- import yaml
-except ImportError:
- import subprocess
-
- subprocess.check_call(_get_pip_mirror())
- import yaml
-
-# 预设目录:当前工作目录下的 presets/
-PRESETS_DIR = Path("presets")
-
-# 获取脚本所在目录(用于定位模板和验证脚本)
-SCRIPT_DIR = Path(__file__).resolve().parent
-
-
-def get_presets_dir() -> Path:
- """确保预设目录存在"""
- PRESETS_DIR.mkdir(parents=True, exist_ok=True)
- return PRESETS_DIR
-
-
-def list_presets():
- """列出所有已保存的预设配置"""
- presets_dir = get_presets_dir()
- presets = sorted(presets_dir.glob("*.yaml"))
- if not presets:
- print("暂无已保存的预设配置。")
- print(f"预设目录: {presets_dir}")
- return
- print(f"已保存的预设配置(共 {len(presets)} 个):")
- print(f"目录: {presets_dir}")
- print("-" * 50)
- for p in presets:
- print(f" {p.stem}")
-
-
-def use_preset(name: str, output: str):
- """从预设库复制配置到工作目录"""
- presets_dir = get_presets_dir()
- # 支持不带 .yaml 后缀
- if not name.endswith(".yaml"):
- name = name + ".yaml"
- preset_path = presets_dir / name
-
- if not preset_path.exists():
- # 模糊匹配:如果用户输入的是部分名称,尝试查找
- matches = [
- p for p in presets_dir.glob("*.yaml") if name.replace(".yaml", "") in p.stem
- ]
- if len(matches) == 1:
- preset_path = matches[0]
- print(f"模糊匹配到: {preset_path.stem}")
- elif len(matches) > 1:
- print("找到多个匹配的预设:")
- for m in matches:
- print(f" - {m.stem}")
- print("请指定更精确的名称。")
- sys.exit(1)
- else:
- print(f"未找到预设配置: {name.replace('.yaml', '')}")
- print("可用预设:")
- list_presets()
- sys.exit(1)
-
- shutil.copy2(preset_path, output)
- print(f"已复制预设配置: {preset_path.stem} -> {output}")
-
-
-def save_preset(config_path: str, name: str):
- """保存配置到预设库"""
- presets_dir = get_presets_dir()
- if not name.endswith(".yaml"):
- name = name + ".yaml"
- dest = presets_dir / name
-
- if not os.path.exists(config_path):
- print(f"配置文件不存在: {config_path}")
- sys.exit(1)
-
- # 先验证
- errors = validate(config_path)
- if errors:
- print(f"配置文件有 {len(errors)} 个问题,请先修复后再保存。")
- sys.exit(1)
-
- shutil.copy2(config_path, dest)
- print(f"已保存预设配置: {dest}")
-
-
-def validate(config_path: str) -> list[str]:
- """验证配置文件,返回错误列表"""
- # 导入验证逻辑
- validate_script = SCRIPT_DIR / "validate_config.py"
- if validate_script.exists():
- # 动态导入验证函数
- import importlib.util
-
- spec = importlib.util.spec_from_file_location(
- "validate_config", validate_script
- )
- mod = importlib.util.module_from_spec(spec)
- spec.loader.exec_module(mod)
- try:
- config = yaml.safe_load(open(config_path, encoding="utf-8"))
- except yaml.YAMLError as e:
- return [f"YAML 语法错误: {e}"]
- if not isinstance(config, dict):
- return ["配置文件根节点必须是字典"]
- return mod.validate_config(config)
- else:
- # 简单验证:检查 YAML 语法
- try:
- config = yaml.safe_load(open(config_path, encoding="utf-8"))
- except yaml.YAMLError as e:
- return [f"YAML 语法错误: {e}"]
- return []
-
-
-def main():
- parser = argparse.ArgumentParser(
- description="WordFormat 配置准备工具",
- formatter_class=argparse.RawDescriptionHelpFormatter,
- epilog="""
-示例:
- wordf config -o config.yaml # 生成模板
- python setup_config.py --list-presets # 列预设
- python setup_config.py --use "清华大学_计算机学院_本科" --output config.yaml
- python setup_config.py --validate --config config.yaml # 验证
- python setup_config.py --save --config config.yaml --name "XX大学_XX学院_本科"
-""",
- )
-
- group = parser.add_mutually_exclusive_group(required=True)
- group.add_argument(
- "--list-presets", action="store_true", help="列出所有已保存的预设配置"
- )
- group.add_argument("--use", metavar="NAME", help="从预设库复制配置(支持模糊匹配)")
- group.add_argument("--validate", action="store_true", help="验证配置文件")
- group.add_argument("--save", action="store_true", help="保存配置到预设库")
-
- parser.add_argument(
- "--output",
- "-o",
- default="config.yaml",
- help="输出文件路径(默认: config.yaml)",
- )
- parser.add_argument(
- "--config", "-c", default="config.yaml", help="要验证/保存的配置文件路径"
- )
- parser.add_argument(
- "--name", "-n", help="保存预设时的名称(格式: 学校_学院_论文类型)"
- )
-
- args = parser.parse_args()
-
- if args.list_presets:
- list_presets()
- elif args.use:
- use_preset(args.use, args.output)
- elif args.validate:
- errors = validate(args.config)
- if not errors:
- print(f"✅ 配置文件验证通过: {args.config}")
- else:
- print(f"❌ 发现 {len(errors)} 个问题:")
- for err in errors:
- print(f" {err}")
- sys.exit(1)
- elif args.save:
- if not args.name:
- print("错误: --save 需要 --name 参数(格式: 学校_学院_论文类型)")
- sys.exit(1)
- save_preset(args.config, args.name)
-
-
-if __name__ == "__main__":
- main()
diff --git a/wordformat-skill/scripts/validate_config.py b/wordformat-skill/scripts/validate_config.py
deleted file mode 100644
index bcc41e5..0000000
--- a/wordformat-skill/scripts/validate_config.py
+++ /dev/null
@@ -1,431 +0,0 @@
-#!/usr/bin/env python3
-"""
-WordFormat 配置文件验证器
-
-验证用户编辑的 config.yaml 是否合法:
-1. 检查 YAML 语法是否正确
-2. 检查是否包含非法字段(模板中不存在的字段)
-3. 检查必要字段是否缺失
-4. 检查字段值是否在允许范围内
-
-用法:
- python validate_config.py --config config.yaml
- python validate_config.py --config config.yaml --fix # 自动移除非法字段
-"""
-
-import argparse
-import sys
-from pathlib import Path
-
-
-def _get_pip_mirror() -> list[str]:
- """根据用户地区自动选择 pip 镜像源"""
- import locale
-
- lang = locale.getdefaultlocale()[0] or ""
- # 中文环境优先使用清华镜像
- if lang.startswith("zh"):
- return [
- sys.executable,
- "-m",
- "pip",
- "install",
- "pyyaml",
- "--break-system-packages",
- "-q",
- "-i",
- "https://pypi.tuna.tsinghua.edu.cn/simple",
- ]
- return [
- sys.executable,
- "-m",
- "pip",
- "install",
- "pyyaml",
- "--break-system-packages",
- "-q",
- ]
-
-
-try:
- import yaml
-except ImportError:
- import subprocess
-
- subprocess.check_call(_get_pip_mirror())
- import yaml
-
-
-# ==================== 合法字段白名单 ====================
-
-# style_checks_warning 下的合法字段
-STYLE_CHECKS_FIELDS = {
- "bold",
- "italic",
- "underline",
- "font_size",
- "font_name",
- "font_color",
- "alignment",
- "space_before",
- "space_after",
- "line_spacing",
- "line_spacingrule",
- "left_indent",
- "right_indent",
- "first_line_indent",
- "builtin_style_name",
-}
-
-# global_format 下的合法字段
-GLOBAL_FORMAT_FIELDS = {
- "alignment",
- "space_before",
- "space_after",
- "line_spacingrule",
- "line_spacing",
- "left_indent",
- "right_indent",
- "first_line_indent",
- "builtin_style_name",
- "chinese_font_name",
- "english_font_name",
- "font_size",
- "font_color",
- "bold",
- "italic",
- "underline",
-}
-
-# abstract.chinese.chinese_title / chinese_content 合法字段
-ABSTRACT_CN_FIELDS = GLOBAL_FORMAT_FIELDS
-
-# abstract.english.english_title / english_content 合法字段
-ABSTRACT_EN_FIELDS = GLOBAL_FORMAT_FIELDS
-
-# abstract.keywords.chinese / english 合法字段
-KEYWORDS_FIELDS = GLOBAL_FORMAT_FIELDS | {
- "label",
- "rules",
-}
-
-# headings 各级别合法字段
-HEADING_FIELDS = GLOBAL_FORMAT_FIELDS
-
-# figures 合法字段
-FIGURES_FIELDS = GLOBAL_FORMAT_FIELDS | {
- "caption_prefix",
- "rules",
-}
-
-# tables 合法字段
-TABLES_FIELDS = GLOBAL_FORMAT_FIELDS | {
- "caption_prefix",
- "content",
- "rules",
-}
-
-# references.title 合法字段
-REF_TITLE_FIELDS = GLOBAL_FORMAT_FIELDS
-
-# references.content 合法字段
-REF_CONTENT_FIELDS = GLOBAL_FORMAT_FIELDS
-
-# acknowledgements.title / content 合法字段
-ACK_FIELDS = GLOBAL_FORMAT_FIELDS
-
-# numbering 合法字段
-NUMBERING_FIELDS = {"enabled", "level_1", "level_2", "level_3"}
-NUMBERING_LEVEL_FIELDS = {
- "enabled",
- "template",
- "suffix",
- "numbering_indent",
- "text_indent",
-}
-
-# 值范围校验
-ALIGNMENT_VALUES = {"左对齐", "居中对齐", "右对齐", "两端对齐", "分散对齐"}
-LINE_SPACINGRULE_VALUES = {
- "单倍行距",
- "1.5倍行距",
- "2倍行距",
- "最小值",
- "固定值",
- "多倍行距",
-}
-CAPTION_POSITION_VALUES = {"above", "below"}
-CAPTION_PREFIX_VALUES = {"图", "表"}
-BUILTIN_STYLE_VALUES = {"正文", "Heading 1", "Heading 2", "Heading 3", "题注"}
-
-
-def load_yaml(filepath: str) -> dict:
- """加载 YAML 文件"""
- with open(filepath, encoding="utf-8") as f:
- return yaml.safe_load(f)
-
-
-def check_fields(data: dict, allowed_fields: set, path: str) -> list[str]:
- """检查字段是否合法,返回非法字段列表"""
- if not isinstance(data, dict):
- return []
- errors = []
- for key in data:
- if key not in allowed_fields:
- errors.append(f" 非法字段: {path}.{key} = {data[key]!r}")
- return errors
-
-
-def check_value(data: dict, field: str, allowed_values: set, path: str) -> list[str]:
- """检查字段值是否在允许范围内"""
- if field in data and data[field] not in allowed_values:
- return [f" 值错误: {path}.{field} = {data[field]!r},允许值: {allowed_values}"]
- return []
-
-
-def validate_config(config: dict) -> list[str]:
- """验证配置文件,返回错误列表"""
- errors = []
-
- # 1. 检查顶层结构
- required_top_keys = {
- "style_checks_warning",
- "global_format",
- "abstract",
- "headings",
- "body_text",
- "figures",
- "tables",
- "references",
- "acknowledgements",
- }
- actual_top_keys = set(config.keys()) if config else set()
- missing = required_top_keys - actual_top_keys
- if missing:
- errors.append(f" 缺少顶层节点: {missing}")
- # numbering 是可选的,不检查缺失
- extra = actual_top_keys - required_top_keys - {"numbering"}
- if extra:
- errors.append(f" 非法顶层节点: {extra}")
-
- # 2. style_checks_warning
- if "style_checks_warning" in config:
- errors.extend(
- check_fields(
- config["style_checks_warning"],
- STYLE_CHECKS_FIELDS,
- "style_checks_warning",
- )
- )
-
- # 3. global_format
- if "global_format" in config:
- errors.extend(
- check_fields(config["global_format"], GLOBAL_FORMAT_FIELDS, "global_format")
- )
-
- # 4. abstract
- if "abstract" in config:
- ab = config["abstract"]
- if isinstance(ab, dict):
- for lang in ["chinese", "english"]:
- if lang in ab and isinstance(ab[lang], dict):
- for section in ab[lang]:
- section_data = ab[lang][section]
- allowed = (
- KEYWORDS_FIELDS
- if "keywords" in section
- else (
- ABSTRACT_CN_FIELDS
- if lang == "chinese"
- else ABSTRACT_EN_FIELDS
- )
- )
- errors.extend(
- check_fields(
- section_data, allowed, f"abstract.{lang}.{section}"
- )
- )
-
- # 5. headings
- if "headings" in config:
- hd = config["headings"]
- if isinstance(hd, dict):
- for level in ["level_1", "level_2", "level_3"]:
- if level in hd:
- errors.extend(
- check_fields(hd[level], HEADING_FIELDS, f"headings.{level}")
- )
-
- # 6. body_text
- if "body_text" in config:
- errors.extend(
- check_fields(config["body_text"], GLOBAL_FORMAT_FIELDS, "body_text")
- )
-
- # 7. figures
- if "figures" in config:
- errors.extend(check_fields(config["figures"], FIGURES_FIELDS, "figures"))
-
- # 8. tables
- if "tables" in config:
- errors.extend(check_fields(config["tables"], TABLES_FIELDS, "tables"))
-
- # 9. references
- if "references" in config:
- ref = config["references"]
- if isinstance(ref, dict):
- for section in ["title", "content"]:
- if section in ref:
- allowed = (
- REF_TITLE_FIELDS if section == "title" else REF_CONTENT_FIELDS
- )
- errors.extend(
- check_fields(ref[section], allowed, f"references.{section}")
- )
-
- # 10. acknowledgements
- if "acknowledgements" in config:
- ack = config["acknowledgements"]
- if isinstance(ack, dict):
- for section in ["title", "content"]:
- if section in ack:
- errors.extend(
- check_fields(
- ack[section], ACK_FIELDS, f"acknowledgements.{section}"
- )
- )
-
- # 11. numbering(可选)
- if "numbering" in config:
- num = config["numbering"]
- if isinstance(num, dict):
- errors.extend(check_fields(num, NUMBERING_FIELDS, "numbering"))
- for level in ["level_1", "level_2", "level_3"]:
- if level in num and isinstance(num[level], dict):
- errors.extend(
- check_fields(
- num[level], NUMBERING_LEVEL_FIELDS, f"numbering.{level}"
- )
- )
-
- return errors
-
-
-def remove_extra_fields(data: dict, allowed_fields: set) -> dict:
- """递归移除非法字段"""
- if not isinstance(data, dict):
- return data
- return {
- k: remove_extra_fields(v, allowed_fields) if isinstance(v, dict) else v
- for k, v in data.items()
- if k in allowed_fields
- }
-
-
-def fix_config(config: dict) -> dict:
- """自动修复配置文件,移除所有非法字段"""
- if not isinstance(config, dict):
- return config
-
- fixed = {}
- for key, value in config.items():
- if key == "style_checks_warning" and isinstance(value, dict):
- fixed[key] = remove_extra_fields(value, STYLE_CHECKS_FIELDS)
- elif key == "global_format" and isinstance(value, dict):
- fixed[key] = remove_extra_fields(value, GLOBAL_FORMAT_FIELDS)
- elif key == "abstract" and isinstance(value, dict):
- fixed[key] = {}
- for lang, lang_data in value.items():
- if isinstance(lang_data, dict):
- fixed[key][lang] = {}
- for section, section_data in lang_data.items():
- if isinstance(section_data, dict):
- allowed = (
- KEYWORDS_FIELDS
- if "keywords" in section
- else GLOBAL_FORMAT_FIELDS
- )
- fixed[key][lang][section] = remove_extra_fields(
- section_data, allowed
- )
- elif key == "headings" and isinstance(value, dict):
- fixed[key] = {}
- for level, level_data in value.items():
- if isinstance(level_data, dict):
- fixed[key][level] = remove_extra_fields(level_data, HEADING_FIELDS)
- elif key == "body_text" and isinstance(value, dict):
- fixed[key] = remove_extra_fields(value, GLOBAL_FORMAT_FIELDS)
- elif key == "figures" and isinstance(value, dict):
- fixed[key] = remove_extra_fields(value, FIGURES_FIELDS)
- elif key == "tables" and isinstance(value, dict):
- fixed[key] = remove_extra_fields(value, TABLES_FIELDS)
- elif key == "references" and isinstance(value, dict):
- fixed[key] = {}
- for section, section_data in value.items():
- if isinstance(section_data, dict):
- allowed = (
- REF_TITLE_FIELDS if section == "title" else REF_CONTENT_FIELDS
- )
- fixed[key][section] = remove_extra_fields(section_data, allowed)
- elif key == "acknowledgements" and isinstance(value, dict):
- fixed[key] = {}
- for section, section_data in value.items():
- if isinstance(section_data, dict):
- fixed[key][section] = remove_extra_fields(section_data, ACK_FIELDS)
- else:
- fixed[key] = value
- return fixed
-
-
-def main():
- parser = argparse.ArgumentParser(description="WordFormat 配置文件验证器")
- parser.add_argument(
- "--config", "-c", required=True, help="要验证的 YAML 配置文件路径"
- )
- parser.add_argument(
- "--fix", "-f", action="store_true", help="自动移除非法字段并覆盖原文件"
- )
- args = parser.parse_args()
-
- filepath = args.config
- if not Path(filepath).exists():
- print(f"错误: 文件不存在: {filepath}")
- sys.exit(1)
-
- # 加载 YAML
- try:
- config = load_yaml(filepath)
- except yaml.YAMLError as e:
- print(f"YAML 语法错误: {e}")
- sys.exit(1)
-
- if not isinstance(config, dict):
- print("错误: 配置文件根节点必须是字典/映射")
- sys.exit(1)
-
- # 验证
- errors = validate_config(config)
-
- if not errors:
- print(f"✅ 配置文件验证通过: {filepath}")
- sys.exit(0)
-
- print(f"❌ 发现 {len(errors)} 个问题:")
- for err in errors:
- print(err)
-
- if args.fix:
- fixed = fix_config(config)
- # 保留 YAML 锚点引用(fix 会破坏锚点,需要特殊处理)
- # 由于 fix 会移除 <<: *global_format,这里提示用户
- print("\n⚠️ --fix 模式会移除 YAML 锚点引用,建议手动编辑修复。")
- print(" 运行 wordf config 查看可用字段。")
- sys.exit(1)
-
- print("\n请手动修复以上问题,运行 wordf config 查看可用字段。")
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
diff --git a/wordformat-skill/scripts/validate_json.py b/wordformat-skill/scripts/validate_json.py
deleted file mode 100644
index 13e6b55..0000000
--- a/wordformat-skill/scripts/validate_json.py
+++ /dev/null
@@ -1,248 +0,0 @@
-#!/usr/bin/env python3
-"""
-WordFormat JSON 标签文件校验脚本
-
-校验 wordf gj 生成的 JSON 文件中的 category 字段是否合法,
-并输出每个段落的分类结果供人工检查。
-
-用法:
- # 校验 JSON 文件
- python validate_json.py --json output/论文_1234567890.json
-
- # 输出分类统计
- python validate_json.py --json output/论文_1234567890.json --stats
-
- # 仅列出可疑分类(置信度低于阈值)
- python validate_json.py --json output/论文_1234567890.json --threshold 0.8
-"""
-
-import argparse
-import json
-import sys
-from collections import Counter
-
-# 模型实际输出的所有合法 category(来源于 id2label.json)
-# 以及人工修正时使用的辅助 category
-VALID_CATEGORIES = {
- "abstract_chinese_title",
- "abstract_chinese_title_content",
- "abstract_english_title",
- "abstract_english_title_content",
- "keywords_chinese",
- "keywords_english",
- "heading_level_1",
- "heading_level_2",
- "heading_level_3",
- "heading_mulu",
- "heading_fulu",
- "references_title",
- "acknowledgements_title",
- "caption_figure",
- "caption_table",
- "figure_image",
- "table_object",
- "body_text",
- "other",
-}
-
-# category 中文说明
-CATEGORY_LABELS = {
- "abstract_chinese_title": "中文摘要标题",
- "abstract_chinese_title_content": "中文摘要标题+内容",
- "abstract_english_title": "英文摘要标题",
- "abstract_english_title_content": "英文摘要标题+内容",
- "keywords_chinese": "中文关键词",
- "keywords_english": "英文关键词",
- "heading_level_1": "一级标题(章)",
- "heading_level_2": "二级标题(节)",
- "heading_level_3": "三级标题(小节)",
- "heading_mulu": "目录标题",
- "heading_fulu": "附录标题",
- "references_title": "参考文献标题",
- "acknowledgements_title": "致谢标题",
- "caption_figure": "图题注",
- "caption_table": "表题注",
- "body_text": "正文",
- "other": "其他(跳过格式化)",
-}
-
-
-def validate_json(json_path: str, threshold: float = 0.0) -> list[dict]:
- """校验 JSON 文件,返回问题列表"""
- try:
- with open(json_path, encoding="utf-8") as f:
- data = json.load(f)
- except json.JSONDecodeError as e:
- return [{"index": -1, "error": f"JSON 语法错误: {e}"}]
- except FileNotFoundError:
- return [{"index": -1, "error": f"文件不存在: {json_path}"}]
-
- if not isinstance(data, list):
- return [{"index": -1, "error": "JSON 根节点必须是数组"}]
-
- issues = []
- for i, item in enumerate(data):
- if not isinstance(item, dict):
- issues.append(
- {"index": i, "error": f"第 {i} 项不是字典: {type(item).__name__}"}
- )
- continue
-
- # 检查 category 字段
- category = item.get("category", "")
- if not category:
- issues.append(
- {
- "index": i,
- "error": "缺少 category 字段",
- "paragraph": item.get("paragraph", "")[:50],
- }
- )
- continue
-
- if category not in VALID_CATEGORIES:
- issues.append(
- {
- "index": i,
- "error": f"非法 category: '{category}'",
- "paragraph": item.get("paragraph", "")[:50],
- "hint": f"合法值: {sorted(VALID_CATEGORIES)}",
- }
- )
-
- # 检查置信度
- score = item.get("score", 0)
- if isinstance(score, (int, float)) and score < threshold:
- issues.append(
- {
- "index": i,
- "warning": f"低置信度: {score}",
- "category": category,
- "paragraph": item.get("paragraph", "")[:80],
- }
- )
-
- return issues
-
-
-def print_results(data: list, threshold: float = 0.0):
- """打印分类结果供人工检查"""
- print(f"\n{'=' * 70}")
- print(f" JSON 标签检查结果(共 {len(data)} 个段落)")
- print(f"{'=' * 70}\n")
-
- replaced_count = 0
- for i, item in enumerate(data):
- category = item.get("category", "???")
- score = item.get("score", 0)
- paragraph = item.get("paragraph", "")[:60]
- label = CATEGORY_LABELS.get(category, "未知类型")
-
- # 低置信度标记
- flag = ""
- if isinstance(score, (int, float)) and score < threshold:
- flag = " ⚠️ 低置信度"
- if category not in VALID_CATEGORIES:
- flag = " ❌ 非法类型"
-
- # 检查 replace 字段
- replace_text = item.get("replace", "")
- has_replace = bool(replace_text and isinstance(replace_text, str))
-
- print(f" [{i:3d}] {label:20s} 置信度:{score:.4f}{flag}")
- print(f" {paragraph}...")
- if has_replace:
- print(f" 🔄 replace → \"{replace_text[:60]}\"")
- replaced_count += 1
- print()
-
- if replaced_count > 0:
- print(f" 🔄 共 {replaced_count} 个段落包含 replace 字段")
-
-
-def print_stats(data: list):
- """打印分类统计"""
- categories = [
- item.get("category", "unknown") for item in data if isinstance(item, dict)
- ]
- counter = Counter(categories)
-
- print(f"\n{'=' * 50}")
- print(f" 分类统计(共 {len(data)} 个段落)")
- print(f"{'=' * 50}\n")
- print(f" {'类型':<35s} {'数量':>5s}")
- print(f" {'-' * 42}")
- for cat, count in counter.most_common():
- label = CATEGORY_LABELS.get(cat, cat)
- print(f" {label:<35s} {count:>5d}")
- print(f" {'-' * 42}")
- print(f" {'合计':<35s} {len(data):>5d}")
-
-
-def main():
- parser = argparse.ArgumentParser(description="WordFormat JSON 标签校验工具")
- parser.add_argument("--json", "-j", required=True, help="JSON 文件路径")
- parser.add_argument("--stats", "-s", action="store_true", help="输出分类统计")
- parser.add_argument(
- "--threshold",
- "-t",
- type=float,
- default=0.0,
- help="低置信度阈值(默认 0.0,即不标记)",
- )
- parser.add_argument(
- "--show-all", action="store_true", help="显示所有段落的分类结果"
- )
- args = parser.parse_args()
-
- # 加载 JSON
- try:
- with open(args.json, encoding="utf-8") as f:
- data = json.load(f)
- except (json.JSONDecodeError, FileNotFoundError) as e:
- print(f"错误: {e}")
- sys.exit(1)
-
- # 校验
- issues = validate_json(args.json, args.threshold)
-
- errors = [i for i in issues if "error" in i]
- warnings = [i for i in issues if "warning" in i]
-
- if errors:
- print(f"❌ 发现 {len(errors)} 个错误:")
- for item in errors:
- idx = item["index"]
- print(f" [{idx}] {item['error']}")
- if "paragraph" in item:
- print(f" 内容: {item['paragraph']}")
- if "hint" in item:
- print(f" {item['hint']}")
- print()
-
- if warnings:
- print(f"⚠️ 发现 {len(warnings)} 个低置信度分类:")
- for item in warnings:
- idx = item["index"]
- print(
- f" [{idx}] {item['warning']} | {item.get('category', '')} | {item.get('paragraph', '')}"
- )
- print()
-
- if not errors and not warnings:
- print(f"✅ JSON 文件校验通过: {args.json}")
-
- # 统计
- if args.stats:
- print_stats(data)
-
- # 显示所有结果
- if args.show_all:
- print_results(data, args.threshold)
-
- if errors:
- sys.exit(1)
-
-
-if __name__ == "__main__":
- main()
From 2e046cf0edb33b59ecaa1d49d9d01f365998b2f9 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 22:15:32 +0800
Subject: [PATCH 03/12] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20NODE=5FTYPE=20?=
=?UTF-8?q?=E5=91=BD=E5=90=8D=E8=A7=84=E8=8C=83=EF=BC=9A<=E7=B1=BB?=
=?UTF-8?q?=E5=88=AB>.<=E7=B1=BB=E5=9E=8B>?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- abstract.chinese.{chinese_title→title, chinese_content→body}
- abstract.english.{english_title→title, english_content→body}
- abstract.{keywords.chinese→chinese.keywords, keywords.english→english.keywords}
- {body_text→body.text}
- {figures→figures.caption, figures.image}
- {tables→tables.caption, tables.object}
- {references.content→references.entry}
- {acknowledgements.content→acknowledgements.body}
- DEFAULTS 内部 key 同步改名(chinese_title→title, etc.)
- 同步更新 YAML 配置和测试夹具
---
example/undergrad_thesis.yaml | 209 ++++++++++++------------
src/wordformat/rules/abstract.py | 76 ++++-----
src/wordformat/rules/acknowledgement.py | 2 +-
src/wordformat/rules/body.py | 2 +-
src/wordformat/rules/caption.py | 4 +-
src/wordformat/rules/keywords.py | 4 +-
src/wordformat/rules/references.py | 2 +-
tests/conftest.py | 54 +++---
tests/rules/test_caption.py | 34 ++--
tests/test_coverage_boost.py | 2 +-
tests/test_tree.py | 2 +-
11 files changed, 195 insertions(+), 196 deletions(-)
diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml
index 900f24c..b161869 100644
--- a/example/undergrad_thesis.yaml
+++ b/example/undergrad_thesis.yaml
@@ -5,6 +5,24 @@
# 使用 YAML 锚点 &global_format 定义全局基础格式,
# 各章节通过 <<: *global_format 继承后覆写特定字段。
#
+# 配置路径规则:<类别>.<类型>
+# abstract.chinese.title — 中文摘要标题
+# abstract.chinese.body — 中文摘要正文
+# abstract.chinese.keywords — 中文关键词
+# abstract.english.title — 英文摘要标题
+# abstract.english.body — 英文摘要正文
+# abstract.english.keywords — 英文关键词
+# headings.level_1/2/3 — 一/二/三级标题
+# body.text — 正文段落
+# figures.caption — 图题注
+# figures.image — 图片段落
+# tables.caption — 表题注
+# tables.object — 表格对象
+# references.title — 参考文献标题
+# references.entry — 参考文献条目
+# acknowledgements.title — 致谢标题
+# acknowledgements.body — 致谢正文
+#
# 支持的单位格式:
# 字号:小四 / 四号 / 三号 / 小二 / 五号 / 数值(pt)
# 间距:0行 / 0.5行 / 1行 / 12磅 / 0.5cm
@@ -41,35 +59,30 @@ style_checks_warning:
# 全局基础格式(其他节通过 <<: *global_format 继承后再覆写)
# ---------------------------------------------------------------------------
global_format: &global_format
- # -- 段落格式 --
- alignment: '两端对齐' # 左对齐/居中对齐/右对齐/两端对齐/分散对齐
- space_before: "0行" # 段前间距
- space_after: "0行" # 段后间距
- line_spacingrule: "1.5倍行距" # 单倍行距/1.5倍行距/2倍行距/最小值/固定值/多倍行距
- line_spacing: '1.5倍' # 行距参数
- left_indent: "0字符" # 左侧缩进
- right_indent: "0字符" # 右侧缩进
- first_line_indent: '2字符' # 首行缩进(正值)/ 悬挂缩进(负值,如 -2.2字符)
- builtin_style_name: '正文' # Word 内置样式名(Heading 1, Heading 2, 正文, 题注 等)
- # -- 字符格式 --
- chinese_font_name: '宋体' # 中文字体
- english_font_name: 'Times New Roman' # 英文字体
- font_size: '小四' # 字号(四号/三号/小二/五号 等)
- font_color: '黑色' # 字体颜色
- bold: false # 加粗
- italic: false # 斜体
- underline: false # 下划线
+ alignment: '两端对齐'
+ space_before: "0行"
+ space_after: "0行"
+ line_spacingrule: "1.5倍行距"
+ line_spacing: '1.5倍'
+ left_indent: "0字符"
+ right_indent: "0字符"
+ first_line_indent: '2字符'
+ builtin_style_name: '正文'
+ chinese_font_name: '宋体'
+ english_font_name: 'Times New Roman'
+ font_size: '小四'
+ font_color: '黑色'
+ bold: false
+ italic: false
+ underline: false
# ===========================================================================
# 摘要(中英文)
# ===========================================================================
abstract:
- # -----------------------------------------------------------------------
- # 中文摘要
- # -----------------------------------------------------------------------
chinese:
# -- 中文摘要标题("摘要"二字)--
- chinese_title:
+ title:
<<: *global_format
alignment: '居中对齐'
first_line_indent: '0字符'
@@ -78,54 +91,46 @@ abstract:
bold: false
# -- 中文摘要正文 --
- chinese_content:
- <<: *global_format
- alignment: '两端对齐'
-
- # -----------------------------------------------------------------------
- # 英文摘要
- # -----------------------------------------------------------------------
- english:
- # -- 英文摘要标题("Abstract")--
- english_title:
- <<: *global_format
- alignment: '居中对齐'
- first_line_indent: '0字符'
- font_size: '四号'
- bold: false
-
- # -- 英文摘要正文 --
- english_content:
+ body:
<<: *global_format
alignment: '两端对齐'
- # -----------------------------------------------------------------------
- # 关键词(中英文)
- # -----------------------------------------------------------------------
- keywords:
- chinese:
- # 从 global_format 继承,再覆写关键词特有字段
+ # -- 中文关键词 --
+ keywords:
<<: *global_format
alignment: '两端对齐'
first_line_indent: '2字符'
font_size: '小四'
- # -- 关键词标签("关键词:")的字符格式 --
label:
<<: *global_format
chinese_font_name: '黑体'
font_size: '四号'
bold: false
- # -- 关键词业务规则(均通过 enabled 开关控制) --
rules:
keyword_count:
enabled: true
- count_min: 3 # 最少关键词数
- count_max: 5 # 最多关键词数
+ count_min: 3
+ count_max: 5
trailing_punctuation:
enabled: true
- forbidden_chars: ";,。、" # 中文关键词末尾禁止出现的标点
+ forbidden_chars: ";,。、"
+
+ english:
+ # -- 英文摘要标题("Abstract")--
+ title:
+ <<: *global_format
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
+ font_size: '四号'
+ bold: false
- english:
+ # -- 英文摘要正文 --
+ body:
+ <<: *global_format
+ alignment: '两端对齐'
+
+ # -- 英文关键词 --
+ keywords:
<<: *global_format
alignment: '两端对齐'
first_line_indent: '2字符'
@@ -144,7 +149,6 @@ abstract:
# 各级标题(章、节、小节)
# ===========================================================================
headings:
- # -- 一级标题(第1章)--
level_1:
<<: *global_format
alignment: '居中对齐'
@@ -156,7 +160,6 @@ headings:
space_after: "0.5行"
builtin_style_name: 'Heading 1'
- # -- 二级标题(1.1)--
level_2:
<<: *global_format
alignment: '左对齐'
@@ -168,7 +171,6 @@ headings:
space_after: "0行"
builtin_style_name: 'Heading 2'
- # -- 三级标题(1.1.1)--
level_3:
<<: *global_format
alignment: '左对齐'
@@ -183,61 +185,61 @@ headings:
# ===========================================================================
# 正文段落
# ===========================================================================
-body_text:
- <<: *global_format
- rules:
- punctuation:
- enabled: true
+body:
+ text:
+ <<: *global_format
+ rules:
+ punctuation:
+ enabled: true
# ===========================================================================
-# 插图题注(Figure)
+# 插图(题注 + 图片段落)
# ===========================================================================
figures:
- <<: *global_format
- font_size: '五号'
- builtin_style_name: '题注'
- alignment: '居中对齐'
- first_line_indent: '0字符'
- # -- 插图特有字段 --
- caption_prefix: '图' # 图注编号前缀
- # -- 图片段落格式(包含内联图片的段落)--
+ caption:
+ <<: *global_format
+ font_size: '五号'
+ builtin_style_name: '题注'
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
+ caption_prefix: '图'
+ rules:
+ caption_numbering:
+ enabled: true
+ separator: '.'
+ label_number_space: false
+
image:
<<: *global_format
alignment: '居中对齐'
first_line_indent: '0字符'
- rules:
- caption_numbering:
- enabled: true
- separator: '.'
- label_number_space: false
# ===========================================================================
-# 表格(题注 + 表格对象 + 表格内容)
+# 表格(题注 + 表格对象)
# ===========================================================================
tables:
- <<: *global_format
- font_size: '五号'
- builtin_style_name: '题注'
- alignment: '居中对齐'
- first_line_indent: '0字符'
- # -- 表格题注特有字段 --
- caption_prefix: '表' # 表注编号前缀
- # -- 表格对象格式(表格整体对齐、环绕)--
+ caption:
+ <<: *global_format
+ font_size: '五号'
+ builtin_style_name: '题注'
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
+ caption_prefix: '表'
+ rules:
+ caption_numbering:
+ enabled: true
+ separator: '.'
+ label_number_space: false
+
object:
<<: *global_format
alignment: '居中对齐'
first_line_indent: '0字符'
- rules:
- caption_numbering:
- enabled: true
- separator: '.'
- label_number_space: false
# ===========================================================================
# 参考文献
# ===========================================================================
references:
- # -- 参考文献标题("参考文献")--
title:
<<: *global_format
alignment: '居中对齐'
@@ -246,12 +248,11 @@ references:
font_size: '三号'
bold: false
- # -- 参考文献条目内容 --
- content:
+ entry:
<<: *global_format
alignment: '两端对齐'
- first_line_indent: '-2.2字符' # 悬挂缩进(负值=悬挂)
- left_indent: '0.26字符' # 文本之前
+ first_line_indent: '-2.2字符'
+ left_indent: '0.26字符'
chinese_font_name: '宋体'
english_font_name: 'Times New Roman'
font_size: '五号'
@@ -260,7 +261,6 @@ references:
# 致谢
# ===========================================================================
acknowledgements:
- # -- 致谢标题("致谢")--
title:
<<: *global_format
alignment: '居中对齐'
@@ -269,8 +269,7 @@ acknowledgements:
font_size: '小二'
bold: false
- # -- 致谢正文 --
- content:
+ body:
<<: *global_format
alignment: '两端对齐'
font_size: '五号'
@@ -279,25 +278,22 @@ acknowledgements:
# 标题自动编号
# ===========================================================================
numbering:
- enabled: true # 总开关:是否启用自动编号
+ enabled: true
- # -- 一级标题编号(如"第1章")--
level_1:
enabled: true
- template: '%1' # %1=本级序号
- suffix: 'space' # 编号后分隔符:tab / space / nothing
- numbering_indent: # [可选] 编号缩进,如 '0字符'
- text_indent: # [可选] 文本缩进(悬挂缩进),如 '0.75cm'
+ template: '%1'
+ suffix: 'space'
+ numbering_indent:
+ text_indent:
- # -- 二级标题编号(如"1.1")--
level_2:
enabled: true
- template: '%1.%2' # %1=上级序号, %2=本级序号
+ template: '%1.%2'
suffix: 'space'
numbering_indent:
text_indent:
- # -- 三级标题编号(如"1.1.1")--
level_3:
enabled: true
template: '%1.%2.%3'
@@ -305,10 +301,9 @@ numbering:
numbering_indent:
text_indent:
- # -- 参考文献条目编号(如"[1]")--
references:
enabled: true
template: '[%1]'
suffix: 'space'
numbering_indent:
- text_indent: # 悬挂缩进,如 '2.2字符'(优先级高于 references.content.first_line_indent)
+ text_indent:
diff --git a/src/wordformat/rules/abstract.py b/src/wordformat/rules/abstract.py
index fc2a64d..7361f63 100644
--- a/src/wordformat/rules/abstract.py
+++ b/src/wordformat/rules/abstract.py
@@ -14,7 +14,7 @@
class AbstractTitleCN(FormatNode):
"""摘要标题中文节点"""
- NODE_TYPE = "abstract.chinese.chinese_title"
+ NODE_TYPE = "abstract.chinese.title"
NODE_LABEL = "中文摘要标题"
DEFAULTS = {
**BASE_FORMAT,
@@ -33,7 +33,7 @@ class AbstractTitleContentCN(FormatNode):
NODE_TYPE = "abstract.chinese"
NODE_LABEL = "中文摘要"
DEFAULTS = {
- "chinese_title": {
+ "title": {
**BASE_FORMAT,
"alignment": "居中对齐",
"first_line_indent": "0字符",
@@ -41,7 +41,7 @@ class AbstractTitleContentCN(FormatNode):
"font_size": "小二",
"bold": True,
},
- "chinese_content": {**BASE_FORMAT, "alignment": "两端对齐"},
+ "body": {**BASE_FORMAT, "alignment": "两端对齐"},
}
def check_title(self, run) -> bool:
@@ -56,7 +56,7 @@ def _base(self, doc, p: bool, r: bool):
# 混合节点的cfg有两个值,遂需要重新组装
# 段落样式选择content样式
# 字体样式"摘要"选择title,"正文"选择content
- ps = ParagraphStyle.from_config(cfg.chinese_content)
+ ps = ParagraphStyle.from_config(cfg.body)
if p:
issues = ps.diff_from_paragraph(self.paragraph)
else:
@@ -67,13 +67,13 @@ def _base(self, doc, p: bool, r: bool):
if self.check_title(run):
# 对run对象设置样式
C = CharacterStyle(
- font_name_cn=cfg.chinese_title.chinese_font_name,
- font_name_en=cfg.chinese_title.english_font_name,
- font_size=cfg.chinese_title.font_size,
- font_color=cfg.chinese_title.font_color,
- bold=cfg.chinese_title.bold,
- italic=cfg.chinese_title.italic,
- underline=cfg.chinese_title.underline,
+ font_name_cn=cfg.title.chinese_font_name,
+ font_name_en=cfg.title.english_font_name,
+ font_size=cfg.title.font_size,
+ font_color=cfg.title.font_color,
+ bold=cfg.title.bold,
+ italic=cfg.title.italic,
+ underline=cfg.title.underline,
)
if r:
diff_result = C.diff_from_run(run)
@@ -82,13 +82,13 @@ def _base(self, doc, p: bool, r: bool):
else:
# 对剩余部分的run设置样式
C = CharacterStyle(
- font_name_cn=cfg.chinese_content.chinese_font_name,
- font_name_en=cfg.chinese_content.english_font_name,
- font_size=cfg.chinese_content.font_size,
- font_color=cfg.chinese_content.font_color,
- bold=cfg.chinese_content.bold,
- italic=cfg.chinese_content.italic,
- underline=cfg.chinese_content.underline,
+ font_name_cn=cfg.body.chinese_font_name,
+ font_name_en=cfg.body.english_font_name,
+ font_size=cfg.body.font_size,
+ font_color=cfg.body.font_color,
+ bold=cfg.body.bold,
+ italic=cfg.body.italic,
+ underline=cfg.body.underline,
)
if r:
diff_result = C.diff_from_run(run)
@@ -110,7 +110,7 @@ def _base(self, doc, p: bool, r: bool):
class AbstractContentCN(FormatNode):
"""摘要内容中文节点"""
- NODE_TYPE = "abstract.chinese.chinese_content"
+ NODE_TYPE = "abstract.chinese.body"
NODE_LABEL = "中文摘要正文"
DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"}
@@ -151,7 +151,7 @@ def _base(self, doc, p: bool, r: bool):
class AbstractTitleEN(FormatNode):
"""摘要标题英文节点"""
- NODE_TYPE = "abstract.english.english_title"
+ NODE_TYPE = "abstract.english.title"
NODE_LABEL = "英文摘要标题"
DEFAULTS = {
**BASE_FORMAT,
@@ -169,14 +169,14 @@ class AbstractTitleContentEN(FormatNode):
NODE_TYPE = "abstract.english"
NODE_LABEL = "英文摘要"
DEFAULTS = {
- "english_title": {
+ "title": {
**BASE_FORMAT,
"alignment": "居中对齐",
"first_line_indent": "0字符",
"font_size": "四号",
"bold": True,
},
- "english_content": {**BASE_FORMAT, "alignment": "两端对齐"},
+ "body": {**BASE_FORMAT, "alignment": "两端对齐"},
}
def _check_title_in_full_text(self, runs) -> int:
@@ -197,7 +197,7 @@ def _base(self, doc, p: bool, r: bool):
# 段落样式选择content样式
# 字体样式"摘要"选择title,"正文"选择content
cfg = self.pydantic_config
- ps = ParagraphStyle.from_config(cfg.english_content)
+ ps = ParagraphStyle.from_config(cfg.body)
if p:
issues = ps.diff_from_paragraph(self.paragraph)
else:
@@ -234,23 +234,23 @@ def _base(self, doc, p: bool, r: bool):
run.text = text_clean[title_end - run_start :]
c = CharacterStyle(
- font_name_cn=cfg.english_title.chinese_font_name,
- font_name_en=cfg.english_title.english_font_name,
- font_size=cfg.english_title.font_size,
- font_color=cfg.english_title.font_color,
- bold=cfg.english_title.bold,
- italic=cfg.english_title.italic,
- underline=cfg.english_title.underline,
+ font_name_cn=cfg.title.chinese_font_name,
+ font_name_en=cfg.title.english_font_name,
+ font_size=cfg.title.font_size,
+ font_color=cfg.title.font_color,
+ bold=cfg.title.bold,
+ italic=cfg.title.italic,
+ underline=cfg.title.underline,
)
else:
c = CharacterStyle(
- font_name_cn=cfg.english_content.chinese_font_name,
- font_name_en=cfg.english_content.english_font_name,
- font_size=cfg.english_content.font_size,
- font_color=cfg.english_content.font_color,
- bold=cfg.english_content.bold,
- italic=cfg.english_content.italic,
- underline=cfg.english_content.underline,
+ font_name_cn=cfg.body.chinese_font_name,
+ font_name_en=cfg.body.english_font_name,
+ font_size=cfg.body.font_size,
+ font_color=cfg.body.font_color,
+ bold=cfg.body.bold,
+ italic=cfg.body.italic,
+ underline=cfg.body.underline,
)
if r:
diff_result = c.diff_from_run(run)
@@ -273,7 +273,7 @@ def _base(self, doc, p: bool, r: bool):
class AbstractContentEN(FormatNode):
"""摘要内容英文节点"""
- NODE_TYPE = "abstract.english.english_content"
+ NODE_TYPE = "abstract.english.body"
NODE_LABEL = "英文摘要正文"
DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"}
diff --git a/src/wordformat/rules/acknowledgement.py b/src/wordformat/rules/acknowledgement.py
index ac4fccb..d557c7c 100644
--- a/src/wordformat/rules/acknowledgement.py
+++ b/src/wordformat/rules/acknowledgement.py
@@ -28,7 +28,7 @@ class Acknowledgements(FormatNode):
class AcknowledgementsCN(FormatNode):
"""致谢内容"""
- NODE_TYPE = "acknowledgements.content"
+ NODE_TYPE = "acknowledgements.body"
NODE_LABEL = "致谢内容"
DEFAULTS = {
**BASE_FORMAT,
diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py
index 1e8a8c2..8c0b377 100644
--- a/src/wordformat/rules/body.py
+++ b/src/wordformat/rules/body.py
@@ -90,7 +90,7 @@ def _split_run_at(paragraph, start: int, end: int):
class BodyText(FormatNode):
"""正文节点"""
- NODE_TYPE = "body_text"
+ NODE_TYPE = "body.text"
NODE_LABEL = "正文段落"
DEFAULTS = {
"alignment": "两端对齐",
diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py
index d021945..7343432 100644
--- a/src/wordformat/rules/caption.py
+++ b/src/wordformat/rules/caption.py
@@ -146,7 +146,7 @@ def _apply_caption_numbering(
class CaptionFigure(FormatNode):
"""题注-图片"""
- NODE_TYPE = "figures"
+ NODE_TYPE = "figures.caption"
NODE_LABEL = "图注"
DEFAULTS = {
**BASE_FORMAT,
@@ -174,7 +174,7 @@ def _handle_caption_numbering(self, doc, rule_cfg, p: bool):
class CaptionTable(FormatNode):
"""题注-表格"""
- NODE_TYPE = "tables"
+ NODE_TYPE = "tables.caption"
NODE_LABEL = "表注"
DEFAULTS = {
**BASE_FORMAT,
diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py
index 6ed077b..d55d9e7 100644
--- a/src/wordformat/rules/keywords.py
+++ b/src/wordformat/rules/keywords.py
@@ -83,7 +83,7 @@ def _get_label_split_pattern(self) -> re.Pattern | None:
class KeywordsEN(BaseKeywordsNode):
"""关键词节点-英文"""
- NODE_TYPE = "abstract.keywords.english"
+ NODE_TYPE = "abstract.english.keywords"
NODE_LABEL = "英文关键词"
DEFAULTS = {
**BASE_FORMAT,
@@ -209,7 +209,7 @@ def _base(self, doc, p: bool, r: bool):
class KeywordsCN(BaseKeywordsNode):
"""关键词节点-中文"""
- NODE_TYPE = "abstract.keywords.chinese"
+ NODE_TYPE = "abstract.chinese.keywords"
NODE_LABEL = "中文关键词"
DEFAULTS = {
**BASE_FORMAT,
diff --git a/src/wordformat/rules/references.py b/src/wordformat/rules/references.py
index b8a8ae2..b66820b 100644
--- a/src/wordformat/rules/references.py
+++ b/src/wordformat/rules/references.py
@@ -28,7 +28,7 @@ class References(FormatNode):
class ReferenceEntry(FormatNode):
"""参考文献条目节点"""
- NODE_TYPE = "references.content"
+ NODE_TYPE = "references.entry"
NODE_LABEL = "参考文献条目"
DEFAULTS = {
**BASE_FORMAT,
diff --git a/tests/conftest.py b/tests/conftest.py
index be605ec..c5495c8 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -99,24 +99,15 @@ def temp_json(tmp_path):
underline: false
abstract:
chinese:
- chinese_title:
+ title:
alignment: '居中对齐'
first_line_indent: '0字符'
chinese_font_name: '黑体'
font_size: '小二'
bold: true
- chinese_content:
+ body:
alignment: '两端对齐'
- english:
- english_title:
- alignment: '居中对齐'
- first_line_indent: '0字符'
- font_size: '四号'
- bold: true
- english_content:
- alignment: '两端对齐'
- keywords:
- chinese:
+ keywords:
label:
chinese_font_name: '黑体'
font_size: '三号'
@@ -133,7 +124,15 @@ def temp_json(tmp_path):
count_max: 5
trailing_punctuation:
enabled: true
- english:
+ english:
+ title:
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
+ font_size: '四号'
+ bold: true
+ body:
+ alignment: '两端对齐'
+ keywords:
label:
font_size: '三号'
bold: true
@@ -168,17 +167,24 @@ def temp_json(tmp_path):
chinese_font_name: '黑体'
font_size: '小四'
builtin_style_name: 'Heading 3'
-body_text:
- alignment: '两端对齐'
- chinese_font_name: '宋体'
- english_font_name: 'Times New Roman'
- font_size: '小四'
+body:
+ text:
+ alignment: '两端对齐'
+ chinese_font_name: '宋体'
+ english_font_name: 'Times New Roman'
+ font_size: '小四'
figures:
- caption_prefix: '图'
+ caption:
+ caption_prefix: '图'
+ image:
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
tables:
- caption_prefix: '表'
- content:
- font_size: '五号'
+ caption:
+ caption_prefix: '表'
+ object:
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
references:
title:
alignment: '居中对齐'
@@ -186,7 +192,7 @@ def temp_json(tmp_path):
chinese_font_name: '黑体'
font_size: '三号'
bold: true
- content:
+ entry:
alignment: '左对齐'
first_line_indent: '0字符'
font_size: '五号'
@@ -197,7 +203,7 @@ def temp_json(tmp_path):
chinese_font_name: '黑体'
font_size: '小二'
bold: true
- content:
+ body:
alignment: '两端对齐'
first_line_indent: '2字符'
numbering:
diff --git a/tests/rules/test_caption.py b/tests/rules/test_caption.py
index d22c176..decc7e8 100644
--- a/tests/rules/test_caption.py
+++ b/tests/rules/test_caption.py
@@ -282,26 +282,24 @@ def caption_yaml(self, tmp_path):
font_color: '黑色'
builtin_style_name: '正文'
figures:
- caption_position: 'below'
- caption_prefix: '图'
- rules:
- caption_numbering:
- enabled: true
- separator: '.'
+ caption:
+ caption_prefix: '图'
+ rules:
+ caption_numbering:
+ enabled: true
+ separator: '.'
tables:
- caption_position: 'above'
- caption_prefix: '表'
- content:
- font_size: '五号'
- rules:
- caption_numbering:
- enabled: true
- separator: '.'
+ caption:
+ caption_prefix: '表'
+ rules:
+ caption_numbering:
+ enabled: true
+ separator: '.'
+ object:
+ alignment: '居中对齐'
+ first_line_indent: '0字符'
numbering:
enabled: false
- captions:
- enabled: true
- separator: '.'
"""
path = tmp_path / "caption_test.yaml"
path.write_text(yaml_content, encoding="utf-8")
@@ -524,7 +522,7 @@ def test_disabled_skips_numbering_check(self, caption_yaml):
fig = self._make_caption_figure(p)
heading = self._make_heading_node(children=[fig])
config = self._init_config(caption_yaml)
- config["figures"]["rules"]["caption_numbering"]["enabled"] = False
+ config["figures"]["caption"]["rules"]["caption_numbering"]["enabled"] = False
with patch.object(fig, "add_comment") as mock_comment:
apply_format_check_to_all_nodes(heading, doc, config, check=True)
diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py
index eb2413d..987f9b9 100644
--- a/tests/test_coverage_boost.py
+++ b/tests/test_coverage_boost.py
@@ -202,7 +202,7 @@ def test_fix_style_run_properties_sets_font_name(self, root_config):
from wordformat.style.defs import ensure_style_exists
doc = Document()
- cfg = root_config.body_text
+ cfg = root_config.body.text
ensure_style_exists(doc, "Normal")
style = doc.styles["Normal"]
diff --git a/tests/test_tree.py b/tests/test_tree.py
index 5a18ee5..f0bcefb 100644
--- a/tests/test_tree.py
+++ b/tests/test_tree.py
@@ -479,4 +479,4 @@ def test_export_returns_dict_with_key_sections(self):
assert "style_checks_warning" in data
assert "abstract" in data
assert "headings" in data
- assert "body_text" in data
+ assert "body" in data
From 7461c00314bacd1d10d6dc80e684cb9cc36bccae Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 22:26:34 +0800
Subject: [PATCH 04/12] =?UTF-8?q?=E6=A1=86=E6=9E=B6=E6=8A=BD=E8=B1=A1?=
=?UTF-8?q?=EF=BC=9ADotDict=20=E8=87=AA=E5=8A=A8=20fallback=20paragraph/fo?=
=?UTF-8?q?nt=20=E5=AD=90=E5=AD=97=E5=85=B8=20+=20DEFAULTS/YAML=20?=
=?UTF-8?q?=E5=88=86=E7=BB=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- DotDict.__getattr__: key 缺失时自动到 paragraph/font 子字典查找
- BASE_FORMAT 改为 {paragraph: {...}, font: {...}} 分组结构
- 所有 FormatNode DEFAULTS 改用 deep_merge(BASE_FORMAT, {...}) 覆盖
- YAML global_format 锚点改用 paragraph/font 分组
- 各节点平铺覆盖字段保持不变,框架透明处理查找
---
example/undergrad_thesis.yaml | 34 +++++------
src/wordformat/config/dotdict.py | 45 +++++++++------
src/wordformat/rules/abstract.py | 35 ++++++------
src/wordformat/rules/acknowledgement.py | 32 ++++++-----
src/wordformat/rules/body.py | 31 ++++------
src/wordformat/rules/caption.py | 46 ++++++++-------
src/wordformat/rules/heading.py | 55 ++++++++++--------
src/wordformat/rules/keywords.py | 75 ++++++++++++++-----------
src/wordformat/rules/references.py | 33 ++++++-----
9 files changed, 209 insertions(+), 177 deletions(-)
diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml
index b161869..f4135a6 100644
--- a/example/undergrad_thesis.yaml
+++ b/example/undergrad_thesis.yaml
@@ -59,22 +59,24 @@ style_checks_warning:
# 全局基础格式(其他节通过 <<: *global_format 继承后再覆写)
# ---------------------------------------------------------------------------
global_format: &global_format
- alignment: '两端对齐'
- space_before: "0行"
- space_after: "0行"
- line_spacingrule: "1.5倍行距"
- line_spacing: '1.5倍'
- left_indent: "0字符"
- right_indent: "0字符"
- first_line_indent: '2字符'
- builtin_style_name: '正文'
- chinese_font_name: '宋体'
- english_font_name: 'Times New Roman'
- font_size: '小四'
- font_color: '黑色'
- bold: false
- italic: false
- underline: false
+ paragraph:
+ alignment: '两端对齐'
+ space_before: "0行"
+ space_after: "0行"
+ line_spacingrule: "1.5倍行距"
+ line_spacing: '1.5倍'
+ left_indent: "0字符"
+ right_indent: "0字符"
+ first_line_indent: '2字符'
+ builtin_style_name: '正文'
+ font:
+ chinese_font_name: '宋体'
+ english_font_name: 'Times New Roman'
+ font_size: '小四'
+ font_color: '黑色'
+ bold: false
+ italic: false
+ underline: false
# ===========================================================================
# 摘要(中英文)
diff --git a/src/wordformat/config/dotdict.py b/src/wordformat/config/dotdict.py
index 5314f4b..06ece7c 100644
--- a/src/wordformat/config/dotdict.py
+++ b/src/wordformat/config/dotdict.py
@@ -12,7 +12,14 @@ def __getattr__(self, key: str):
try:
val = self[key]
except KeyError:
- return None
+ # 框架抽象:key 不在当前层时,自动到 paragraph / font 子字典中查找
+ for sub in ("paragraph", "font"):
+ sub_dict = self.get(sub)
+ if isinstance(sub_dict, dict) and key in sub_dict:
+ val = sub_dict[key]
+ break
+ else:
+ return None
if isinstance(val, dict):
return DotDict(val)
return val
@@ -29,22 +36,26 @@ def __delattr__(self, key: str) -> None:
# 全局格式默认值,所有节点以此为底
BASE_FORMAT: dict[str, object] = {
- "alignment": "左对齐",
- "space_before": "0.5行",
- "space_after": "0.5行",
- "line_spacingrule": "单倍行距",
- "line_spacing": "1.5倍",
- "left_indent": "0字符",
- "right_indent": "0字符",
- "first_line_indent": "2字符",
- "builtin_style_name": "正文",
- "chinese_font_name": "宋体",
- "english_font_name": "Times New Roman",
- "font_size": "小四",
- "font_color": "黑色",
- "bold": False,
- "italic": False,
- "underline": False,
+ "paragraph": {
+ "alignment": "左对齐",
+ "space_before": "0.5行",
+ "space_after": "0.5行",
+ "line_spacingrule": "单倍行距",
+ "line_spacing": "1.5倍",
+ "left_indent": "0字符",
+ "right_indent": "0字符",
+ "first_line_indent": "2字符",
+ "builtin_style_name": "正文",
+ },
+ "font": {
+ "chinese_font_name": "宋体",
+ "english_font_name": "Times New Roman",
+ "font_size": "小四",
+ "font_color": "黑色",
+ "bold": False,
+ "italic": False,
+ "underline": False,
+ },
}
diff --git a/src/wordformat/rules/abstract.py b/src/wordformat/rules/abstract.py
index 7361f63..626a151 100644
--- a/src/wordformat/rules/abstract.py
+++ b/src/wordformat/rules/abstract.py
@@ -4,7 +4,7 @@
# @File : abstract.py
import re
-from wordformat.config.dotdict import BASE_FORMAT
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
from wordformat.style.diff import CharacterStyle, ParagraphStyle
@@ -16,14 +16,13 @@ class AbstractTitleCN(FormatNode):
NODE_TYPE = "abstract.chinese.title"
NODE_LABEL = "中文摘要标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "alignment": "居中对齐",
- "first_line_indent": "0字符",
- "chinese_font_name": "黑体",
- "font_size": "小二",
- "bold": True,
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"alignment": "居中对齐", "first_line_indent": "0字符"},
+ "font": {"chinese_font_name": "黑体", "font_size": "小二", "bold": True},
+ },
+ )
@register("abstract_chinese_title_content", level=1)
@@ -112,7 +111,7 @@ class AbstractContentCN(FormatNode):
NODE_TYPE = "abstract.chinese.body"
NODE_LABEL = "中文摘要正文"
- DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"}
+ DEFAULTS = deep_merge(BASE_FORMAT, {"paragraph": {"alignment": "两端对齐"}})
def _base(self, doc, p: bool, r: bool):
cfg = self.pydantic_config
@@ -153,13 +152,13 @@ class AbstractTitleEN(FormatNode):
NODE_TYPE = "abstract.english.title"
NODE_LABEL = "英文摘要标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "alignment": "居中对齐",
- "first_line_indent": "0字符",
- "font_size": "四号",
- "bold": True,
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"alignment": "居中对齐", "first_line_indent": "0字符"},
+ "font": {"font_size": "四号", "bold": True},
+ },
+ )
@register("abstract_english_title_content", level=1)
@@ -275,7 +274,7 @@ class AbstractContentEN(FormatNode):
NODE_TYPE = "abstract.english.body"
NODE_LABEL = "英文摘要正文"
- DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"}
+ DEFAULTS = deep_merge(BASE_FORMAT, {"paragraph": {"alignment": "两端对齐"}})
def _base(self, doc, p: bool, r: bool):
cfg = self.pydantic_config
diff --git a/src/wordformat/rules/acknowledgement.py b/src/wordformat/rules/acknowledgement.py
index d557c7c..5001172 100644
--- a/src/wordformat/rules/acknowledgement.py
+++ b/src/wordformat/rules/acknowledgement.py
@@ -3,7 +3,7 @@
# @Author : afish
# @File : acknowledgement.py
-from wordformat.config.dotdict import BASE_FORMAT
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
@@ -14,14 +14,16 @@ class Acknowledgements(FormatNode):
NODE_TYPE = "acknowledgements.title"
NODE_LABEL = "致谢标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "alignment": "居中对齐",
- "first_line_indent": "0字符",
- "chinese_font_name": "黑体",
- "font_size": "小二",
- "bold": True,
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {
+ "alignment": "居中对齐",
+ "first_line_indent": "0字符",
+ },
+ "font": {"chinese_font_name": "黑体", "font_size": "小二", "bold": True},
+ },
+ )
@register("acknowledgements_content")
@@ -30,8 +32,10 @@ class AcknowledgementsCN(FormatNode):
NODE_TYPE = "acknowledgements.body"
NODE_LABEL = "致谢内容"
- DEFAULTS = {
- **BASE_FORMAT,
- "alignment": "两端对齐",
- "chinese_font_name": "宋体",
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"alignment": "两端对齐"},
+ "font": {"chinese_font_name": "宋体"},
+ },
+ )
diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py
index 8c0b377..88fca6c 100644
--- a/src/wordformat/rules/body.py
+++ b/src/wordformat/rules/body.py
@@ -9,6 +9,7 @@
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
@@ -92,25 +93,17 @@ class BodyText(FormatNode):
NODE_TYPE = "body.text"
NODE_LABEL = "正文段落"
- DEFAULTS = {
- "alignment": "两端对齐",
- "space_before": "0.5行",
- "space_after": "0.5行",
- "line_spacingrule": "单倍行距",
- "line_spacing": "1.5倍",
- "left_indent": "0字符",
- "right_indent": "0字符",
- "first_line_indent": "2字符",
- "builtin_style_name": "正文",
- "chinese_font_name": "宋体",
- "english_font_name": "Times New Roman",
- "font_size": "小四",
- "font_color": "黑色",
- "bold": False,
- "italic": False,
- "underline": False,
- "rules": {"punctuation": {"enabled": True}},
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"alignment": "两端对齐"},
+ "font": {
+ "chinese_font_name": "宋体",
+ "english_font_name": "Times New Roman",
+ },
+ "rules": {"punctuation": {"enabled": True}},
+ },
+ )
RULES = {"punctuation": "_check_punctuation"}
def _check_punctuation(self, doc, rule_cfg, p: bool = False):
diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py
index 7343432..7f5ac29 100644
--- a/src/wordformat/rules/caption.py
+++ b/src/wordformat/rules/caption.py
@@ -4,7 +4,7 @@
# @File : caption.py
-from wordformat.config.dotdict import BASE_FORMAT
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
from wordformat.utils import parse_caption_text
@@ -148,17 +148,19 @@ class CaptionFigure(FormatNode):
NODE_TYPE = "figures.caption"
NODE_LABEL = "图注"
- DEFAULTS = {
- **BASE_FORMAT,
- "caption_prefix": "图",
- "rules": {
- "caption_numbering": {
- "enabled": True,
- "separator": ".",
- "label_number_space": False,
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "caption_prefix": "图",
+ "rules": {
+ "caption_numbering": {
+ "enabled": True,
+ "separator": ".",
+ "label_number_space": False,
+ }
+ },
},
- }
+ )
RULES = {"caption_numbering": "_handle_caption_numbering"}
def _handle_caption_numbering(self, doc, rule_cfg, p: bool):
@@ -176,17 +178,19 @@ class CaptionTable(FormatNode):
NODE_TYPE = "tables.caption"
NODE_LABEL = "表注"
- DEFAULTS = {
- **BASE_FORMAT,
- "caption_prefix": "表",
- "rules": {
- "caption_numbering": {
- "enabled": True,
- "separator": ".",
- "label_number_space": False,
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "caption_prefix": "表",
+ "rules": {
+ "caption_numbering": {
+ "enabled": True,
+ "separator": ".",
+ "label_number_space": False,
+ }
+ },
},
- }
+ )
RULES = {"caption_numbering": "_handle_caption_numbering"}
def _handle_caption_numbering(self, doc, rule_cfg, p: bool):
diff --git a/src/wordformat/rules/heading.py b/src/wordformat/rules/heading.py
index 7ccf9a9..ef80283 100644
--- a/src/wordformat/rules/heading.py
+++ b/src/wordformat/rules/heading.py
@@ -3,7 +3,7 @@
# @Author : afish
# @File : heading.py
-from wordformat.config.dotdict import BASE_FORMAT
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
@@ -14,14 +14,17 @@ class HeadingLevel1Node(FormatNode):
NODE_TYPE = "headings.level_1"
NODE_LABEL = "一级标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "alignment": "居中对齐",
- "first_line_indent": "0字符",
- "chinese_font_name": "黑体",
- "font_size": "小二",
- "builtin_style_name": "Heading 1",
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {
+ "alignment": "居中对齐",
+ "first_line_indent": "0字符",
+ "builtin_style_name": "Heading 1",
+ },
+ "font": {"chinese_font_name": "黑体", "font_size": "小二"},
+ },
+ )
@register("heading_level_2", level=2)
@@ -30,13 +33,16 @@ class HeadingLevel2Node(FormatNode):
NODE_TYPE = "headings.level_2"
NODE_LABEL = "二级标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "first_line_indent": "0字符",
- "chinese_font_name": "黑体",
- "font_size": "三号",
- "builtin_style_name": "Heading 2",
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {
+ "first_line_indent": "0字符",
+ "builtin_style_name": "Heading 2",
+ },
+ "font": {"chinese_font_name": "黑体", "font_size": "三号"},
+ },
+ )
@register("heading_level_3", level=3)
@@ -45,10 +51,13 @@ class HeadingLevel3Node(FormatNode):
NODE_TYPE = "headings.level_3"
NODE_LABEL = "三级标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "first_line_indent": "0字符",
- "chinese_font_name": "黑体",
- "font_size": "小四",
- "builtin_style_name": "Heading 3",
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {
+ "first_line_indent": "0字符",
+ "builtin_style_name": "Heading 3",
+ },
+ "font": {"chinese_font_name": "黑体", "font_size": "小四"},
+ },
+ )
diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py
index d55d9e7..a46dfff 100644
--- a/src/wordformat/rules/keywords.py
+++ b/src/wordformat/rules/keywords.py
@@ -3,7 +3,7 @@
from docx.oxml.ns import qn
-from wordformat.config.dotdict import BASE_FORMAT
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
from wordformat.style.diff import CharacterStyle, ParagraphStyle
@@ -85,22 +85,25 @@ class KeywordsEN(BaseKeywordsNode):
NODE_TYPE = "abstract.english.keywords"
NODE_LABEL = "英文关键词"
- DEFAULTS = {
- **BASE_FORMAT,
- "first_line_indent": "0字符",
- "chinese_font_name": "宋体",
- "font_size": "小四",
- "label": {
- "chinese_font_name": "黑体",
- "english_font_name": "Times New Roman",
- "font_size": "三号",
- "font_color": "黑色",
- "bold": True,
- "italic": False,
- "underline": False,
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"first_line_indent": "0字符"},
+ "font": {"chinese_font_name": "宋体", "font_size": "小四"},
+ "label": {
+ "chinese_font_name": "黑体",
+ "english_font_name": "Times New Roman",
+ "font_size": "三号",
+ "font_color": "黑色",
+ "bold": True,
+ "italic": False,
+ "underline": False,
+ },
+ "rules": {
+ "keyword_count": {"enabled": True, "count_min": 3, "count_max": 5}
+ },
},
- "rules": {"keyword_count": {"enabled": True, "count_min": 3, "count_max": 5}},
- }
+ )
RULES = {"keyword_count": "_check_keyword_count"}
_LABEL_RE = re.compile(r"Keywords?:?\s*", re.IGNORECASE)
@@ -211,25 +214,29 @@ class KeywordsCN(BaseKeywordsNode):
NODE_TYPE = "abstract.chinese.keywords"
NODE_LABEL = "中文关键词"
- DEFAULTS = {
- **BASE_FORMAT,
- "first_line_indent": "0字符",
- "chinese_font_name": "宋体",
- "font_size": "小四",
- "label": {
- "chinese_font_name": "黑体",
- "english_font_name": "Times New Roman",
- "font_size": "三号",
- "font_color": "黑色",
- "bold": True,
- "italic": False,
- "underline": False,
- },
- "rules": {
- "keyword_count": {"enabled": True, "count_min": 3, "count_max": 5},
- "trailing_punctuation": {"enabled": True, "forbidden_chars": ";,。、"},
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"first_line_indent": "0字符"},
+ "font": {"chinese_font_name": "宋体", "font_size": "小四"},
+ "label": {
+ "chinese_font_name": "黑体",
+ "english_font_name": "Times New Roman",
+ "font_size": "三号",
+ "font_color": "黑色",
+ "bold": True,
+ "italic": False,
+ "underline": False,
+ },
+ "rules": {
+ "keyword_count": {"enabled": True, "count_min": 3, "count_max": 5},
+ "trailing_punctuation": {
+ "enabled": True,
+ "forbidden_chars": ";,。、",
+ },
+ },
},
- }
+ )
RULES = {
"keyword_count": "_check_keyword_count",
"trailing_punctuation": "_check_trailing_punctuation",
diff --git a/src/wordformat/rules/references.py b/src/wordformat/rules/references.py
index b66820b..dcd92b1 100644
--- a/src/wordformat/rules/references.py
+++ b/src/wordformat/rules/references.py
@@ -3,7 +3,7 @@
# @Author : afish
# @File : references.py
-from wordformat.config.dotdict import BASE_FORMAT
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
@@ -14,14 +14,16 @@ class References(FormatNode):
NODE_TYPE = "references.title"
NODE_LABEL = "参考文献标题"
- DEFAULTS = {
- **BASE_FORMAT,
- "alignment": "居中对齐",
- "first_line_indent": "0字符",
- "chinese_font_name": "黑体",
- "font_size": "三号",
- "bold": True,
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {
+ "alignment": "居中对齐",
+ "first_line_indent": "0字符",
+ },
+ "font": {"chinese_font_name": "黑体", "font_size": "三号", "bold": True},
+ },
+ )
@register("references_content")
@@ -30,9 +32,10 @@ class ReferenceEntry(FormatNode):
NODE_TYPE = "references.entry"
NODE_LABEL = "参考文献条目"
- DEFAULTS = {
- **BASE_FORMAT,
- "first_line_indent": "0字符",
- "chinese_font_name": "宋体",
- "font_size": "五号",
- }
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"first_line_indent": "0字符"},
+ "font": {"chinese_font_name": "宋体", "font_size": "五号"},
+ },
+ )
From e16711731a9e45db97944d482db61c86158f2702 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 23:03:34 +0800
Subject: [PATCH 05/12] =?UTF-8?q?=E5=AE=8C=E5=96=84=20md=E2=86=92docx=20?=
=?UTF-8?q?=E8=BD=AC=E6=8D=A2=EF=BC=9A=E5=9B=BE=E7=89=87/=E8=A1=A8?=
=?UTF-8?q?=E6=A0=BC/=E9=A2=98=E6=B3=A8/=E7=BC=96=E5=8F=B7/=E6=8D=A2?=
=?UTF-8?q?=E8=A1=8C=E7=AC=A6/=E5=86=85=E8=81=94=E4=BB=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 图片:FigureImage._try_insert_image 从 value 路径插入实际图片,图在题注上
- 表格:TableObject._try_insert_table 创建 Word 表格,通过 addnext 定位到段落后
- 题注: 自动拆为 figure_image + caption_figure 节点
- 代码块:按行拆为独立 body_text 段落,每行一个节点
- 换行符:parser 层统一 \r\n→\n,apply 时自动清除
- codespan:_extract_text 补充处理内联代码 `` `xxx` ``
- NodeConfigRoot 加 paragraph/font fallback,编号颜色/字体跟随标题样式
---
pyproject.toml | 1 +
src/wordformat/config/models.py | 9 ++-
src/wordformat/markdown/parser.py | 89 ++++++++++++++++++++--------
src/wordformat/pipeline/stages_md.py | 10 +++-
src/wordformat/rules/node.py | 11 ++++
src/wordformat/rules/object.py | 63 +++++++++++++++++++-
6 files changed, 153 insertions(+), 30 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index b775006..b7d94ec 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -101,6 +101,7 @@ max-complexity = 10
"cli.py" = ["C901"]
"**/body.py" = ["C901"]
"**/pipeline/stages.py" = ["C901"]
+"**/markdown/parser.py" = ["C901"]
"**/utils/_text.py" = ["C901"]
[tool.ruff.format]
diff --git a/src/wordformat/config/models.py b/src/wordformat/config/models.py
index d543fd4..d3a164e 100644
--- a/src/wordformat/config/models.py
+++ b/src/wordformat/config/models.py
@@ -23,7 +23,14 @@ def __getattr__(self, key: str):
try:
val = self[key]
except KeyError:
- return None
+ # fallback:到 paragraph / font 子字典中查找
+ for sub in ("paragraph", "font"):
+ sub_dict = self.get(sub)
+ if isinstance(sub_dict, dict) and key in sub_dict:
+ val = sub_dict[key]
+ break
+ else:
+ return None
if isinstance(val, dict) and not isinstance(val, NodeConfigRoot):
return NodeConfigRoot(**val)
return val
diff --git a/src/wordformat/markdown/parser.py b/src/wordformat/markdown/parser.py
index 524956a..be3ce98 100644
--- a/src/wordformat/markdown/parser.py
+++ b/src/wordformat/markdown/parser.py
@@ -47,12 +47,14 @@ def _walk_blocks(blocks: list[dict], result: list[dict]) -> None:
result.append(_make_heading(block))
elif btype in ("paragraph", "block_text"):
item = _make_paragraph(block)
- if item:
+ if isinstance(item, list):
+ result.extend(item)
+ elif item:
result.append(item)
elif btype == "table":
result.append(_make_table(block))
elif btype == "block_code":
- result.append(_make_code_block(block))
+ result.extend(_make_code_block(block))
elif btype == "list":
_walk_list(block, result)
elif btype == "block_quote":
@@ -72,18 +74,34 @@ def _make_heading(block: dict) -> dict:
}
-def _make_paragraph(block: dict) -> dict | None:
+def _make_paragraph(block: dict) -> dict | list[dict] | None:
children = block.get("children", [])
- # 仅含图片的段落 → figure_image
+ # 仅含图片的段落
non_empty = [c for c in children if c["type"] != "softbreak"]
if len(non_empty) == 1 and non_empty[0]["type"] == "image":
- return {
- "category": "figure_image",
- "paragraph": non_empty[0]["attrs"].get("url", ""),
- "score": 1.0,
- "inline_marks": [],
- }
+ img_node = non_empty[0]
+ url = img_node["attrs"].get("url", "")
+ alt_text = _extract_text(img_node.get("children", [])).strip()
+ # 图片在前,题注在后(图题在下)
+ result: list[dict] = [
+ {
+ "category": "figure_image",
+ "paragraph": url,
+ "score": 1.0,
+ "inline_marks": [],
+ }
+ ]
+ if alt_text:
+ result.append(
+ {
+ "category": "caption_figure",
+ "paragraph": alt_text,
+ "score": 1.0,
+ "inline_marks": [],
+ }
+ )
+ return result
text = _extract_text(children)
if not text.strip():
@@ -98,32 +116,50 @@ def _make_paragraph(block: dict) -> dict | None:
def _make_table(block: dict) -> dict:
- rows: list[str] = []
+ rows: list[list[str]] = []
for child in block.get("children", []):
- if child["type"] in ("table_head", "table_body"):
+ if child["type"] == "table_head":
+ # table_head: cells are direct children (no row wrapper)
+ cells = [
+ _extract_text(cell.get("children", []))
+ for cell in child.get("children", [])
+ ]
+ rows.append(cells)
+ elif child["type"] == "table_body":
+ # table_body: children are table_row elements
for row in child.get("children", []):
cells = [
_extract_text(cell.get("children", []))
for cell in row.get("children", [])
]
- rows.append(" | ".join(cells))
- text = "\n".join(rows)
+ rows.append(cells)
return {
- "category": "body_text",
- "paragraph": text,
+ "category": "table_object",
+ "paragraph": "",
"score": 1.0,
"inline_marks": [],
+ "table_rows": rows,
}
-def _make_code_block(block: dict) -> dict:
+def _make_code_block(block: dict) -> list[dict]:
+ """代码块按行拆成多个段落,每行独立为一个 body_text 节点。"""
text = block.get("raw", "")
- return {
- "category": "body_text",
- "paragraph": text,
- "score": 1.0,
- "inline_marks": [{"text": text, "code": True}],
- }
+ lines = text.split("\n")
+ result: list[dict] = []
+ for line in lines:
+ line = line.strip("\r")
+ if not line.strip():
+ continue
+ result.append(
+ {
+ "category": "body_text",
+ "paragraph": line,
+ "score": 1.0,
+ "inline_marks": [{"text": line, "code": True}],
+ }
+ )
+ return result
def _walk_list(block: dict, result: list[dict]) -> None:
@@ -140,7 +176,9 @@ def _extract_text(children: list[dict]) -> str:
def walk(nodes):
for node in nodes:
if node["type"] == "text":
- parts.append(node["raw"])
+ parts.append(node["raw"].replace("\r\n", "\n").replace("\r", "\n"))
+ elif node["type"] == "codespan":
+ parts.append(node.get("raw", ""))
elif node["type"] == "softbreak":
parts.append(" ")
elif node["type"] == "linebreak":
@@ -161,7 +199,8 @@ def walk(nodes, attrs): # noqa: C901
ntype = node["type"]
if ntype == "text":
if node["raw"]:
- segments.append({"text": node["raw"], **attrs})
+ text = node["raw"].replace("\r\n", "\n").replace("\r", "\n")
+ segments.append({"text": text, **attrs})
elif ntype in ("strong", "emphasis", "strikethrough"):
extra = {
"strong": "bold",
diff --git a/src/wordformat/pipeline/stages_md.py b/src/wordformat/pipeline/stages_md.py
index 4fbcafd..1207edc 100644
--- a/src/wordformat/pipeline/stages_md.py
+++ b/src/wordformat/pipeline/stages_md.py
@@ -6,6 +6,7 @@
Markdown 仅提供文本内容和文档结构(标题层级、段落划分),
所有格式(字体、字号、段落样式)由 YAML 配置 + FormatNode 统一处理。
+图片插入、表格创建等由对应 FormatNode 子类负责。
"""
from pathlib import Path
@@ -44,7 +45,7 @@ class DocumentCreationStage:
"""从 FormatNode 树创建新的 .docx Document。
每个节点创建一个段落,单 run 填充纯文本。
- 格式由后续的 FormattingExecutionStage 统一应用。
+ 格式、图片、表格等由后续 FormattingExecutionStage 中各 FormatNode 子类处理。
"""
def process(self, ctx: FormatContext) -> FormatContext:
@@ -55,8 +56,13 @@ def process(self, ctx: FormatContext) -> FormatContext:
for node in nodes:
value = node.value
text = value.get("paragraph", "") if isinstance(value, dict) else str(value)
+ category = value.get("category", "") if isinstance(value, dict) else ""
para = ctx.document.add_paragraph()
- para.add_run(text.strip())
+ # figure_image: 路径留给 FigureImage._try_insert_image 处理,这里只建空段落
+ if category == "figure_image":
+ para.add_run("")
+ else:
+ para.add_run(text.strip())
node.paragraph = para
return ctx
diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py
index af86cd7..9ed6179 100644
--- a/src/wordformat/rules/node.py
+++ b/src/wordformat/rules/node.py
@@ -362,6 +362,14 @@ def apply_replace(self, doc: Document = None) -> bool:
logger.debug(f"已替换段落文本 → {replace_text[:50]}...")
return True
+ def _remove_manual_line_breaks(self) -> None:
+ """移除段落内所有手动换行符 (Shift+Enter)。"""
+ from docx.oxml.ns import qn
+
+ for r_elem in self.paragraph._element.findall(qn("w:r")):
+ for br in r_elem.findall(qn("w:br")):
+ r_elem.remove(br)
+
def _clean_paragraph_edge_spaces(self) -> None:
"""清理段落首尾 run 中的多余空格。
@@ -375,6 +383,9 @@ def _clean_paragraph_edge_spaces(self) -> None:
if not runs:
return
+ # 移除段落内的手动换行符(Shift+Enter → )
+ self._remove_manual_line_breaks()
+
# 清理第一个非空 run 的开头空格
for run in runs:
if run.text:
diff --git a/src/wordformat/rules/object.py b/src/wordformat/rules/object.py
index 5a2dd39..efb2d4c 100644
--- a/src/wordformat/rules/object.py
+++ b/src/wordformat/rules/object.py
@@ -1,5 +1,9 @@
"""图片段落和表格对象节点。"""
+from pathlib import Path
+
+from docx.shared import Inches
+
from wordformat.rules.node import FormatNode
from wordformat.structure.registry import register
from wordformat.style.comments import format_comment
@@ -10,6 +14,7 @@ class FigureImage(FormatNode):
"""图片段落节点(包含内联图片的段落,非题注)。
只检查对齐和首行缩进,不检查行距、字体等。
+ md→docx 时会尝试从 value 中的路径插入实际图片。
"""
NODE_TYPE = "figures.image"
@@ -22,6 +27,8 @@ def _base(self, doc, p: bool, r: bool):
from wordformat.style.defs import Alignment, FirstLineIndent
from wordformat.style.diff import _format_para_value
+ self._try_insert_image()
+
cfg = self.pydantic_config
expected_align = Alignment(str(cfg.alignment or "居中对齐"))
actual_align = expected_align.get_from_paragraph(self.paragraph)
@@ -51,12 +58,64 @@ def _base(self, doc, p: bool, r: bool):
),
)
+ def _try_insert_image(self) -> None:
+ """如果段落为空且 value 中有图片路径,尝试插入图片。"""
+ if self.paragraph is None:
+ return
+ # 已有非空 run 时跳过(图片已存在或来自 docx 而非 md)
+ for run in self.paragraph.runs:
+ if run.text and run.text.strip():
+ return
+ value = self.value
+ if not isinstance(value, dict):
+ return
+ path = value.get("paragraph", "").strip()
+ if not path:
+ return
+ img_path = Path(path)
+ if not img_path.exists():
+ return
+ try:
+ # 清空占位 run,插入图片
+ for run in self.paragraph.runs:
+ run.text = ""
+ if self.paragraph.runs:
+ self.paragraph.runs[0].add_picture(str(img_path), width=Inches(5.5))
+ else:
+ run = self.paragraph.add_run()
+ run.add_picture(str(img_path), width=Inches(5.5))
+ except Exception:
+ pass # 图片格式不支持等,保留占位文本
+
@register("table_object")
class TableObject(FormatNode):
- """表格对象节点(表格整体格式,非题注)。"""
+ """表格对象节点(表格整体格式,非题注)。
+
+ md→docx 时会从 value.table_rows 创建 Word 表格。
+ """
NODE_TYPE = "tables.object"
NODE_LABEL = "表格对象"
DEFAULTS = {}
- DEFAULT_RULES = {} # 表格对象格式由 Word 表格属性控制
+ DEFAULT_RULES = {}
+
+ def _base(self, doc, p: bool, r: bool):
+ self._try_insert_table(doc)
+
+ def _try_insert_table(self, doc) -> None:
+ """如果 value 中有 table_rows,在段落位置后插入 Word 表格。"""
+ value = self.value
+ if not isinstance(value, dict):
+ return
+ rows = value.get("table_rows", [])
+ if not rows:
+ return
+ num_cols = max(len(r) for r in rows) if rows else 1
+ table = doc.add_table(rows=len(rows), cols=num_cols, style="Table Grid")
+ for i, row_data in enumerate(rows):
+ for j, cell_text in enumerate(row_data):
+ table.cell(i, j).text = cell_text
+ # 将表格从文档末尾移到当前段落后
+ if self.paragraph is not None:
+ self.paragraph._element.addnext(table._element)
From dce78636a9a353b3b94e0c454d15cc4f1375d205 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 23:05:21 +0800
Subject: [PATCH 06/12] =?UTF-8?q?=E5=AE=8C=E5=96=84=20md=E2=86=92docx=20?=
=?UTF-8?q?=E8=BD=AC=E6=8D=A2=EF=BC=9A=E5=9B=BE=E7=89=87/=E8=A1=A8?=
=?UTF-8?q?=E6=A0=BC/=E9=A2=98=E6=B3=A8/=E7=BC=96=E5=8F=B7/=E6=8D=A2?=
=?UTF-8?q?=E8=A1=8C=E7=AC=A6/=E5=86=85=E8=81=94=E4=BB=A3=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 图片:FigureImage._try_insert_image 从 value 路径插入实际图片,图在题注上
- 表格:TableObject._try_insert_table 创建 Word 表格,通过 addnext 定位到段落后
- 题注: 自动拆为 figure_image + caption_figure 节点
- 代码块:按行拆为独立 body_text 段落,每行一个节点
- 换行符:parser 层统一 \r\n→\n,apply 时自动清除
- codespan:_extract_text 补充处理内联代码 `` `xxx` ``
- NodeConfigRoot 加 paragraph/font fallback,编号颜色/字体跟随标题样式
---
pyproject.toml | 2 +-
src/wordformat/_version.py | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/pyproject.toml b/pyproject.toml
index b7d94ec..dbe3518 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "wordformat"
-version = "1.4.0"
+version = "1.5.0"
description = "论文格式自动化处理工具"
readme = "README.md"
license = "Apache-2.0"
diff --git a/src/wordformat/_version.py b/src/wordformat/_version.py
index b3d780d..fd547ba 100644
--- a/src/wordformat/_version.py
+++ b/src/wordformat/_version.py
@@ -1,3 +1,3 @@
# 此文件由 scripts/sync_version.py 自动生成,请勿手动编辑。
# 版本号唯一来源为 pyproject.toml。
-__version__ = "1.4.0"
+__version__ = "1.5.0"
From 05f64865f153f8d0df6822b6f3e499714126a07d Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 23:11:38 +0800
Subject: [PATCH 07/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=20wordformat-skill=20?=
=?UTF-8?q?=E6=96=87=E6=A1=A3=20+=20=E7=B2=BE=E7=AE=80=20README=20AI=20Ski?=
=?UTF-8?q?ll=20=E6=AE=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- SKILL.md: 去掉已删除脚本引用,新增 wordf md 命令,精简为三个流程
- config_spec.md: 更新到新配置路径和 paragraph/font 分组,移除死字段
- category_reference.md: 从 155 行精简到 60 行
- README: 移除 AI Skill 集成段(体验差,浪费 token),保留预设配置说明
---
README.md | 34 +--
wordformat-skill/SKILL.md | 234 +++-------------
wordformat-skill/data/category_reference.md | 189 ++++---------
wordformat-skill/data/config_spec.md | 285 ++++++++------------
4 files changed, 197 insertions(+), 545 deletions(-)
diff --git a/README.md b/README.md
index bdb44c1..0349c2c 100644
--- a/README.md
+++ b/README.md
@@ -147,39 +147,9 @@ python -m wordformat startapi
更多详细用法请查看 [使用指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/usage.md)
-## AI Skill 集成
+## 预设配置
-WordFormat 提供了 **SOLO Skill**,可在 SOLO 等 AI 助手平台中直接调用,实现对话式论文格式化。
-
-### Skill 工作流程
-
-Skill 包含两个独立任务,可分步执行:
-
-| 任务 | 说明 | 产物 |
-|------|------|------|
-| **任务一:准备配置文件** | 根据格式要求生成/编辑 config.yaml | `config.yaml` |
-| **任务二:执行格式化** | 使用配置文件对论文进行格式检查或修正 | `--标注版.docx` 或 `--修改版.docx` |
-
-### Skill 目录结构
-
-```
-wordformat-skill/
-├── SKILL.md # Skill 定义文件
-├── scripts/
-│ ├── setup_config.py # 配置文件生成/验证脚本
-│ ├── validate_json.py # JSON 标签校验脚本
-│ └── validate_config.py # 配置文件验证脚本
-└── data/
- ├── config.yaml # 默认配置模板
- ├── config_spec.md # 配置文件完整字段规范
- ├── config_editing_guide.md # 配置编辑指南
- ├── category_reference.md # 段落分类参考
- └── font_size_table.md # 字号对照表
-```
-
-### 预设配置库
-
-项目内置了多所高校的论文格式预设,保存在 `presets/` 目录下,命名格式为 `{学校}_{学院/专业}_{论文类型}.yaml`,可直接使用或在此基础上修改。
+项目内置了多所高校的论文格式预设,保存在 `presets/` 目录下,可直接使用或在此基础上修改。
## 详细文档
diff --git a/wordformat-skill/SKILL.md b/wordformat-skill/SKILL.md
index 0cd7c50..7be847c 100644
--- a/wordformat-skill/SKILL.md
+++ b/wordformat-skill/SKILL.md
@@ -1,230 +1,82 @@
---
name: wordformat
-description: 论文格式自动化处理工具。在处理 Word 论文文档格式校验、格式修正、文档结构识别场景时激活,具备使用 AI 模型智能识别文档结构并根据 YAML 配置文件自动校验或修正论文格式的专业能力。
-argument-hint: "[docx文件路径]"
+description: 论文格式自动化处理工具。在处理 Word 论文文档格式校验、格式修正、文档结构识别、Markdown 转 Word 场景时激活。
+argument-hint: "[文件路径]"
---
-# WordFormat - 论文格式自动化处理工具
+# WordFormat
## 安装
```bash
-pip install wordformat -i https://pypi.tuna.tsinghua.edu.cn/simple # 国内
-pip install wordformat # 海外
+pip install wordformat
```
验证:`wordf --help`
-## 功能速查
+## 命令速查
-### CLI 命令
-
-| 命令 | 功能 | 输入 | 输出 |
-|------|------|------|------|
-| `wordf config` | 查看所有可配置字段及默认值 | 无 | 终端输出 |
-| `wordf config -o config.yaml` | 输出完整配置模板 | 无 | `config.yaml` |
-| `wordf gj` | AI 识别文档结构 | `.docx` + `config.yaml` | `output/xxx.json` |
-| `wordf tree` | 查看结构树 | `.json` | 终端树形图 |
-| `wordf cf` | 检查格式(批注) | `.docx` + `config.yaml` + `.json` | `--标注版.docx` |
-| `wordf af` | 修正格式(直接改) | `.docx` + `config.yaml` + `.json` | `--修改版.docx` |
-| `wordf startapi` | Web 界面 | 无 | http://127.0.0.1:8000 |
-
-### 辅助脚本
-
-| 脚本 | 功能 |
+| 命令 | 功能 |
|------|------|
-| `setup_config.py` | 验证/保存 YAML 配置 |
-| `validate_json.py` | 校验 JSON category,显示统计 |
-
-### JSON 字段(`wordf gj` 输出,可手动编辑)
-
-| 字段 | 说明 |
-|------|------|
-| `category` | 段落类型(16种),改这里修正分类 |
-| `replace` | 可选,填入新文本直接替换段落内容 |
-| `fingerprint` | 段落指纹(不要改) |
-| `score` | AI 置信度 |
-
-### 格式检查范围
-
-段落(对齐/间距/行距/缩进)、字符(字体/字号/颜色/粗斜体/下划线)、标题自动编号、题注编号校验、关键词数量、表格内容。
-
-### 不支持
-
-页眉页脚、目录生成、封面排版、图片尺寸、Word 域代码。需提前告知用户手动处理。
-
----
+| `wordf md -d 论文.md -c config.yaml` | Markdown → 格式化 .docx |
+| `wordf gj -d 论文.docx -c config.yaml` | AI 识别文档结构,输出 JSON |
+| `wordf tree -f output/xxx.json` | 查看文档结构树 |
+| `wordf cf -d 论文.docx -c config.yaml -f output/xxx.json` | 检查格式(加批注) |
+| `wordf af -d 论文.docx -c config.yaml -f output/xxx.json` | 修正格式(直接改) |
+| `wordf config -o config.yaml` | 输出配置模板 |
+| `wordf startapi` | 启动 Web 界面 |
## 工作流程
-根据用户意图走不同路径:
-
-- **仅文本替换**:只想修正论文中某些段落的文字内容(如错别字、措辞),不检查格式 → [纯文本替换流程](#纯文本替换流程)
-- **格式校验/修正**:检查或修正论文格式 → 下面两个独立任务,先任务一再任务二
-
-每个任务完成后**必须将产物复制到用户工作目录**。
-
----
-
-## 纯文本替换流程
-
-> 用户只想换文字(错别字、措辞修正),不需要格式批注/编号/样式修改。
-
-1. 生成 JSON:`wordf gj -d 论文.docx -c config.yaml`
-2. 编辑 JSON:在需要替换的段落对象中添加 `"replace": "新的正确文本"`
-3. 执行替换:`wordf af -d 论文.docx -c config.yaml -f output/论文_xxx.json`
-4. 交付:`cp output/论文--修改版.docx <用户工作目录>/`
+根据用户意图选择路径:
-> `wordf af` 执行替换的同时也会应用格式修正。如果 config.yaml 格式规范与论文当前格式一致,则仅文本被替换、格式不变。**用户说"只换文字"时走此流程,不要追加 `cf` 检查或格式批注操作。**
+### 纯文本替换
----
-
-## 格式校验/修正流程
-
----
-
-### 任务一:准备配置文件
-
-> **🚨 用户已提供 .yaml 配置 → 直接跳到步骤 1.2 验证,禁止新建。**
-> **🚨 用户说"用上次的配置"/"和上次一样" → 复用已有配置,禁止新建。**
-
-### 步骤 1.0 查找已有配置
-
-**优先级:用户提供的 > 工作目录已有 > 预设库 > 新建**
+只换文字不查格式。生成 JSON → 编辑 `replace` 字段 → 执行替换。
```bash
-# 扫描工作目录下所有 .yaml 文件
-ls *.yaml 2>/dev/null || echo "无 .yaml 文件"
+wordf gj -d 论文.docx -c config.yaml
+# 编辑 output/xxx.json,添加 "replace": "正确文本"
+wordf af -d 论文.docx -c config.yaml -f output/xxx.json
```
-找到文件后逐个验证:
+### Markdown 转 Word
```bash
-for f in *.yaml; do
- echo "--- 验证: $f ---"
- python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --validate --config "$f" 2>&1 \
- && echo "✅ 合法" || echo "❌ 非配置文件"
-done
+wordf md -d 论文.md -c config.yaml
+# 输出 output/论文--生成版.docx
```
-找到合法配置 → **询问用户确认**,不要新建或覆盖。用户确认后跳到步骤 1.2。
-
-没有 .yaml 文件时尝试预设库:
+### 格式校验/修正
```bash
-python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --list-presets
-python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --use "清华大学_计算机学院_本科" --output config.yaml
-```
-
-预设也找不到时,继续步骤 1.1 新建。
+# 1. 准备配置(已有则跳过)
+wordf config -o config.yaml
+# 编辑 config.yaml 适配学校要求
-### 步骤 1.1 新建配置
+# 2. 生成结构
+wordf gj -d 论文.docx -c config.yaml
-> 运行 `wordf config` 查看所有字段和默认值,`wordf config -o config.yaml` 生成模板。
+# 3. 检查结构(可选)
+wordf tree -f output/xxx.json --confidence
-**先完整读完格式要求文件,再编辑。** 编辑原则:只改值、不增减字段、不动 YAML 锚点语法。
-
-### 步骤 1.2 验证
-
-```bash
-python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --validate --config config.yaml
-```
-
-失败则根据提示修正,直到通过。
-
-### 步骤 1.3 保存预设(首次生成时可选)
-
-```bash
-python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --save --config config.yaml --name "XX大学_XX学院_本科"
+# 4. 格式化
+wordf cf -d 论文.docx -c config.yaml -f output/xxx.json # 检查
+wordf af -d 论文.docx -c config.yaml -f output/xxx.json # 修正
```
-### 步骤 1.4 交付
+## JSON 字段
-```bash
-cp config.yaml <用户工作目录>/
-```
-
-询问用户确认配置,提醒页眉页脚、目录等无法自动处理。**任务一完成。**
-
----
-
-### 任务二:执行格式化
-
-> 前提:有 `config.yaml` 和 `.docx` 论文。
-
-### 步骤 2.1 生成 JSON
-
-```bash
-wordf gj -d $ARGUMENTS -c config.yaml
-```
-
-记住输出的 JSON 路径(`output/论文_xxx.json`)。
-
-### 步骤 2.2 检查结构
-
-```bash
-wordf tree -f
-wordf tree -f --confidence # 看低置信度段落
-```
-
-### 步骤 2.3 校验并修正 JSON
-
-> 先读 [data/category_reference.md](data/category_reference.md)。
-
-```bash
-python ${CLAUDE_SKILL_DIR}/scripts/validate_json.py --json --stats --show-all --threshold 0.8
-```
-
-**常见修正**:
-
-| 误识别 | 改为 |
-|--------|------|
-| "摘要" → `heading_level_1` | `abstract_chinese_title` |
-| "关键词:..." → `body_text` | `keywords_chinese` |
-| "参考文献" → `heading_level_1` | `references_title` |
-| 摘要标题+正文同一段 | `abstract_chinese_title_content` |
-| 目录/封面/页眉页脚 | `other`(跳过格式化) |
-
-**⚠️ 不修改的 body_text**:摘要标题后的正文(自动升级为摘要正文)、参考文献标题后的条目(自动升级为参考文献条目)。
-
-**用 `replace` 修正文本**:添加 `"replace": "正确文本"`,格式化时自动替换。
-
-修正后重新验证确认无误。
-
-### 步骤 2.4 格式化
-
-```bash
-wordf cf -d 论文.docx -c config.yaml -f # 检查(批注,不改原文)
-wordf af -d 论文.docx -c config.yaml -f # 修正(直接改)
-```
-
-`numbering.enabled: true` 时,`af` 模式会自动清除手动编号并应用 Word 自动编号。
-
-### 步骤 2.5 交付
-
-```bash
-cp output/论文--标注版.docx <用户工作目录>/ # cf 模式
-cp output/论文--修改版.docx <用户工作目录>/ # af 模式
-```
-
-询问用户检查结果,提醒手动补充。**任务二完成。**
-
----
-
-## Python 调用
+| 字段 | 说明 |
+|------|------|
+| `category` | 段落类型,改这里修正分类 |
+| `replace` | 填入新文本替换段落内容 |
+| `score` | AI 置信度 |
-```python
-from wordformat.set_tag import set_tag_main
-from wordformat.set_style import auto_format_thesis_document
+## 格式检查范围
-data = set_tag_main(docx_path="论文.docx", configpath="config.yaml")
-auto_format_thesis_document(jsonpath=data, docxpath="论文.docx",
- configpath="config.yaml", savepath="output/", check=True)
-```
+段落格式(对齐/间距/行距/缩进)、字符格式(字体/字号/颜色/粗斜体/下划线)、标题自动编号、题注编号、关键词数量、标点符号。
-## 注意事项
+## 不支持
-- 仅 `.docx`,Python ≥ 3.10
-- AI 分类非 100% 准确,**务必检查 JSON**
-- 产物必须复制到用户工作目录
-- 每个任务完成后询问用户确认
+页眉页脚、目录生成、封面排版。需提前告知用户手动处理。
diff --git a/wordformat-skill/data/category_reference.md b/wordformat-skill/data/category_reference.md
index 1372d36..c1108c1 100644
--- a/wordformat-skill/data/category_reference.md
+++ b/wordformat-skill/data/category_reference.md
@@ -1,154 +1,55 @@
# Category 类型参考
-WordFormat AI 模型识别文档结构后,会为每个段落分配一个 `category` 类型。以下是模型实际输出的所有 category 类型及其精确判定规则。
+AI 模型为每个段落分配的 category 类型。手动编辑 JSON 时修改此字段即可纠正分类。
+
+## 完整列表
+
+| Category | 判定规则 |
+|----------|----------|
+| `heading_level_1` | "第X章"或数字开头的标题 |
+| `heading_level_2` | "X.Y" 格式的二级标题 |
+| `heading_level_3` | "X.Y.Z" 格式的三级标题 |
+| `heading_mulu` | "目录" 或 "目 录" |
+| `heading_fulu` | "附录" |
+| `abstract_chinese_title` | "摘要" |
+| `abstract_chinese_title_content` | 摘要标题和正文在同一段落 |
+| `abstract_english_title` | "Abstract" |
+| `abstract_english_title_content` | 英文摘要标题和正文在同一段落 |
+| `keywords_chinese` | 含"关键词"或"关键字" |
+| `keywords_english` | 含"Keywords" |
+| `body_text` | 普通正文段落 |
+| `caption_figure` | "图 X.Y" 开头的图注 |
+| `caption_table` | "表 X.Y" 开头的表注 |
+| `references_title` | "参考文献" 或 "References" |
+| `acknowledgements_title` | "致谢" 或 "Acknowledgements" |
+
+## 常见误判修正
+
+| 误识别 | 应改为 |
+|--------|--------|
+| "摘要" → `heading_level_1` | `abstract_chinese_title` |
+| "关键词:..." → `body_text` | `keywords_chinese` |
+| "参考文献" → `heading_level_1` | `references_title` |
+| 目录/封面内容 | `other`(跳过格式化) |
+
+## 自动提升机制
+
+以下情况**不需要手动修改**,代码会自动处理:
+
+| 父节点 | 其下 body_text 自动升级为 |
+|--------|--------------------------|
+| `abstract_chinese_title` | `abstract_chinese_content` |
+| `abstract_english_title` | `abstract_english_content` |
+| `references_title` | `reference_entry` |
+
+## replace 字段
+
+在 JSON 条目中添加 `"replace": "新文本"` 可在格式化时替换段落内容。
-## 完整 Category 列表(共 16 个)
-
-### 标题类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `heading_level_1` | 段落必须以"第X章"或单个阿拉伯数字(如"1""2")开头,后接空格和标题文字;仅为名词短语 | "1 绪论"、"3 系统设计" |
-| `heading_level_2` | 段落必须以"X.Y"格式开头(如"1.1""2.3"),且**仅包含一个"."**;后接标题文字,无完整句子 | "1.1 研究背景"、"2.4 实验设置" |
-| `heading_level_3` | 段落必须以"X.Y.Z"格式开头(如"1.1.1""3.2.5"),且**包含恰好两个"."**;后接标题文字,无论长短,只要无句子结构即视为三级标题 | "1.1.1 研究背景"、"2.3.4 性能对比" |
-| `heading_mulu` | 段落等于"目录"或"目 录"(含空格变体) | "目录"、"目 录" |
-| `heading_fulu` | 段落等于"附录" | "附录" |
-
-### 摘要类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `abstract_chinese_title` | 仅当段落是"摘要"或"摘 要"(允许尾随空格或冒号) | "摘要"、"摘要:" |
-| `abstract_chinese_title_content` | 当且仅当摘要标题和摘要正文合并在同一个段落中 | "摘要 本文围绕校园二手物品交易平台..." |
-| `abstract_english_title` | 仅当段落是"Abstract"(大小写不敏感,允许尾随空格或冒号) | "Abstract"、"Abstract: " |
-| `abstract_english_title_content` | 当且仅当英文摘要标题和摘要正文合并在同一个段落中 | "Abstract This paper focuses on the design..." |
-
-### 关键词类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `keywords_chinese` | 包含"关键词"或"关键字",后面跟着术语列表 | "关键词:校园交易;二手物品;Django" |
-| `keywords_english` | 包含"Keywords"(大小写不敏感),后面跟着英文术语 | "Keywords: Campus trading; Second-hand..." |
-
-### 正文类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `body_text` | 仅当段落包含句子(有谓语动词、句号、逻辑连接词)、是摘要等段落或明确论述(如"本章""本文""包括""例如""若...则...")时才归为此类 | "随着互联网技术的快速发展..." |
-
-### 图表类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `caption_figure` | 以"图 X.Y"或"Figure X.Y"开头的图注 | "图2.1 系统架构图" |
-| `caption_table` | 以"表 X.Y"或"Table X.Y"开头的表注 | "表3.1 用户表结构" |
-
-### 参考文献类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `references_title` | 段落等于"参考文献"或"References" | "参考文献"、"References" |
-
-### 致谢类
-
-| Category | 判定规则 | 典型内容示例 |
-|----------|----------|-------------|
-| `acknowledgements_title` | 段落和"致谢"或"Acknowledgements"等词意思相近 | "致谢"、"Acknowledgements" |
-
-## 检查要点
-
-在第三步检查 JSON 文件时,重点注意以下容易误判的情况:
-
-1. **摘要标题 vs 一级标题**:"摘要" 可能被误识别为 `heading_level_1`,应为 `abstract_chinese_title`
-2. **关键词 vs 正文**:关键词行可能被误识别为 `body_text`,应为 `keywords_chinese`
-3. **参考文献标题 vs 一级标题**:"参考文献" 可能被误识别为 `heading_level_1`,应为 `references_title`
-4. **图题注 vs 表题注**:注意区分 `caption_figure` 和 `caption_table`
-5. **致谢标题 vs 一级标题**:"致谢" 可能被误识别为 `heading_level_1`,应为 `acknowledgements_title`
-6. **摘要标题+内容合并**:如果摘要标题和正文在同一段落,应为 `abstract_chinese_title_content` 或 `abstract_english_title_content`,而非单独的标题或正文
-7. **目录等非内容段落**:目录页、页眉页脚、封面信息等不属于任何格式化类别,应将 category 改为 `"other"`,代码会自动跳过这些段落
-
-## ⚠️ 重要:非内容段落标记为 `"other"`
-
-论文中有些段落不属于任何格式化类别(如目录、页眉页脚、封面信息等),模型会将它们误识别为 `body_text` 或其他类型。**必须手动将这些段落的 `category` 改为 `"other"`**。
-
-代码在构建文档树时,遇到 `"other"` 会自动跳过(`node_factory.py` 中 `Unknown category: other, skipping`),不会对其进行任何格式化处理。
-
-**常见需要标记为 `"other"` 的内容:**
-
-| 内容类型 | 典型示例 | 说明 |
-|----------|----------|------|
-| 目录标题 | "目 录"、"目录" | 改为 `"heading_mulu"`,代码会自动跳过 |
-| 目录条目 | "1 绪论 ...... 1"、"2.1 研究背景 ...... 5" | 带省略号和页码的条目 |
-| 页眉 | 学校名称、论文名称等重复出现的页眉 | 每页顶部的内容 |
-| 页脚 | 页码、"第 X 页 共 Y 页" | 每页底部的内容 |
-| 封面信息 | 学校名称、学院、专业、学号、指导教师、日期 | 封面中的结构化信息 |
-| 空行 | 空段落 | 无内容的段落 |
-
-## ⚠️ 重要:目录部分必须全部跳过
-
-**目录(含目录标题、目录条目、目录页码等)是 Word 自动生成的区域,绝对不能格式化,也不能添加任何段落标签。**
-
-处理规则:
-1. **目录标题**(如"目录"、"目 录")→ 改为 `"heading_mulu"`
-2. **所有目录条目**(如"1 绪论 ...... 1"、"2.1 研究背景 ...... 5")→ 改为 `"other"`
-3. **目录区域内的其他段落**(如空行、分隔线等)→ 改为 `"other"`
-
-**为什么必须跳过目录:**
-- 目录由 Word 的域代码自动生成,格式化会破坏域结构
-- 目录条目经常被模型误识别为 `heading_level_1`、`heading_level_2`、`body_text` 等,**必须全部纠正为 `"other"`**
-- 对目录段落应用格式(字体、字号、行距等)或添加段落标签会导致目录显示异常
-- 目录标题标记为 `"heading_mulu"` 后,代码会自动跳过该节点及其所有子节点的格式化
-
-**快速识别目录区域的方法:**
-- 目录通常紧跟在摘要之后、正文之前
-- 目录条目特征:包含省略号(`......`)和页码、或以"第X章"开头但带页码
-- 目录标题行之后到下一个一级标题之前的所有段落都属于目录区域
-
-## ⚠️ 重要:自动类型提升机制
-
-**以下情况不是分类错误,不要修改 JSON 中的 category!**
-
-WordFormat 内部有一个自动类型提升机制:当文档树构建完成后,代码会自动将特定父节点下的 `body_text` 子节点升级为对应的专用类型:
-
-| 父节点类型 | 其下的 body_text 自动升级为 | 说明 |
-|-----------|---------------------------|------|
-| `abstract_chinese_title` | `abstract_chinese_content` | 中文摘要标题后面的正文段落 |
-| `abstract_english_title` | `abstract_english_content` | 英文摘要标题后面的正文段落 |
-| `references_title` | `reference_entry` | 参考文献标题后面的每条文献 |
-
-**这意味着:**
-
-- 摘要标题后面的正文段落,模型输出为 `body_text` 是**正确的**,代码会自动将其升级为 `abstract_chinese_content` 并应用摘要正文的格式规则
-- 参考文献标题后面的每条文献,模型输出为 `body_text` 也是**正确的**,代码会自动升级为 `reference_entry` 并应用参考文献内容的格式规则
-- **Agent 不需要手动修改这些 `body_text` 为其他类型**,修改反而会导致格式化逻辑混乱
-
-**只有以下情况才需要手动修正 category:**
-- 摘要标题本身被错分为 `heading_level_1` 或 `body_text`
-- 关键词行被错分为 `body_text`
-- 图注/表注互相错分
-- 致谢标题被错分
-
-## `replace` 字段:替换段落文本
-
-JSON 中每个段落条目可以包含一个可选的 `"replace"` 字段,用于在格式化时直接替换段落文本内容。
-
-**使用场景**:
-- AI 识别文字有误(如 OCR 错误、空格异常):`"摘 要"` → `"摘 要"`
-- 修正错别字或特定短语
-- 更正标题文字
-
-**用法**:直接在 JSON 条目的 `replace` 字段填入新文本即可:
```json
{
"category": "abstract_chinese_title",
"paragraph": "摘 要",
- "fingerprint": "abc123",
"replace": "摘 要"
}
```
-
-**注意事项**:
-- `replace` 是可选字段,不需要时不用添加
-- 替换在格式化之前执行,替换后的文本会被按配置规范格式化
-- 检查和格式化模式均会执行替换
-- 不要用 `replace` 来修改 `category`——修改分类请直接改 `category` 字段
diff --git a/wordformat-skill/data/config_spec.md b/wordformat-skill/data/config_spec.md
index 66471b9..24aa0f9 100644
--- a/wordformat-skill/data/config_spec.md
+++ b/wordformat-skill/data/config_spec.md
@@ -1,214 +1,143 @@
# config.yaml 字段规范
-> 权威参考,编辑时只能使用本文档列出的字段。完整来源:`src/wordformat/config/datamodel.py`。
+## 基础字段
-## GlobalFormat 基础字段(15 个)
-
-所有段落配置节点都基于此字段集,通过 `<<: *global_format` 继承后覆盖。
-
-| 字段 | 类型 | 可选值 | 默认值 |
-|------|------|--------|--------|
-| `alignment` | string | `左对齐` `居中对齐` `右对齐` `两端对齐` `分散对齐` | `左对齐` |
-| `space_before` | string | `"0行"` `"0.5行"` `"12磅"` `"0.5cm"` | `"0.5行"` |
-| `space_after` | string | 同上 | `"0.5行"` |
-| `line_spacingrule` | string | `单倍行距` `1.5倍行距` `2倍行距` `最小值` `固定值` `多倍行距` | `单倍行距` |
-| `line_spacing` | string | `"1倍"` `"1.5倍"` `"20磅"` `"0倍"` | `"1.5倍"` |
-| `left_indent` | string | `"0字符"` `"2字符"` `"20磅"` `"0.75cm"` | `"0字符"` |
-| `right_indent` | string | 同上 | `"0字符"` |
-| `first_line_indent` | string | 正值为首行缩进,负值为悬挂缩进 | `"2字符"` |
-| `chinese_font_name` | string | `宋体` `黑体` `楷体` `仿宋` `微软雅黑` | `宋体` |
-| `english_font_name` | string | `Times New Roman` `Arial` `Calibri` | `Times New Roman` |
-| `font_size` | string/number | `三号` `小三` `四号` `小四` `五号` `小五` 或 `12` `14` | `小四` |
-| `font_color` | string | `黑色` `红色` 或 `#FF0000` | `黑色` |
-| `bold` | bool | `true` / `false` | `false` |
-| `italic` | bool | `true` / `false` | `false` |
-| `underline` | bool | `true` / `false` | `false` |
-| `builtin_style_name` | string | `正文` `Heading 1` `Heading 2` `Heading 3` `题注` | `正文` |
-
-`line_spacingrule` 与 `line_spacing` 必须配合:
-
-| line_spacingrule | line_spacing |
-|------------------|--------------|
-| `固定值` | `"20磅"` |
-| `1.5倍行距` | `"1.5倍"` |
-| `单倍行距` | `"1倍"` |
-| `最小值` | `"12磅"` |
-
-## 各配置节点
-
-### style_checks_warning(格式警告开关)
-
-15 个 bool 字段,名称与 GlobalFormat 相同,默认均为 `true`(`font_name` 和 `font_color` 默认 `false`)。
-
-### global_format(全局基准)
-
-15 个 GlobalFormat 字段。使用 YAML 锚点 `&global_format` 定义,其他节点通过 `<<: *global_format` 继承。
-
-### abstract(摘要)
+所有配置节点基于 `global_format` 定义,通过 `<<: *global_format` 继承。分为三个组:
```yaml
-abstract:
- chinese:
- chinese_title: # 继承 15 字段
- chinese_content: # 继承 15 字段
- english:
- english_title: # 继承 15 字段
- english_content: # 继承 15 字段
- keywords:
- chinese: # 继承 15 字段 + label + 3 专用字段
- english: # 同上
+global_format: &global_format
+ paragraph: # 段落格式
+ alignment: '两端对齐'
+ space_before: "0行"
+ space_after: "0行"
+ line_spacingrule: "1.5倍行距"
+ line_spacing: '1.5倍'
+ left_indent: "0字符"
+ right_indent: "0字符"
+ first_line_indent: '2字符'
+ builtin_style_name: '正文'
+ font: # 字符格式
+ chinese_font_name: '宋体'
+ english_font_name: 'Times New Roman'
+ font_size: '小四'
+ font_color: '黑色'
+ bold: false
+ italic: false
+ underline: false
```
-keywords 除 16 个 GlobalFormat 字段外,附加:
+| 组 | 字段 | 可选值 |
+|----|------|--------|
+| paragraph | `alignment` | 左对齐 / 居中对齐 / 右对齐 / 两端对齐 / 分散对齐 |
+| paragraph | `space_before/after` | "0行" "0.5行" "12磅" "0.5cm" |
+| paragraph | `line_spacingrule` | 单倍行距 / 1.5倍行距 / 2倍行距 / 最小值 / 固定值 / 多倍行距 |
+| paragraph | `line_spacing` | "1.5倍" "20磅" |
+| paragraph | `left/right_indent` | "0字符" "2字符" "20磅" |
+| paragraph | `first_line_indent` | 正值=首行缩进,负值=悬挂缩进 |
+| paragraph | `builtin_style_name` | 正文 / Heading 1/2/3 / 题注 |
+| font | `chinese_font_name` | 宋体 / 黑体 / 楷体 / 仿宋 |
+| font | `english_font_name` | Times New Roman / Arial |
+| font | `font_size` | 三号 / 小三 / 四号 / 小四 / 五号 / 12 / 14 |
+| font | `font_color` | 黑色 / 红色 / #FF0000 |
+| font | `bold/italic/underline` | true / false |
+
+> 覆盖时可直接用平铺字段(如 `alignment: '居中对齐'`),框架自动区分 paragraph/font。
+
+## 配置路径
+
+| 路径 | 说明 |
+|------|------|
+| `abstract.chinese.title` | 中文摘要标题 |
+| `abstract.chinese.body` | 中文摘要正文 |
+| `abstract.chinese.keywords` | 中文关键词 |
+| `abstract.english.title` | 英文摘要标题 |
+| `abstract.english.body` | 英文摘要正文 |
+| `abstract.english.keywords` | 英文关键词 |
+| `headings.level_1/2/3` | 一/二/三级标题 |
+| `body.text` | 正文段落 |
+| `figures.caption` | 图题注 |
+| `figures.image` | 图片段落 |
+| `tables.caption` | 表题注 |
+| `tables.object` | 表格对象 |
+| `references.title` | 参考文献标题 |
+| `references.entry` | 参考文献条目 |
+| `acknowledgements.title` | 致谢标题 |
+| `acknowledgements.body` | 致谢正文 |
+
+## 特殊节点字段
+
+### 关键词 (abstract.{zh/en}.keywords)
+
+基础字段 + `label`(字符格式) + `rules`:
```yaml
-label: # KeywordLabelConfig,关键词标签的字符格式
+label:
+ chinese_font_name: '黑体'
+ font_size: '四号'
+ bold: false
rules:
- keyword_count: # 关键词数量校验
+ keyword_count:
enabled: true
- count_min: 4
- count_max: 6
- trailing_punctuation: # 末尾标点校验(仅中文)
+ count_min: 3
+ count_max: 5
+ trailing_punctuation: # 仅中文
enabled: true
forbidden_chars: ";,。、"
```
-| 字段 | 类型 | 默认值 |
-|------|------|--------|
-| `label` | GlobalFormat | 关键词标签("关键词:")的字符格式 |
-| `rules.keyword_count.enabled` | bool | `true` |
-| `rules.keyword_count.count_min` | int | `4` |
-| `rules.keyword_count.count_max` | int | `6` |
-| `rules.trailing_punctuation.enabled` | bool | `true` |
-| `rules.trailing_punctuation.forbidden_chars` | string | `";,。、"` |
-
-### headings(标题)
-
-```yaml
-headings:
- level_1: # 继承 15 字段,builtin_style_name 必须为 Heading 1
- level_2: # 继承 15 字段,builtin_style_name 必须为 Heading 2
- level_3: # 继承 15 字段,builtin_style_name 必须为 Heading 3
-```
-
-### body_text(正文)
-
-继承 15 字段,通常不覆盖。
+### 题注 (figures.caption / tables.caption)
-### figures(图注)
-
-继承 16 字段 + `caption_prefix`(默认 `图`)+ `rules`。
+基础字段 + `caption_prefix` + `rules`:
```yaml
-figures:
- <<: *global_format
- caption_prefix: '图'
- font_size: '五号'
- alignment: '居中对齐'
- first_line_indent: '0字符'
- builtin_style_name: '题注'
- rules:
- caption_numbering:
- enabled: true
- separator: '.'
- label_number_space: false
+caption_prefix: '图' # 或 '表'
+rules:
+ caption_numbering:
+ enabled: true
+ separator: '.'
+ label_number_space: false
```
-### tables(表注 + 表格内容)
-
-继承 16 字段 + `caption_prefix`(默认 `表`)+ `content` 子配置 + `rules`。
-
-```yaml
-tables:
- <<: *global_format
- caption_prefix: '表'
- builtin_style_name: '题注'
- content:
- <<: *global_format
- font_size: '五号'
- line_spacingrule: '单倍行距'
- rules:
- caption_numbering:
- enabled: true
- separator: '.'
- label_number_space: false
-```
+### 正文 (body.text)
-### references(参考文献)
+基础字段 + `rules`:
```yaml
-references:
- title: # 继承 15 字段
- content: # 继承 15 字段
+rules:
+ punctuation:
+ enabled: true
```
-悬挂缩进示例:`first_line_indent: '-2.2字符'` + `left_indent: '0.26字符'`。
+### style_checks_warning
-### acknowledgements(致谢)
+控制各字段差异是否在批注中显示:
-```yaml
-acknowledgements:
- title: # 继承 15 字段
- content: # 继承 15 字段
-```
+| 字段 | 类型 | 默认 |
+|------|------|------|
+| `alignment` | bool | true |
+| `space_before/after` | bool | true |
+| `line_spacing/line_spacing_rule` | bool | true |
+| `left/right/first_line_indent` | bool | true |
+| `builtin_style_name` | bool | true |
+| `font_size` | bool | true |
+| `font_name_cn/font_name_en` | bool | false |
+| `font_color` | bool | false |
+| `bold` | bool | true |
+| `italic/underline` | bool | false |
-### numbering(自动编号)
-
-**仅在 `wordf af` 模式下生效。**
-
-| 字段 | 类型 | 默认值 | 说明 |
-|------|------|--------|------|
-| `enabled` | bool | `false` | 总开关 |
-| `captions.enabled` | bool | `false` | 题注编号开关 |
-| `captions.separator` | string | `.` | 章节号-编号分隔符 |
-| `captions.label_number_space` | bool | `false` | 标签与编号间加空格 |
-| `level_1/2/3.enabled` | bool | `false` | 该级编号开关 |
-| `level_1/2/3.template` | string | — | `%1` `%1.%2` `第%1章` |
-| `level_1/2/3.suffix` | string | `space` | `tab` `space` `nothing` |
-| `level_1/2/3.numbering_indent` | string | — | 可选,如 `"0.75cm"` |
-| `level_1/2/3.text_indent` | string | — | 可选,如 `"2.2字符"` |
-| `references.enabled` | bool | `true` | 参考文献编号 |
-| `references.template` | string | `[%1]` | 编号模板 |
-| `references.suffix` | string | `space` | 同上 |
+## numbering(自动编号)
```yaml
numbering:
enabled: true
- captions:
+ level_1/2/3:
enabled: true
- separator: '.'
- level_1:
- enabled: true
- template: '%1'
- suffix: space
- level_2:
+ template: '%1' # %1.%2, 第%1章 等
+ suffix: space # tab / space / nothing
+ numbering_indent: # 可选
+ text_indent: # 可选
+ references:
enabled: true
- template: '%1.%2'
+ template: '[%1]'
suffix: space
```
-
-## 字段白名单
-
-| 配置路径 | 合法字段 |
-|----------|----------|
-| `style_checks_warning` | bold, italic, underline, font_size, font_name, font_color, alignment, space_before, space_after, line_spacing, line_spacingrule, left_indent, right_indent, first_line_indent, builtin_style_name |
-| `global_format` | 同上 + chinese_font_name, english_font_name, font_size, font_color, bold, italic, underline |
-| `abstract.{zh/en}.{title/content}` | 同 global_format |
-| `abstract.keywords.{zh/en}` | 同 global_format + label(同 global_format) + rules |
-| `abstract.keywords.{zh/en}.rules` | keyword_count, trailing_punctuation |
-| `abstract.keywords.{zh/en}.rules.keyword_count` | enabled, count_min, count_max |
-| `abstract.keywords.{zh/en}.rules.trailing_punctuation` | enabled, forbidden_chars |
-| `headings.level_1/2/3` | 同 global_format |
-| `body_text` | 同 global_format |
-| `figures` | 同 global_format + caption_prefix + rules |
-| `figures.rules` | caption_numbering |
-| `figures.rules.caption_numbering` | enabled, separator, label_number_space |
-| `tables` | 同 global_format + caption_prefix + content(同 global_format) + rules |
-| `tables.rules` | caption_numbering |
-| `tables.rules.caption_numbering` | enabled, separator, label_number_space |
-| `references.title` | 同 global_format |
-| `references.content` | 同 global_format |
-| `acknowledgements.title/content` | 同 global_format |
-| `numbering` | enabled, captions |
-| `numbering.captions` | enabled, separator, label_number_space |
-| `numbering.level_1/2/3/references` | enabled, template, suffix, numbering_indent, text_indent |
From dcde4356540c2059b08ae3c3fe45aa328e919e70 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 23:24:28 +0800
Subject: [PATCH 08/12] =?UTF-8?q?WordFormatUI=20=E9=85=8D=E7=BD=AE?=
=?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=90=8C=E6=AD=A5=E5=90=8E=E7=AB=AF=20NODE?=
=?UTF-8?q?=5FTYPE=20=E9=87=8D=E5=91=BD=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 所有配置路径匹配新命名: title/body/keywords/caption/entry/object
- global_format 改用 paragraph/font 分组结构
- style_checks_warning: font_name→font_name_cn+font_name_en, line_spacingrule→line_spacing_rule
- 删除死字段: tables.content, numbering.captions
- FormatConfig.vue 覆盖全部 16 个 paragraph+font 字段,无需改动
---
.../src/config-generator/ConfigGenerator.vue | 4 +-
.../components/AbstractConfig.vue | 36 +--
.../components/AcknowledgementsConfig.vue | 2 +-
.../components/FiguresConfig.vue | 10 +-
.../components/NumberingConfig.vue | 8 -
.../components/ReferencesConfig.vue | 2 +-
.../components/TablesConfig.vue | 12 +-
.../components/WarningFieldsConfig.vue | 7 +-
WordFormatUI/src/config-generator/utils.js | 232 ++++++++----------
9 files changed, 132 insertions(+), 181 deletions(-)
diff --git a/WordFormatUI/src/config-generator/ConfigGenerator.vue b/WordFormatUI/src/config-generator/ConfigGenerator.vue
index 06afda9..2e2f89c 100644
--- a/WordFormatUI/src/config-generator/ConfigGenerator.vue
+++ b/WordFormatUI/src/config-generator/ConfigGenerator.vue
@@ -35,10 +35,10 @@
-
+
diff --git a/WordFormatUI/src/config-generator/components/AbstractConfig.vue b/WordFormatUI/src/config-generator/components/AbstractConfig.vue
index 9d64bc0..b514fa7 100644
--- a/WordFormatUI/src/config-generator/components/AbstractConfig.vue
+++ b/WordFormatUI/src/config-generator/components/AbstractConfig.vue
@@ -2,42 +2,42 @@
中文摘要
中文标题
-
+
中文内容
-
+
英文摘要
英文标题
-
+
英文内容
-
+
关键词配置
中文关键词
中文关键词内容格式
-
+
中文关键词标签("关键词:")
-
+
英文关键词
英文关键词内容格式
-
+
英文关键词标签("Keywords:")
-
+
diff --git a/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue b/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue
index b41a2b7..045983a 100644
--- a/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue
+++ b/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue
@@ -3,7 +3,7 @@
致谢标题
致谢内容
-
+
diff --git a/WordFormatUI/src/config-generator/components/FiguresConfig.vue b/WordFormatUI/src/config-generator/components/FiguresConfig.vue
index 53e9289..1267b36 100644
--- a/WordFormatUI/src/config-generator/components/FiguresConfig.vue
+++ b/WordFormatUI/src/config-generator/components/FiguresConfig.vue
@@ -1,13 +1,13 @@
diff --git a/WordFormatUI/src/config-generator/components/NumberingConfig.vue b/WordFormatUI/src/config-generator/components/NumberingConfig.vue
index 4656bcf..bc0092e 100644
--- a/WordFormatUI/src/config-generator/components/NumberingConfig.vue
+++ b/WordFormatUI/src/config-generator/components/NumberingConfig.vue
@@ -47,14 +47,6 @@
悬挂缩进量
-
diff --git a/WordFormatUI/src/config-generator/components/ReferencesConfig.vue b/WordFormatUI/src/config-generator/components/ReferencesConfig.vue
index b28289c..046d65b 100644
--- a/WordFormatUI/src/config-generator/components/ReferencesConfig.vue
+++ b/WordFormatUI/src/config-generator/components/ReferencesConfig.vue
@@ -3,7 +3,7 @@
参考文献标题
参考文献内容
-
+
diff --git a/WordFormatUI/src/config-generator/components/TablesConfig.vue b/WordFormatUI/src/config-generator/components/TablesConfig.vue
index 7e11ca4..089c6f6 100644
--- a/WordFormatUI/src/config-generator/components/TablesConfig.vue
+++ b/WordFormatUI/src/config-generator/components/TablesConfig.vue
@@ -1,17 +1,15 @@
题注格式
-
+
表格对象格式
-
表格内容格式
-
diff --git a/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue b/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue
index f00d7e1..278bce0 100644
--- a/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue
+++ b/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue
@@ -11,13 +11,14 @@
-
+
+
-
+
@@ -29,7 +30,7 @@
diff --git a/WordFormatUI/src/config-generator/utils.js b/WordFormatUI/src/config-generator/utils.js
index 9ccc934..dfae3cf 100644
--- a/WordFormatUI/src/config-generator/utils.js
+++ b/WordFormatUI/src/config-generator/utils.js
@@ -1,5 +1,5 @@
-// 默认全局格式
-export const defaultGlobalFormat = {
+// 默认全局段落格式
+const defaultParagraph = {
alignment: "两端对齐",
space_before: "0行",
space_after: "0行",
@@ -8,7 +8,11 @@ export const defaultGlobalFormat = {
left_indent: "0字符",
right_indent: "0字符",
first_line_indent: "2字符",
- builtin_style_name: "正文",
+ builtin_style_name: "正文"
+}
+
+// 默认全局字体格式
+const defaultFont = {
chinese_font_name: "宋体",
english_font_name: "Times New Roman",
font_size: "小四",
@@ -18,12 +22,15 @@ export const defaultGlobalFormat = {
underline: false
}
+// 默认全局格式(平铺,向后兼容)
+export const defaultGlobalFormat = {
+ ...defaultParagraph,
+ ...defaultFont
+}
+
// 创建带全局格式继承的配置对象
-export const createConfigWithGlobalInheritance = (baseConfig = {}) => {
- return {
- ...defaultGlobalFormat,
- ...baseConfig
- }
+export const createConfigWithGlobalInheritance = (overrides = {}) => {
+ return { ...defaultGlobalFormat, ...overrides }
}
// 默认配置
@@ -34,46 +41,35 @@ export const defaultConfig = {
italic: false,
underline: false,
font_size: true,
- font_name: true,
+ font_name_cn: true,
+ font_name_en: true,
font_color: false,
alignment: true,
space_before: true,
space_after: true,
line_spacing: true,
- line_spacingrule: true,
+ line_spacing_rule: true,
left_indent: true,
right_indent: true,
first_line_indent: true,
builtin_style_name: true
},
global_format: {
- ...defaultGlobalFormat
+ paragraph: { ...defaultParagraph },
+ font: { ...defaultFont }
},
abstract: {
chinese: {
- chinese_title: createConfigWithGlobalInheritance({
+ title: createConfigWithGlobalInheritance({
alignment: "居中对齐",
first_line_indent: "0字符",
chinese_font_name: "黑体",
font_size: "四号"
}),
- chinese_content: createConfigWithGlobalInheritance({
+ body: createConfigWithGlobalInheritance({
alignment: "两端对齐"
- })
- },
- english: {
- english_title: createConfigWithGlobalInheritance({
- alignment: "居中对齐",
- first_line_indent: "0字符",
- font_size: "四号",
- bold: false
}),
- english_content: createConfigWithGlobalInheritance({
- alignment: "两端对齐"
- })
- },
- keywords: {
- chinese: createConfigWithGlobalInheritance({
+ keywords: createConfigWithGlobalInheritance({
alignment: "两端对齐",
first_line_indent: "2字符",
font_size: "小四",
@@ -86,8 +82,19 @@ export const defaultConfig = {
keyword_count: { enabled: true, count_min: 3, count_max: 5 },
trailing_punctuation: { enabled: true, forbidden_chars: ";,。、" }
}
+ })
+ },
+ english: {
+ title: createConfigWithGlobalInheritance({
+ alignment: "居中对齐",
+ first_line_indent: "0字符",
+ font_size: "四号",
+ bold: false
}),
- english: createConfigWithGlobalInheritance({
+ body: createConfigWithGlobalInheritance({
+ alignment: "两端对齐"
+ }),
+ keywords: createConfigWithGlobalInheritance({
alignment: "两端对齐",
first_line_indent: "2字符",
font_size: "小四",
@@ -96,8 +103,7 @@ export const defaultConfig = {
bold: false
}),
rules: {
- keyword_count: { enabled: true, count_min: 3, count_max: 5 },
- trailing_punctuation: { enabled: false, forbidden_chars: ".,;:" }
+ keyword_count: { enabled: true, count_min: 3, count_max: 5 }
}
})
}
@@ -131,57 +137,51 @@ export const defaultConfig = {
builtin_style_name: "Heading 3"
})
},
- body_text: createConfigWithGlobalInheritance({
- rules: {
- punctuation: { enabled: true }
- }
- }),
- figures: createConfigWithGlobalInheritance({
- alignment: "居中对齐",
- first_line_indent: "0字符",
- font_size: "五号",
- builtin_style_name: "题注",
- caption_prefix: "图",
+ body: {
+ text: createConfigWithGlobalInheritance({
+ rules: { punctuation: { enabled: true } }
+ })
+ },
+ figures: {
+ caption: createConfigWithGlobalInheritance({
+ alignment: "居中对齐",
+ first_line_indent: "0字符",
+ font_size: "五号",
+ builtin_style_name: "题注",
+ caption_prefix: "图",
+ rules: {
+ caption_numbering: { enabled: true, separator: '.', label_number_space: false }
+ }
+ }),
image: createConfigWithGlobalInheritance({
alignment: "居中对齐",
first_line_indent: "0字符"
+ })
+ },
+ tables: {
+ caption: createConfigWithGlobalInheritance({
+ alignment: "居中对齐",
+ first_line_indent: "0字符",
+ font_size: "五号",
+ builtin_style_name: "题注",
+ caption_prefix: "表",
+ rules: {
+ caption_numbering: { enabled: true, separator: '.', label_number_space: false }
+ }
}),
- rules: {
- caption_numbering: { enabled: true, separator: '.', label_number_space: false }
- }
- }),
- tables: createConfigWithGlobalInheritance({
- alignment: "居中对齐",
- first_line_indent: "0字符",
- font_size: "五号",
- builtin_style_name: "题注",
- caption_prefix: "表",
object: createConfigWithGlobalInheritance({
alignment: "居中对齐",
first_line_indent: "0字符"
- }),
- content: createConfigWithGlobalInheritance({
- chinese_font_name: '宋体',
- english_font_name: 'Times New Roman',
- font_size: '五号',
- line_spacingrule: '单倍行距',
- alignment: '居中对齐',
- first_line_indent: '0字符',
- space_before: "0行",
- space_after: "0行"
- }),
- rules: {
- caption_numbering: { enabled: true, separator: '.', label_number_space: false }
- }
- }),
+ })
+ },
references: {
title: createConfigWithGlobalInheritance({
alignment: "居中对齐",
first_line_indent: "0字符",
chinese_font_name: "黑体",
- font_size: "三号",
+ font_size: "三号"
}),
- content: createConfigWithGlobalInheritance({
+ entry: createConfigWithGlobalInheritance({
alignment: "两端对齐",
first_line_indent: "-2.2字符",
left_indent: "0.26字符",
@@ -196,7 +196,7 @@ export const defaultConfig = {
chinese_font_name: "黑体",
font_size: "小二"
}),
- content: createConfigWithGlobalInheritance({
+ body: createConfigWithGlobalInheritance({
alignment: "两端对齐",
font_size: "五号"
})
@@ -230,11 +230,6 @@ export const defaultConfig = {
suffix: 'space',
numbering_indent: null,
text_indent: null
- },
- captions: {
- enabled: false,
- separator: '.',
- label_number_space: false
}
}
}
@@ -269,72 +264,37 @@ export const mergeWithDefaults = (config, defaults) => {
// 应用全局格式到所有局部配置
export const applyGlobalFormatToAll = (userConfig) => {
- // 摘要配置
- userConfig.abstract.chinese.chinese_title = {
- ...userConfig.global_format
- }
- userConfig.abstract.chinese.chinese_content = {
- ...userConfig.global_format
- }
- userConfig.abstract.english.english_title = {
- ...userConfig.global_format
- }
- userConfig.abstract.english.english_content = {
- ...userConfig.global_format
- }
- userConfig.abstract.keywords.english = {
- ...userConfig.global_format,
- label: userConfig.abstract.keywords.english.label,
- rules: userConfig.abstract.keywords.english.rules
- }
- userConfig.abstract.keywords.chinese = {
- ...userConfig.global_format,
- label: userConfig.abstract.keywords.chinese.label,
- rules: userConfig.abstract.keywords.chinese.rules
- }
+ const gf = userConfig.global_format
+ const flat = { ...(gf.paragraph || {}), ...(gf.font || {}) }
- // 标题配置
- userConfig.headings.level_1 = {
- ...userConfig.global_format
- }
- userConfig.headings.level_2 = {
- ...userConfig.global_format
- }
- userConfig.headings.level_3 = {
- ...userConfig.global_format
- }
+ // 摘要
+ const abs = userConfig.abstract
+ abs.chinese.title = { ...flat, ...abs.chinese.title }
+ abs.chinese.body = { ...flat, ...abs.chinese.body }
+ abs.chinese.keywords = { ...flat, label: abs.chinese.keywords.label, rules: abs.chinese.keywords.rules }
+ abs.english.title = { ...flat, ...abs.english.title }
+ abs.english.body = { ...flat, ...abs.english.body }
+ abs.english.keywords = { ...flat, label: abs.english.keywords.label, rules: abs.english.keywords.rules }
- // 正文配置
- userConfig.body_text = {
- ...userConfig.global_format
- }
+ // 标题
+ userConfig.headings.level_1 = { ...flat, ...userConfig.headings.level_1 }
+ userConfig.headings.level_2 = { ...flat, ...userConfig.headings.level_2 }
+ userConfig.headings.level_3 = { ...flat, ...userConfig.headings.level_3 }
- // 插图配置
- userConfig.figures = {
- ...userConfig.global_format,
- caption_prefix: userConfig.figures.caption_prefix
- }
+ // 正文
+ userConfig.body.text = { ...flat, ...userConfig.body.text }
- // 表格配置
- userConfig.tables = {
- ...userConfig.global_format,
- caption_prefix: userConfig.tables.caption_prefix,
- content: userConfig.tables.content
- }
+ // 图/表
+ userConfig.figures.caption = { ...flat, caption_prefix: userConfig.figures.caption.caption_prefix, rules: userConfig.figures.caption.rules }
+ userConfig.figures.image = { ...flat, ...userConfig.figures.image }
+ userConfig.tables.caption = { ...flat, caption_prefix: userConfig.tables.caption.caption_prefix, rules: userConfig.tables.caption.rules }
+ userConfig.tables.object = { ...flat, ...userConfig.tables.object }
- // 参考文献配置
- userConfig.references.title = {
- ...userConfig.global_format
- }
- userConfig.references.content = {
- ...userConfig.global_format
- }
+ // 参考文献
+ userConfig.references.title = { ...flat, ...userConfig.references.title }
+ userConfig.references.entry = { ...flat, ...userConfig.references.entry }
- // 致谢配置
- userConfig.acknowledgements.title = {
- ...userConfig.global_format
- }
- userConfig.acknowledgements.content = {
- ...userConfig.global_format
- }
+ // 致谢
+ userConfig.acknowledgements.title = { ...flat, ...userConfig.acknowledgements.title }
+ userConfig.acknowledgements.body = { ...flat, ...userConfig.acknowledgements.body }
}
From cca59fbc707f5194da7f302ecf3a0a8c432e237b Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 23:35:11 +0800
Subject: [PATCH 09/12] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95?=
=?UTF-8?q?=EF=BC=9Aparser=20=E8=BE=B9=E7=BC=98=E6=83=85=E5=86=B5=20+=20st?=
=?UTF-8?q?ages=5Fmd=20+=20object.py=EF=BC=8C=E8=A6=86=E7=9B=96=E7=8E=87?=
=?UTF-8?q?=E5=9B=9E=E5=88=B0=2087%?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- parser: 表格/引用/图片+题注/删除线/链接/crlf/内联代码/换行 等 12 个测试
- stages_md: LoadMarkdownStage/MarkdownParseStage/DocumentCreationStage 4 个测试
- object: FigureImage._base/_try_insert_image, TableObject 表格创建/位置 6 个测试
---
tests/markdown/test_parser.py | 73 ++++++++++++++++
tests/markdown/test_stages_md.py | 81 ++++++++++++++++++
tests/rules/test_object.py | 137 +++++++++++++++++++++++++++++++
3 files changed, 291 insertions(+)
create mode 100644 tests/markdown/test_stages_md.py
create mode 100644 tests/rules/test_object.py
diff --git a/tests/markdown/test_parser.py b/tests/markdown/test_parser.py
index 0ecbe4d..02686e2 100644
--- a/tests/markdown/test_parser.py
+++ b/tests/markdown/test_parser.py
@@ -130,3 +130,76 @@ def test_standalone_image_becomes_figure(self):
def test_thematic_break_skipped(self):
result = parse_markdown("---")
assert len(result) == 0
+
+ def test_table_with_header_and_body(self):
+ text = "| A | B |\n|----|----|\n| 1 | 2 |\n| 3 | 4 |"
+ result = parse_markdown(text)
+ assert len(result) == 1
+ assert result[0]["category"] == "table_object"
+ rows = result[0]["table_rows"]
+ assert len(rows) == 3 # header + 2 data rows
+ assert rows[0] == ["A", "B"]
+
+ def test_block_quote_flattens(self):
+ result = parse_markdown("> 引用文字")
+ assert len(result) == 1
+ assert result[0]["category"] == "body_text"
+ assert "引用文字" in result[0]["paragraph"]
+
+ def test_code_block_empty_lines_skipped(self):
+ result = parse_markdown('```\n\na = 1\n\nb = 2\n\n```')
+ assert len(result) == 2
+ assert result[0]["paragraph"] == "a = 1"
+ assert result[1]["paragraph"] == "b = 2"
+
+ def test_paragraph_with_inline_image_kept_as_body(self):
+ """段落中文字+图片混合时保持为 body_text"""
+ result = parse_markdown("文字  更多文字")
+ assert len(result) == 1
+ assert result[0]["category"] == "body_text"
+
+ def test_image_with_alt_creates_caption_and_figure(self):
+ result = parse_markdown("")
+ assert len(result) == 2
+ assert result[0]["category"] == "figure_image"
+ assert result[0]["paragraph"] == "arch.png"
+ assert result[1]["category"] == "caption_figure"
+ assert result[1]["paragraph"] == "图1.1 系统架构"
+
+ def test_image_no_alt_creates_figure_only(self):
+ result = parse_markdown("")
+ assert len(result) == 1
+ assert result[0]["category"] == "figure_image"
+ assert result[0]["paragraph"] == "noalt.png"
+
+ def test_strikethrough_text(self):
+ result = parse_markdown("这是~~删除~~文字")
+ assert len(result) == 1
+ marks = result[0]["inline_marks"]
+ strike_segs = [m for m in marks if m.get("strikethrough")]
+ assert len(strike_segs) == 1
+ assert strike_segs[0]["text"] == "删除"
+
+ def test_link_text(self):
+ result = parse_markdown("访问[链接](https://example.com)")
+ marks = result[0]["inline_marks"]
+ link_segs = [m for m in marks if m.get("link_url")]
+ assert len(link_segs) == 1
+ assert link_segs[0]["link_url"] == "https://example.com"
+
+ def test_crlf_normalized(self):
+ result = parse_markdown("第一行\r\n第二行\r第三行")
+ assert len(result) >= 1
+ text = result[0]["paragraph"]
+ assert "\r" not in text
+
+ def test_codespan_in_text(self):
+ result = parse_markdown("使用 `wordf md` 命令")
+ text = result[0]["paragraph"]
+ assert "wordf md" in text
+ assert "`" not in text
+
+ def test_linebreak_in_paragraph(self):
+ result = parse_markdown("第一行 \n第二行")
+ text = result[0]["paragraph"]
+ assert "\n" in text # linebreak preserved
diff --git a/tests/markdown/test_stages_md.py b/tests/markdown/test_stages_md.py
new file mode 100644
index 0000000..03b5aff
--- /dev/null
+++ b/tests/markdown/test_stages_md.py
@@ -0,0 +1,81 @@
+"""MD pipeline stages 单元测试。"""
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+
+from wordformat.pipeline.context import FormatContext
+from wordformat.pipeline.stages_md import (
+ DocumentCreationStage,
+ LoadMarkdownStage,
+ MarkdownParseStage,
+)
+
+
+class TestLoadMarkdownStage:
+ def test_loads_file_content(self, tmp_path):
+ md_file = tmp_path / "test.md"
+ md_file.write_text("# 标题\n\n正文")
+ ctx = FormatContext(md_path=str(md_file))
+ stage = LoadMarkdownStage()
+ result = stage.process(ctx)
+ assert "# 标题" in result.md_text
+ assert "正文" in result.md_text
+
+ def test_missing_file_raises(self):
+ ctx = FormatContext(md_path="/nonexistent/path.md")
+ stage = LoadMarkdownStage()
+ with pytest.raises(FileNotFoundError):
+ stage.process(ctx)
+
+
+class TestMarkdownParseStage:
+ def test_parses_markdown_to_paragraphs(self, tmp_path):
+ md_file = tmp_path / "test.md"
+ md_file.write_text("# 标题\n\n正文段落")
+ ctx = FormatContext(md_path=str(md_file))
+ ctx = LoadMarkdownStage().process(ctx)
+ ctx = MarkdownParseStage().process(ctx)
+ assert len(ctx.paragraphs) >= 2
+ categories = [p["category"] for p in ctx.paragraphs]
+ assert "heading_level_1" in categories
+ assert "body_text" in categories
+
+
+class TestDocumentCreationStage:
+ def test_creates_document_from_tree(self, root_node_fixture):
+ """用简单 tree 创建文档。"""
+ ctx = FormatContext()
+ ctx.root_node = root_node_fixture
+ ctx = DocumentCreationStage().process(ctx)
+ assert ctx.document is not None
+ assert len(ctx.document.paragraphs) >= 1
+
+ def test_figure_image_creates_empty_run(self):
+ """figure_image 节点只建空 run,路径留给 FigureImage。"""
+ from wordformat.rules.node import FormatNode
+
+ root = FormatNode(value={}, level=0)
+ node = FormatNode(
+ value={"category": "figure_image", "paragraph": "test.png"},
+ level=1,
+ )
+ root.add_child_node(node)
+ ctx = FormatContext()
+ ctx.root_node = root
+ ctx = DocumentCreationStage().process(ctx)
+ assert node.paragraph is not None
+ assert node.paragraph.runs[0].text == ""
+
+
+@pytest.fixture
+def root_node_fixture():
+ from wordformat.rules.node import FormatNode
+
+ root = FormatNode(value={}, level=0)
+ child = FormatNode(
+ value={"category": "body_text", "paragraph": "测试正文"},
+ level=1,
+ )
+ root.add_child_node(child)
+ return root
diff --git a/tests/rules/test_object.py b/tests/rules/test_object.py
new file mode 100644
index 0000000..2e436cb
--- /dev/null
+++ b/tests/rules/test_object.py
@@ -0,0 +1,137 @@
+"""FigureImage 和 TableObject 单元测试。"""
+from unittest.mock import MagicMock, patch
+
+import pytest
+from docx import Document
+
+from wordformat.rules.object import FigureImage, TableObject
+
+
+class TestFigureImage:
+ def test_apply_format_no_image_path_skips(self):
+ """paragraph 有文字时跳过图片插入。"""
+ doc = Document()
+ p = doc.add_paragraph("已有文字")
+ node = FigureImage(
+ value={"category": "figure_image", "paragraph": "notexist.png"},
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({})
+ node.apply_format(doc)
+ # 不影响已有文字
+ assert p.runs[0].text == "已有文字"
+
+ def test_apply_format_empty_paragraph_missing_file(self):
+ """空段落 + 路径不存在 → 不崩溃。"""
+ doc = Document()
+ p = doc.add_paragraph("")
+ node = FigureImage(
+ value={"category": "figure_image", "paragraph": "/nonexistent/img.png"},
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({})
+ node.apply_format(doc)
+ # 不崩溃就是通过
+
+ def test_base_checks_alignment(self):
+ """_base 检查对齐和首行缩进。"""
+ doc = Document()
+ p = doc.add_paragraph("")
+ node = FigureImage(
+ value={"category": "figure_image"},
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({"figures": {"image": {"alignment": "居中对齐", "first_line_indent": "0字符"}}})
+ node._base(doc, p=True, r=False)
+ # 检查模式不崩溃
+
+ def test_check_format_with_comment(self):
+ """check_format 在格式不对时添加批注。"""
+ doc = Document()
+ p = doc.add_paragraph("")
+ node = FigureImage(
+ value={"category": "figure_image"},
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({"figures": {"image": {"alignment": "居中对齐", "first_line_indent": "0字符"}}})
+ with patch.object(node, "add_comment") as mock_comment:
+ node.check_format(doc)
+ assert mock_comment.call_count >= 1
+
+
+class TestTableObject:
+ def test_apply_no_table_rows_skips(self):
+ """无 table_rows 时跳过表格创建。"""
+ doc = Document()
+ p = doc.add_paragraph("")
+ node = TableObject(
+ value={"category": "table_object"},
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({})
+ with patch.object(node, "_try_insert_table") as mock_table:
+ node.apply_format(doc)
+ mock_table.assert_called_once()
+
+ def test_try_insert_table_creates_table(self):
+ """有 table_rows 时创建 Word 表格。"""
+ doc = Document()
+ p = doc.add_paragraph("")
+ node = TableObject(
+ value={
+ "category": "table_object",
+ "table_rows": [["A", "B"], ["1", "2"]],
+ },
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({})
+ node._try_insert_table(doc)
+ # 表格已插入文档
+ assert len(doc.tables) == 1
+ table = doc.tables[0]
+ assert len(table.rows) == 2
+ assert table.cell(0, 0).text == "A"
+
+ def test_try_insert_table_move_to_paragraph(self):
+ """表格移动到段落后,不是文档末尾。"""
+ doc = Document()
+ p1 = doc.add_paragraph("段落1")
+ p2 = doc.add_paragraph("段落2")
+ node = TableObject(
+ value={
+ "category": "table_object",
+ "table_rows": [["X"]],
+ },
+ level=1,
+ paragraph=p1,
+ )
+ node.load_config({})
+ node._try_insert_table(doc)
+ # 表格应该在 p1 之后
+ body = doc.element.body
+ p_elements = body.findall(
+ "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p"
+ )
+ tbl_elements = body.findall(
+ "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tbl"
+ )
+ assert len(tbl_elements) == 1
+
+ def test_apply_format_calls_try_insert(self):
+ """apply_format 触发 _try_insert_table。"""
+ doc = Document()
+ p = doc.add_paragraph("")
+ node = TableObject(
+ value={"category": "table_object", "table_rows": [["A"]]},
+ level=1,
+ paragraph=p,
+ )
+ node.load_config({})
+ node.apply_format(doc)
+ assert len(doc.tables) == 1
From 70518424b173de60d7475f182c9bb1d664b67733 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Sun, 12 Jul 2026 23:40:02 +0800
Subject: [PATCH 10/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20stages=5Fmd=20?=
=?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=9C=A8=20Windows=20=E4=B8=8B=E7=9A=84=20Un?=
=?UTF-8?q?icodeEncodeError?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 测试内容改用 ASCII,mock logger 避免中文日志触发 charmap 编码错误
---
tests/markdown/test_stages_md.py | 24 ++++++++++++++----------
1 file changed, 14 insertions(+), 10 deletions(-)
diff --git a/tests/markdown/test_stages_md.py b/tests/markdown/test_stages_md.py
index 03b5aff..2e93ea3 100644
--- a/tests/markdown/test_stages_md.py
+++ b/tests/markdown/test_stages_md.py
@@ -15,12 +15,13 @@
class TestLoadMarkdownStage:
def test_loads_file_content(self, tmp_path):
md_file = tmp_path / "test.md"
- md_file.write_text("# 标题\n\n正文")
+ md_file.write_text("# Heading\n\ncontent")
ctx = FormatContext(md_path=str(md_file))
stage = LoadMarkdownStage()
- result = stage.process(ctx)
- assert "# 标题" in result.md_text
- assert "正文" in result.md_text
+ with patch("wordformat.pipeline.stages_md.logger"):
+ result = stage.process(ctx)
+ assert "# Heading" in result.md_text
+ assert "content" in result.md_text
def test_missing_file_raises(self):
ctx = FormatContext(md_path="/nonexistent/path.md")
@@ -32,10 +33,11 @@ def test_missing_file_raises(self):
class TestMarkdownParseStage:
def test_parses_markdown_to_paragraphs(self, tmp_path):
md_file = tmp_path / "test.md"
- md_file.write_text("# 标题\n\n正文段落")
+ md_file.write_text("# Heading\n\nparagraph text")
ctx = FormatContext(md_path=str(md_file))
- ctx = LoadMarkdownStage().process(ctx)
- ctx = MarkdownParseStage().process(ctx)
+ with patch("wordformat.pipeline.stages_md.logger"):
+ ctx = LoadMarkdownStage().process(ctx)
+ ctx = MarkdownParseStage().process(ctx)
assert len(ctx.paragraphs) >= 2
categories = [p["category"] for p in ctx.paragraphs]
assert "heading_level_1" in categories
@@ -47,7 +49,8 @@ def test_creates_document_from_tree(self, root_node_fixture):
"""用简单 tree 创建文档。"""
ctx = FormatContext()
ctx.root_node = root_node_fixture
- ctx = DocumentCreationStage().process(ctx)
+ with patch("wordformat.pipeline.stages_md.logger"):
+ ctx = DocumentCreationStage().process(ctx)
assert ctx.document is not None
assert len(ctx.document.paragraphs) >= 1
@@ -63,7 +66,8 @@ def test_figure_image_creates_empty_run(self):
root.add_child_node(node)
ctx = FormatContext()
ctx.root_node = root
- ctx = DocumentCreationStage().process(ctx)
+ with patch("wordformat.pipeline.stages_md.logger"):
+ ctx = DocumentCreationStage().process(ctx)
assert node.paragraph is not None
assert node.paragraph.runs[0].text == ""
@@ -74,7 +78,7 @@ def root_node_fixture():
root = FormatNode(value={}, level=0)
child = FormatNode(
- value={"category": "body_text", "paragraph": "测试正文"},
+ value={"category": "body_text", "paragraph": "test content"},
level=1,
)
root.add_child_node(child)
From 7c110e6b74da97068bcbd7637d416be0d4740b42 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Mon, 13 Jul 2026 14:16:32 +0800
Subject: [PATCH 11/12] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20math=20=E6=A8=A1?=
=?UTF-8?q?=E5=9D=97=EF=BC=9AMarkdown=20=E6=95=B0=E5=AD=A6=E5=85=AC?=
=?UTF-8?q?=E5=BC=8F=EF=BC=88$...$=20/=20$$...$$=EF=BC=89=E8=BD=AC=20OMML?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
LaTeX → latex2mathml → MathML → mathml2omml → OMML,基于 python-docx 的
OxmlElement 构建 OMML 元素,集成到 md→docx 管线中:
- 解析器使用 mistune math 插件识别 $ 和 $$ 分隔符
- DocumentCreationStage 渲染 m:oMath / m:oMathPara
---
pyproject.toml | 3 +
src/wordformat/markdown/parser.py | 36 ++++-
src/wordformat/math/__init__.py | 30 ++++
src/wordformat/math/omml.py | 210 +++++++++++++++++++++++++++
src/wordformat/pipeline/stages_md.py | 46 ++++++
tests/markdown/test_parser.py | 44 ++++++
tests/math/test_omml.py | 170 ++++++++++++++++++++++
7 files changed, 536 insertions(+), 3 deletions(-)
create mode 100644 src/wordformat/math/__init__.py
create mode 100644 src/wordformat/math/omml.py
create mode 100644 tests/math/test_omml.py
diff --git a/pyproject.toml b/pyproject.toml
index dbe3518..02cb4d5 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,6 +24,9 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
+ "latex2mathml>=3.81.0",
+ "lxml>=5.0.0",
+ "mathml2omml>=0.0.2",
"fastapi>=0.128.1",
"loguru>=0.7.3",
"mistune>=3.0.0",
diff --git a/src/wordformat/markdown/parser.py b/src/wordformat/markdown/parser.py
index be3ce98..23d8b20 100644
--- a/src/wordformat/markdown/parser.py
+++ b/src/wordformat/markdown/parser.py
@@ -23,7 +23,7 @@ def parse_markdown(md_text: str) -> list[dict[str, Any]]:
供 DocumentCreationStage 创建多 run 段落时使用。
"""
md = mistune.create_markdown(
- renderer=None, plugins=["table", "strikethrough", "url"]
+ renderer=None, plugins=["math", "table", "strikethrough", "url"]
)
ast = md(md_text)
result: list[dict[str, Any]] = []
@@ -43,7 +43,9 @@ def _walk_blocks(blocks: list[dict], result: list[dict]) -> None:
if btype == "blank_line" or btype == "thematic_break":
continue
- if btype == "heading":
+ if btype == "block_math":
+ result.append(_make_block_math(block))
+ elif btype == "heading":
result.append(_make_heading(block))
elif btype in ("paragraph", "block_text"):
item = _make_paragraph(block)
@@ -61,6 +63,17 @@ def _walk_blocks(blocks: list[dict], result: list[dict]) -> None:
_walk_blocks(block.get("children", []), result)
+def _make_block_math(block: dict) -> dict:
+ return {
+ "category": "math_block",
+ "paragraph": block.get("raw", ""),
+ "score": 1.0,
+ "inline_marks": [
+ {"text": block.get("raw", ""), "math": True, "math_display": True}
+ ],
+ }
+
+
def _make_heading(block: dict) -> dict:
level = block["attrs"]["level"]
category = f"heading_level_{min(level, 3)}"
@@ -77,8 +90,10 @@ def _make_heading(block: dict) -> dict:
def _make_paragraph(block: dict) -> dict | list[dict] | None:
children = block.get("children", [])
- # 仅含图片的段落
+ # 仅含图片或块级公式的段落
non_empty = [c for c in children if c["type"] != "softbreak"]
+ if len(non_empty) == 1 and non_empty[0]["type"] == "block_math":
+ return _make_block_math(non_empty[0])
if len(non_empty) == 1 and non_empty[0]["type"] == "image":
img_node = non_empty[0]
url = img_node["attrs"].get("url", "")
@@ -179,6 +194,10 @@ def walk(nodes):
parts.append(node["raw"].replace("\r\n", "\n").replace("\r", "\n"))
elif node["type"] == "codespan":
parts.append(node.get("raw", ""))
+ elif node["type"] == "inline_math":
+ parts.append(f"${node.get('raw', '')}$")
+ elif node["type"] == "block_math":
+ parts.append(f"$${node.get('raw', '')}$$")
elif node["type"] == "softbreak":
parts.append(" ")
elif node["type"] == "linebreak":
@@ -216,6 +235,17 @@ def walk(nodes, attrs): # noqa: C901
)
elif ntype == "codespan":
segments.append({"text": node.get("raw", ""), **attrs, "code": True})
+ elif ntype == "inline_math":
+ segments.append({"text": node.get("raw", ""), **attrs, "math": True})
+ elif ntype == "block_math":
+ segments.append(
+ {
+ "text": node.get("raw", ""),
+ **attrs,
+ "math": True,
+ "math_display": True,
+ }
+ )
elif ntype == "softbreak":
segments.append({"text": " ", **attrs})
elif ntype == "linebreak":
diff --git a/src/wordformat/math/__init__.py b/src/wordformat/math/__init__.py
new file mode 100644
index 0000000..179d735
--- /dev/null
+++ b/src/wordformat/math/__init__.py
@@ -0,0 +1,30 @@
+"""wordformat.math — LaTeX → OMML 公式渲染。
+
+将 LaTeX 数学表达式转换为 Word 原生 OMML (Office Math Markup Language),
+支持分数、根式、上下标、大型运算符、重音符号、希腊字母等。
+
+Usage:
+ from wordformat.math import add_display_math, add_inline_math, latex_to_omath
+
+ # 块级公式
+ add_display_math(doc, r"\frac{1}{2\\pi}\\int_{-\\infty}^{\\infty}e^{-x^2}dx")
+
+ # 内联公式
+ add_inline_math(paragraph, "其中 $E=mc^2$ 是质能方程")
+"""
+
+from wordformat.math.omml import (
+ add_display_math,
+ add_inline_math,
+ latex_to_omath,
+ latex_to_omath_para,
+ set_cell_math,
+)
+
+__all__ = [
+ "latex_to_omath",
+ "latex_to_omath_para",
+ "add_display_math",
+ "add_inline_math",
+ "set_cell_math",
+]
diff --git a/src/wordformat/math/omml.py b/src/wordformat/math/omml.py
new file mode 100644
index 0000000..7ee3788
--- /dev/null
+++ b/src/wordformat/math/omml.py
@@ -0,0 +1,210 @@
+"""
+OML Renderer — LaTeX math to OMML (Office Math Markup Language) for .docx files.
+
+Uses the pure-Python pipeline:
+ LaTeX -> latex2mathml -> MathML -> mathml2omml -> OMML
+"""
+
+from __future__ import annotations
+
+import re
+
+import latex2mathml.converter
+import mathml2omml
+from docx.oxml import OxmlElement, parse_xml
+from docx.oxml.ns import nsmap, qn
+
+# ---------------------------------------------------------------------------
+# Constants
+# ---------------------------------------------------------------------------
+
+MATH_FONT = "Cambria Math"
+_DEFAULT_SZ = "22"
+
+# mathml2omml 输出使用 m: 前缀但未声明命名空间,解析时需包裹 root 声明
+_WRAPPED_MATH_NS = f'{{body}}'
+
+
+# ---------------------------------------------------------------------------
+# LaTeX → OMML pipeline
+# ---------------------------------------------------------------------------
+
+
+def _latex_to_omml_xml(latex: str) -> str | None:
+ """Convert a LaTeX math string to OMML XML string via MathML."""
+ if not latex.strip():
+ return None
+ try:
+ mathml = latex2mathml.converter.convert(latex)
+ except Exception:
+ return None
+ if not mathml:
+ return None
+ try:
+ return mathml2omml.convert(mathml)
+ except Exception:
+ return None
+
+
+# ---------------------------------------------------------------------------
+# Element builders (python-docx OxmlElement)
+# ---------------------------------------------------------------------------
+
+
+def _make_wrpr():
+ """Build a w:rPr element with math font and size."""
+ wrpr = OxmlElement("w:rPr")
+ rf = OxmlElement("w:rFonts")
+ rf.set(qn("w:ascii"), MATH_FONT)
+ rf.set(qn("w:hAnsi"), MATH_FONT)
+ wrpr.append(rf)
+ sz = OxmlElement("w:sz")
+ sz.set(qn("w:val"), _DEFAULT_SZ)
+ wrpr.append(sz)
+ return wrpr
+
+
+def _make_ctrl_pr():
+ """Build an m:ctrlPr containing w:rPr."""
+ cp = OxmlElement("m:ctrlPr")
+ cp.append(_make_wrpr())
+ return cp
+
+
+def _post_process(omath) -> None:
+ """Fix up mathml2omml output to match Word/WPS expectations."""
+ # Add w:rPr to every m:r that lacks it (WPS needs font/size on each run)
+ for mr in om_elements(omath, "m:r"):
+ if mr.find(qn("w:rPr")) is None:
+ wrpr = _make_wrpr()
+ mrpr = mr.find(qn("m:rPr"))
+ if mrpr is not None:
+ idx = list(mr).index(mrpr)
+ mr.insert(idx + 1, wrpr)
+ else:
+ mr.insert(0, wrpr)
+
+ # Fix rad elements: add radPr, degHide, deg, ctrlPr as needed
+ for rad in om_elements(omath, "m:rad"):
+ radpr = rad.find(qn("m:radPr"))
+ if radpr is None:
+ radpr = OxmlElement("m:radPr")
+ rad.insert(0, radpr)
+ if radpr.find(qn("m:degHide")) is None and rad.find(qn("m:deg")) is None:
+ dg_hide = OxmlElement("m:degHide")
+ dg_hide.set(qn("m:val"), "1")
+ radpr.append(dg_hide)
+ if radpr.find(qn("m:ctrlPr")) is None:
+ radpr.append(_make_ctrl_pr())
+ deg = rad.find(qn("m:deg"))
+ if deg is None:
+ deg = OxmlElement("m:deg")
+ deg.append(_make_ctrl_pr())
+ rp_idx = list(rad).index(radpr)
+ rad.insert(rp_idx + 1, deg)
+ elif deg.find(qn("m:ctrlPr")) is None:
+ deg.append(_make_ctrl_pr())
+
+
+# ---------------------------------------------------------------------------
+# Public API
+# ---------------------------------------------------------------------------
+
+
+def latex_to_omath(latex: str) -> OxmlElement | None:
+ """Parse a LaTeX math expression into an ``m:oMath`` OMML element.
+
+ Returns ``None`` if the string is empty or parsing fails.
+ """
+ omml_xml = _latex_to_omml_xml(latex)
+ if not omml_xml:
+ return None
+ try:
+ wrapped = _WRAPPED_MATH_NS.format(body=omml_xml)
+ tree = parse_xml(wrapped.encode("utf-8"))
+ omath = tree.find(qn("m:oMath"))
+ if omath is not None:
+ _post_process(omath)
+ return omath
+ except Exception:
+ return None
+
+
+def latex_to_omath_para(latex: str) -> OxmlElement | None:
+ """Parse LaTeX into an ``m:oMathPara`` element (for display / block math)."""
+ om = latex_to_omath(latex)
+ if om is None:
+ return None
+ omathpara = OxmlElement("m:oMathPara")
+ omathpara.append(om)
+ return omathpara
+
+
+def add_display_math(doc, latex: str):
+ """Add a centered display-math paragraph to a ``python-docx`` Document.
+
+ The *latex* string should **not** contain ``$$`` delimiters.
+ Returns the new paragraph.
+ """
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
+
+ p = doc.add_paragraph()
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
+ ppr = p._p.find(qn("w:pPr"))
+ if ppr is not None:
+ ppr.append(_make_wrpr())
+ om = latex_to_omath(latex)
+ if om is not None:
+ omathpara = OxmlElement("m:oMathPara")
+ omathpara.append(om)
+ p._p.append(omathpara)
+ else:
+ from docx.shared import Pt
+
+ run = p.add_run(latex)
+ run.font.size = Pt(11)
+ return p
+
+
+def add_inline_math(paragraph, text: str):
+ """Render text containing ``$...$`` inline-math segments into *paragraph*.
+
+ Math segments are rendered as proper OMML ``m:oMath`` elements.
+ """
+ parts = re.split(r"(\$[^$\n]+\$)", text)
+ for part in parts:
+ if part.startswith("$") and part.endswith("$"):
+ math = part[1:-1].strip()
+ om = latex_to_omath(math)
+ if om is not None:
+ paragraph._p.append(om)
+ else:
+ paragraph.add_run(math)
+ elif part:
+ paragraph.add_run(part)
+
+
+def set_cell_math(cell, text: str):
+ """Render text with ``$...$`` / ``$$...$$`` math into a table cell."""
+ p = cell.paragraphs[0]
+ p.clear()
+ stripped = text.strip()
+ if stripped.startswith("$$") and stripped.endswith("$$"):
+ math = stripped[2:-2].strip()
+ om = latex_to_omath(math)
+ if om is not None:
+ omathpara = OxmlElement("m:oMathPara")
+ omathpara.append(om)
+ p._p.append(omathpara)
+ else:
+ p.add_run(math)
+ else:
+ add_inline_math(p, text)
+
+
+def om_elements(element, tag: str):
+ """Iterate over descendant elements matching *tag* (e.g. ``m:r``, ``m:rad``).
+
+ Uses pre-resolved qn for performance.
+ """
+ return element.iter(qn(tag))
diff --git a/src/wordformat/pipeline/stages_md.py b/src/wordformat/pipeline/stages_md.py
index 1207edc..7d0571b 100644
--- a/src/wordformat/pipeline/stages_md.py
+++ b/src/wordformat/pipeline/stages_md.py
@@ -15,6 +15,7 @@
from wordformat.log_config import logger
from wordformat.markdown.parser import parse_markdown
+from wordformat.math.omml import latex_to_omath, latex_to_omath_para
from wordformat.rules.node import FormatNode
from .context import FormatContext
@@ -57,16 +58,61 @@ def process(self, ctx: FormatContext) -> FormatContext:
value = node.value
text = value.get("paragraph", "") if isinstance(value, dict) else str(value)
category = value.get("category", "") if isinstance(value, dict) else ""
+ inline_marks = (
+ value.get("inline_marks", []) if isinstance(value, dict) else []
+ )
para = ctx.document.add_paragraph()
# figure_image: 路径留给 FigureImage._try_insert_image 处理,这里只建空段落
if category == "figure_image":
para.add_run("")
+ elif category == "math_block":
+ self._add_math_paragraph(para, text)
+ elif self._has_math(inline_marks):
+ self._add_math_runs(para, inline_marks)
else:
para.add_run(text.strip())
node.paragraph = para
return ctx
+ @staticmethod
+ def _has_math(inline_marks: list[dict]) -> bool:
+ return any(m.get("math") for m in inline_marks)
+
+ @staticmethod
+ def _add_math_paragraph(para, latex: str):
+ """创建块级公式段落(居中显示)。"""
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
+
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
+ omp = latex_to_omath_para(latex)
+ if omp is not None:
+ para._p.append(omp)
+ else:
+ para.add_run(latex)
+
+ @staticmethod
+ def _add_math_runs(para, inline_marks: list[dict]):
+ """根据 inline_marks 创建段落,数学片段渲染为 OMML 元素。"""
+ for seg in inline_marks:
+ if seg.get("math"):
+ latex = seg.get("text", "")
+ if seg.get("math_display"):
+ # 块级公式在段落中:使用 oMathPara
+ omp = latex_to_omath_para(latex)
+ if omp is not None:
+ para._p.append(omp)
+ else:
+ para.add_run(latex)
+ else:
+ om = latex_to_omath(latex)
+ if om is not None:
+ para._p.append(om)
+ else:
+ para.add_run(latex)
+ else:
+ para.add_run(seg.get("text", ""))
+
@staticmethod
def _flatten_tree_nodes(root_node: FormatNode) -> list[FormatNode]:
result: list[FormatNode] = []
diff --git a/tests/markdown/test_parser.py b/tests/markdown/test_parser.py
index 02686e2..95b072f 100644
--- a/tests/markdown/test_parser.py
+++ b/tests/markdown/test_parser.py
@@ -203,3 +203,47 @@ def test_linebreak_in_paragraph(self):
result = parse_markdown("第一行 \n第二行")
text = result[0]["paragraph"]
assert "\n" in text # linebreak preserved
+
+
+class TestMathParsing:
+ """测试 Markdown 中数学公式的解析。"""
+
+ def test_inline_math_segment(self):
+ result = parse_markdown("质能方程 $E=mc^2$ 是著名的")
+ assert len(result) == 1
+ assert result[0]["category"] == "body_text"
+ marks = result[0]["inline_marks"]
+ math_segs = [m for m in marks if m.get("math")]
+ assert len(math_segs) == 1
+ assert math_segs[0]["text"] == "E=mc^2"
+
+ def test_inline_math_preserved_in_text(self):
+ result = parse_markdown("The value $x^2$ is squared.")
+ text = result[0]["paragraph"]
+ assert "$x^2$" in text
+
+ def test_standalone_display_math_block(self):
+ result = parse_markdown("$$\n\\frac{1}{2}\n$$")
+ assert len(result) == 1
+ assert result[0]["category"] == "math_block"
+ assert result[0]["paragraph"] == "\\frac{1}{2}"
+
+ def test_display_math_in_paragraph_creates_display_segment(self):
+ """行内 $$...$$ 也应被识别"""
+ result = parse_markdown("公式 $$a^2 + b^2 = c^2$$ 是勾股定理")
+ marks = result[0]["inline_marks"]
+ math_segs = [m for m in marks if m.get("math_display")]
+ assert len(math_segs) >= 1
+
+ def test_multiple_inline_math_in_one_paragraph(self):
+ result = parse_markdown("$x_1$ 和 $x_2$ 都是变量")
+ marks = result[0]["inline_marks"]
+ math_segs = [m for m in marks if m.get("math")]
+ assert len(math_segs) == 2
+
+ def test_math_with_subscripts(self):
+ result = parse_markdown("权重 $w_{ij}$ 的计算公式如下。")
+ marks = result[0]["inline_marks"]
+ math_segs = [m for m in marks if m.get("math")]
+ assert len(math_segs) == 1
+ assert math_segs[0]["text"] == "w_{ij}"
diff --git a/tests/math/test_omml.py b/tests/math/test_omml.py
new file mode 100644
index 0000000..7226d87
--- /dev/null
+++ b/tests/math/test_omml.py
@@ -0,0 +1,170 @@
+#! /usr/bin/env python
+"""wordformat.math 模块单元测试。"""
+
+import pytest
+
+from wordformat.math import (
+ add_display_math,
+ add_inline_math,
+ latex_to_omath,
+ latex_to_omath_para,
+ set_cell_math,
+)
+
+
+class TestLatexToOmath:
+ """测试 LaTeX → OMML 核心转换。"""
+
+ def test_simple_expression(self):
+ om = latex_to_omath(r"x^2 + y^2")
+ assert om is not None
+ assert om.tag.endswith("}oMath")
+
+ def test_fraction(self):
+ om = latex_to_omath(r"\frac{1}{2}")
+ assert om is not None
+
+ def test_sqrt(self):
+ om = latex_to_omath(r"\sqrt{x^2 + y^2}")
+ assert om is not None
+
+ def test_sum(self):
+ om = latex_to_omath(r"\sum_{i=1}^{n} x_i")
+ assert om is not None
+
+ def test_greek_letters(self):
+ om = latex_to_omath(r"\alpha + \beta = \gamma")
+ assert om is not None
+
+ def test_subscript_and_superscript(self):
+ om = latex_to_omath(r"x_i^2 + w_j^{(k)}")
+ assert om is not None
+
+ def test_matrix(self):
+ om = latex_to_omath(r"\begin{matrix} a & b \\ c & d \end{matrix}")
+ assert om is not None
+
+ def test_empty_string(self):
+ assert latex_to_omath("") is None
+ assert latex_to_omath(" ") is None
+
+ def test_invalid_latex(self):
+ # Invalid LaTeX should return None rather than crash
+ result = latex_to_omath(r"\invalid_command_xyz{")
+ # Should not raise, may return None or partial result
+ assert result is None or result is not None
+
+ def test_aligned_equation(self):
+ om = latex_to_omath(r"\begin{aligned} x &= 1 \\ y &= 2 \end{aligned}")
+ # aligned environment may not be supported by all backends
+ # should not raise, even if result is None
+ assert om is None or om is not None
+
+
+class TestLatexToOmathPara:
+ """测试块级公式转换。"""
+
+ def test_wraps_in_omathpara(self):
+ omp = latex_to_omath_para(r"x = \frac{1}{2}")
+ assert omp is not None
+ assert "oMathPara" in omp.tag
+
+ def test_empty_returns_none(self):
+ assert latex_to_omath_para("") is None
+
+
+class TestAddDisplayMath:
+ """测试添加块级公式到文档。"""
+
+ def test_adds_paragraph(self):
+ from docx import Document
+
+ doc = Document()
+ p = add_display_math(doc, r"x^2 + y^2 = z^2")
+ assert p is not None
+ # 段落中应包含 OMML 元素
+ omath = p._p.find(".//{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath")
+ assert omath is not None
+
+ def test_empty_latex_adds_text_run(self):
+ from docx import Document
+
+ doc = Document()
+ p = add_display_math(doc, "")
+ assert p is not None
+ assert len(p.runs) >= 1
+
+
+class TestAddInlineMath:
+ """测试内联公式渲染。"""
+
+ def test_single_inline_math(self):
+ from docx import Document
+
+ doc = Document()
+ p = doc.add_paragraph()
+ add_inline_math(p, "The value is $x^2$ here.")
+ # 应有 text run 和 oMath 元素
+ runs = p.runs
+ assert len(runs) >= 1
+
+ def test_multiple_inline_math(self):
+ from docx import Document
+
+ doc = Document()
+ p = doc.add_paragraph()
+ add_inline_math(p, "$a$ and $b$ and $c$")
+ # Should not crash with multiple formulas
+ assert p._p is not None
+
+ def test_no_math(self):
+ from docx import Document
+
+ doc = Document()
+ p = doc.add_paragraph()
+ add_inline_math(p, "Plain text no formula.")
+ assert len(p.runs) == 1
+ assert p.runs[0].text == "Plain text no formula."
+
+ def test_invalid_math_falls_back_to_text(self):
+ from docx import Document
+
+ doc = Document()
+ p = doc.add_paragraph()
+ add_inline_math(p, r"$\invalid\command{}$")
+ # Should not raise
+ assert p._p is not None
+
+
+class TestSetCellMath:
+ """测试表格单元格数学公式。"""
+
+ def test_display_math_in_cell(self):
+ from docx import Document
+
+ doc = Document()
+ table = doc.add_table(rows=1, cols=1)
+ cell = table.cell(0, 0)
+ cell.paragraphs[0].add_run("placeholder") # ensure runs exist
+ set_cell_math(cell, r"$$x^2 + y^2$$")
+ assert cell.paragraphs[0]._p is not None
+
+ def test_inline_math_in_cell(self):
+ from docx import Document
+
+ doc = Document()
+ table = doc.add_table(rows=1, cols=1)
+ cell = table.cell(0, 0)
+ cell.paragraphs[0].add_run("placeholder")
+ set_cell_math(cell, r"The value is $E=mc^2$.")
+ assert cell.paragraphs[0]._p is not None
+
+ def test_plain_text_in_cell(self):
+ from docx import Document
+
+ doc = Document()
+ table = doc.add_table(rows=1, cols=1)
+ cell = table.cell(0, 0)
+ cell.paragraphs[0].add_run("placeholder")
+ set_cell_math(cell, "No math here.")
+ assert len(cell.paragraphs[0].runs) >= 1
From 6fe4049b959f0a9ca4c8d80266aca597c3a22625 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Mon, 13 Jul 2026 14:47:31 +0800
Subject: [PATCH 12/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20math=5Fblock=20?=
=?UTF-8?q?=E4=B8=A2=E5=A4=B1=20+=20=E6=B8=85=E7=90=86=E4=B8=8D=E5=AD=98?=
=?UTF-8?q?=E5=9C=A8=E7=9A=84=20[api]=20extra=20=E5=BC=95=E7=94=A8?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 MathBlock FormatNode (@register("math_block")),避免 node_factory 跳过
- tree_builder 将 math_block 排除在 heading 之外,防止嵌套吸收后续段落
- 移除 README/CLAUDE.md/docs 中所有 wordformat[api] 引用(extra 不存在)
---
CLAUDE.md | 5 ++---
README.md | 9 +++------
docs/installation.md | 13 +++----------
docs/usage.md | 5 ++---
src/wordformat/rules/__init__.py | 2 ++
src/wordformat/rules/math.py | 20 ++++++++++++++++++++
src/wordformat/structure/tree_builder.py | 10 +++++++---
7 files changed, 39 insertions(+), 25 deletions(-)
create mode 100644 src/wordformat/rules/math.py
diff --git a/CLAUDE.md b/CLAUDE.md
index 563bbf6..178fb13 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -57,10 +57,9 @@ pre-commit run --all-files
## Install variants
```bash
-pip install -e "." # core only
-pip install -e ".[api]" # core + FastAPI server
+pip install -e "." # core (includes FastAPI server)
pip install -e ".[test]" # core + pytest plugins
-pip install -e ".[dev]" # everything (api, test, pre-commit, ruff, pyinstaller)
+pip install -e ".[dev]" # everything (test, pre-commit, ruff, pyinstaller)
```
## Pre-commit hooks
diff --git a/README.md b/README.md
index 0349c2c..e6a417b 100644
--- a/README.md
+++ b/README.md
@@ -54,8 +54,7 @@ pip install pipx # 其他平台
# 安装 WordFormat
pipx install wordformat
-# 需要 Web 界面时安装 api 版本
-pipx install "wordformat[api]"
+pipx install wordformat
```
安装完成后直接使用 `wordf` 或 `wordformat` 命令。
@@ -64,8 +63,6 @@ pipx install "wordformat[api]"
```bash
pip install wordformat
-# 或
-pip install "wordformat[api]"
```
> 安装后如果提示"找不到 wordf 命令",说明 Python 脚本目录不在系统 PATH 中。
@@ -123,8 +120,8 @@ wordf md -d 论文.md -c 配置.yaml
### startapi命令行预览(推荐使用)
```bash
-# 下载api版
-pip install "wordformat[api]"
+# 安装
+pip install wordformat
# 启动 Web 可视化界面
wordf startapi
# 访问 http://127.0.0.1:8000
diff --git a/docs/installation.md b/docs/installation.md
index 43579d0..71057fc 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -19,13 +19,7 @@ pip install wordformat
uv pip install wordformat
```
-安装完成后即可使用 `wordf` 和 `wordformat` 命令。
-
-如需 API 服务功能,安装 api 可选依赖:
-
-```bash
-pip install wordformat[api]
-```
+安装完成后即可使用 `wordf` 和 `wordformat` 命令(含 API 服务)。
### 方式二:从源码安装(推荐开发者)
@@ -72,9 +66,8 @@ wordf --help
| 依赖组 | 安装命令 | 包含内容 |
|--------|----------|----------|
-| `api` | `pip install wordformat[api]` | FastAPI、uvicorn(API 服务) |
| `test` | `pip install wordformat[test]` | pytest(测试框架) |
-| `dev` | `pip install wordformat[dev]` | 以上全部 + ruff、pre-commit、pyinstaller |
+| `dev` | `pip install wordformat[dev]` | test + ruff、pre-commit、pyinstaller |
## 故障排查
@@ -92,5 +85,5 @@ wordf --help
### API 服务启动失败
-- 确保已安装 api 可选依赖:`pip install wordformat[api]`
+- 确保已安装 wordformat:`pip install wordformat`
- 检查端口未被其他服务占用
diff --git a/docs/usage.md b/docs/usage.md
index 3ea632e..b17da11 100644
--- a/docs/usage.md
+++ b/docs/usage.md
@@ -186,8 +186,7 @@ wordf af -d your_document.docx -c example/undergrad_thesis.yaml -f output/论文
### 启动方式
```bash
-# 使用 pip 安装时带上 api 依赖
-pip install "wordformat[api]"
+pip install wordformat
# 默认启动(127.0.0.1:8000,仅本机可访问)
wordf startapi
@@ -351,7 +350,7 @@ auto_format_thesis_document(
make server
# 或直接使用 uvicorn
-pip install -e ".[api]"
+pip install -e "."
python start_api.py
```
diff --git a/src/wordformat/rules/__init__.py b/src/wordformat/rules/__init__.py
index 902c3d9..a2dd6b2 100644
--- a/src/wordformat/rules/__init__.py
+++ b/src/wordformat/rules/__init__.py
@@ -16,6 +16,7 @@
from .caption import CaptionFigure, CaptionTable
from .heading import HeadingLevel1Node, HeadingLevel2Node, HeadingLevel3Node
from .keywords import KeywordsCN, KeywordsEN
+from .math import MathBlock
from .node import FormatNode
from .object import FigureImage, TableObject
from .references import ReferenceEntry, References
@@ -29,6 +30,7 @@
"AbstractContentEN",
"KeywordsEN",
"KeywordsCN",
+ "MathBlock",
"Acknowledgements",
"AcknowledgementsCN",
"BodyText",
diff --git a/src/wordformat/rules/math.py b/src/wordformat/rules/math.py
new file mode 100644
index 0000000..0dd8d40
--- /dev/null
+++ b/src/wordformat/rules/math.py
@@ -0,0 +1,20 @@
+#! /usr/bin/env python
+"""数学公式节点。公式本身由 OMML 渲染,Node 仅占位确保不被跳过。"""
+
+from wordformat.config.dotdict import BASE_FORMAT, deep_merge
+from wordformat.rules.node import FormatNode
+from wordformat.structure.registry import register
+
+
+@register("math_block", level=5)
+class MathBlock(FormatNode):
+ """块级公式"""
+
+ NODE_TYPE = "math.block"
+ NODE_LABEL = "块级公式"
+ DEFAULTS = deep_merge(
+ BASE_FORMAT,
+ {
+ "paragraph": {"alignment": "居中对齐"},
+ },
+ )
diff --git a/src/wordformat/structure/tree_builder.py b/src/wordformat/structure/tree_builder.py
index 713288b..5f2559b 100644
--- a/src/wordformat/structure/tree_builder.py
+++ b/src/wordformat/structure/tree_builder.py
@@ -8,13 +8,17 @@
from wordformat.structure.settings import CATEGORY_TO_CLASS, LEVEL_MAP
from wordformat.tree import Stack
+_NON_HEADING = {"body_text", "math_block", "figure_image", "table_object"}
+
+_HEADING_CATEGORIES = {
+ k: v for k, v in CATEGORY_TO_CLASS.items() if k not in _NON_HEADING
+}
+
class DocumentTreeBuilder:
"""负责将扁平列表构建成层级树结构"""
- HEADING_CATEGORIES = {
- k: v for k, v in CATEGORY_TO_CLASS.items() if k != "body_text"
- }
+ HEADING_CATEGORIES = _HEADING_CATEGORIES
CONFIG = {}
def __init__(self):