From 62db17dfb7f9e5bfb2b7d8af8538fd4ffb7c4f59 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Thu, 9 Jul 2026 14:03:12 +0800
Subject: [PATCH 1/4] =?UTF-8?q?#=20=E4=BC=98=E5=8C=96=E7=B3=BB=E7=BB=9F?=
=?UTF-8?q?=E4=BB=A3=E7=A0=81=E6=9E=B6=E6=9E=84?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 3 +-
configs/undergrad_thesis.yaml | 330 ---
pyproject.toml | 24 +-
pyproject.toml.bak | 147 +
src/wordformat/__init__.py | 17 +-
src/wordformat/api/__init__.py | 21 +-
src/wordformat/base.py | 14 +-
.../{set_tag.py => classify/tag.py} | 0
src/wordformat/cli.py | 35 +-
.../config/{config.py => loader.py} | 2 +-
.../config/{datamodel.py => models.py} | 43 +-
src/wordformat/hyperlinks.py | 16 +-
src/wordformat/log_config.py | 42 +-
src/wordformat/numbering.py | 218 +-
src/wordformat/pipeline/__init__.py | 10 +
src/wordformat/pipeline/context.py | 33 +
src/wordformat/pipeline/orchestrate.py | 92 +
src/wordformat/pipeline/stages.py | 578 ++++
src/wordformat/rules/abstract.py | 4 +-
src/wordformat/rules/acknowledgement.py | 2 +-
src/wordformat/rules/body.py | 2 +-
src/wordformat/rules/caption.py | 4 +-
src/wordformat/rules/heading.py | 2 +-
src/wordformat/rules/keywords.py | 36 +-
src/wordformat/rules/node.py | 10 +-
src/wordformat/rules/object.py | 8 +-
src/wordformat/rules/references.py | 2 +-
src/wordformat/set_style.py | 771 ------
.../{word_structure => structure}/__init__.py | 0
.../document_builder.py | 2 +-
.../node_factory.py | 2 +-
.../{word_structure => structure}/settings.py | 0
.../tree_builder.py | 4 +-
.../{word_structure => structure}/utils.py | 15 +-
.../style/{comment_format.py => comments.py} | 0
.../style/{style_enum.py => defs.py} | 112 +-
.../style/{check_format.py => diff.py} | 34 +-
.../style/{get_some.py => reader.py} | 4 +-
src/wordformat/style/{utils.py => units.py} | 17 +
.../style/{set_some.py => writer.py} | 42 +-
src/wordformat/style/xml_ops.py | 154 ++
src/wordformat/tree.py | 226 +-
src/wordformat/utils/__init__.py | 24 +
src/wordformat/utils/_docx.py | 66 +
src/wordformat/utils/_fs.py | 29 +
src/wordformat/{utils.py => utils/_text.py} | 243 +-
src/wordformat/utils/_yaml.py | 11 +
tests/conftest.py | 70 +-
tests/test_api.py | 410 +++
tests/test_caption_numbering.py | 26 +-
tests/test_classify.py | 230 ++
tests/test_coverage_boost.py | 92 +-
tests/test_integration.py | 586 +---
tests/{test_core.py => test_numbering.py} | 882 +-----
tests/test_rules.py | 15 +-
tests/test_style.py | 2356 -----------------
tests/test_style_defs.py | 773 ++++++
tests/test_style_diff.py | 525 ++++
tests/test_style_reader.py | 969 +++++++
tests/test_style_writer.py | 388 +++
tests/test_tree.py | 424 +++
tests/test_utils.py | 464 ++++
uv.lock | 57 +-
63 files changed, 6162 insertions(+), 5556 deletions(-)
delete mode 100644 configs/undergrad_thesis.yaml
create mode 100644 pyproject.toml.bak
rename src/wordformat/{set_tag.py => classify/tag.py} (100%)
rename src/wordformat/config/{config.py => loader.py} (98%)
rename src/wordformat/config/{datamodel.py => models.py} (91%)
create mode 100644 src/wordformat/pipeline/__init__.py
create mode 100644 src/wordformat/pipeline/context.py
create mode 100644 src/wordformat/pipeline/orchestrate.py
create mode 100644 src/wordformat/pipeline/stages.py
delete mode 100644 src/wordformat/set_style.py
rename src/wordformat/{word_structure => structure}/__init__.py (100%)
rename src/wordformat/{word_structure => structure}/document_builder.py (94%)
rename src/wordformat/{word_structure => structure}/node_factory.py (92%)
rename src/wordformat/{word_structure => structure}/settings.py (100%)
rename src/wordformat/{word_structure => structure}/tree_builder.py (94%)
rename src/wordformat/{word_structure => structure}/utils.py (79%)
rename src/wordformat/style/{comment_format.py => comments.py} (100%)
rename src/wordformat/style/{style_enum.py => defs.py} (90%)
rename src/wordformat/style/{check_format.py => diff.py} (97%)
rename src/wordformat/style/{get_some.py => reader.py} (99%)
rename src/wordformat/style/{utils.py => units.py} (89%)
rename src/wordformat/style/{set_some.py => writer.py} (94%)
create mode 100644 src/wordformat/style/xml_ops.py
create mode 100644 src/wordformat/utils/__init__.py
create mode 100644 src/wordformat/utils/_docx.py
create mode 100644 src/wordformat/utils/_fs.py
rename src/wordformat/{utils.py => utils/_text.py} (62%)
create mode 100644 src/wordformat/utils/_yaml.py
create mode 100644 tests/test_api.py
create mode 100644 tests/test_classify.py
rename tests/{test_core.py => test_numbering.py} (66%)
delete mode 100644 tests/test_style.py
create mode 100644 tests/test_style_defs.py
create mode 100644 tests/test_style_diff.py
create mode 100644 tests/test_style_reader.py
create mode 100644 tests/test_style_writer.py
create mode 100644 tests/test_tree.py
create mode 100644 tests/test_utils.py
diff --git a/.gitignore b/.gitignore
index 644309c..4f17a49 100644
--- a/.gitignore
+++ b/.gitignore
@@ -20,7 +20,8 @@ tmp
tmp/*
.trae
output/*
-temp
+temp/
+api.log
dist
build
src/wordformat/data/model/*.onnx
diff --git a/configs/undergrad_thesis.yaml b/configs/undergrad_thesis.yaml
deleted file mode 100644
index f5e1765..0000000
--- a/configs/undergrad_thesis.yaml
+++ /dev/null
@@ -1,330 +0,0 @@
-# ===========================================================================
-# 本科毕业论文格式配置示例
-# ===========================================================================
-# 本文件展示所有可配置字段及其默认值。
-# 使用 YAML 锚点 &global_format 定义全局基础格式,
-# 各章节通过 <<: *global_format 继承后覆写特定字段。
-#
-# 支持的单位格式:
-# 字号:小四 / 四号 / 三号 / 小二 / 五号 / 数值(pt)
-# 间距:0行 / 0.5行 / 1行 / 12磅 / 0.5cm
-# 缩进:2字符 / -2.2字符(悬挂) / 0.75cm / 24磅
-# 行距:单倍行距 / 1.5倍行距 / 2倍行距 / 最小值 / 固定值 / 多倍行距
-# 字体颜色:黑色 / 红色 / 蓝色 等
-# 对齐:左对齐 / 居中对齐 / 右对齐 / 两端对齐 / 分散对齐
-# ===========================================================================
-
-template_name: "山西晋中理工学院(毕业设计)"
-
-# ---------------------------------------------------------------------------
-# 预警开关:控制各字段差异是否在批注中显示(false=不显示该类型差异)
-# ---------------------------------------------------------------------------
-style_checks_warning:
- bold: true
- italic: false
- underline: false
- font_size: true
- font_name: true
- font_color: false
- 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
-
-# ---------------------------------------------------------------------------
-# 全局基础格式(其他节通过 <<: *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 # 下划线
-
-# ===========================================================================
-# 摘要(中英文)
-# ===========================================================================
-abstract:
- # -----------------------------------------------------------------------
- # 中文摘要
- # -----------------------------------------------------------------------
- chinese:
- # -- 中文摘要标题("摘要"二字)--
- chinese_title:
- <<: *global_format
- alignment: '居中对齐'
- first_line_indent: '0字符'
- chinese_font_name: '黑体'
- font_size: '四号'
- bold: false
-
- # -- 中文摘要正文 --
- chinese_content:
- <<: *global_format
- alignment: '两端对齐'
-
- # -----------------------------------------------------------------------
- # 英文摘要
- # -----------------------------------------------------------------------
- english:
- # -- 英文摘要标题("Abstract")--
- english_title:
- <<: *global_format
- alignment: '居中对齐'
- first_line_indent: '0字符'
- font_size: '四号'
- bold: false
-
- # -- 英文摘要正文 --
- english_content:
- <<: *global_format
- alignment: '两端对齐'
-
- # -----------------------------------------------------------------------
- # 关键词(中英文)
- # -----------------------------------------------------------------------
- keywords:
- chinese:
- # 从 global_format 继承,再覆写关键词特有字段
- <<: *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 # 最多关键词数
- trailing_punctuation:
- enabled: true
- forbidden_chars: ";,。、" # 中文关键词末尾禁止出现的标点
-
- english:
- <<: *global_format
- alignment: '两端对齐'
- first_line_indent: '2字符'
- font_size: '小四'
- label:
- <<: *global_format
- font_size: '四号'
- bold: false
- rules:
- keyword_count:
- enabled: true
- count_min: 3
- count_max: 5
-
-# ===========================================================================
-# 各级标题(章、节、小节)
-# ===========================================================================
-headings:
- # -- 一级标题(第1章)--
- level_1:
- <<: *global_format
- alignment: '居中对齐'
- first_line_indent: '0字符'
- chinese_font_name: '黑体'
- font_size: '小二'
- bold: false
- space_before: "0.5行"
- space_after: "0.5行"
- builtin_style_name: 'Heading 1'
-
- # -- 二级标题(1.1)--
- level_2:
- <<: *global_format
- alignment: '左对齐'
- first_line_indent: '0字符'
- chinese_font_name: '黑体'
- font_size: '三号'
- bold: false
- space_before: "0行"
- space_after: "0行"
- builtin_style_name: 'Heading 2'
-
- # -- 三级标题(1.1.1)--
- level_3:
- <<: *global_format
- alignment: '左对齐'
- first_line_indent: '0字符'
- chinese_font_name: '黑体'
- font_size: '小四'
- bold: false
- space_before: "0行"
- space_after: "0行"
- builtin_style_name: 'Heading 3'
-
-# ===========================================================================
-# 正文段落
-# ===========================================================================
-body_text:
- <<: *global_format
- rules:
- punctuation:
- enabled: true
-
-# ===========================================================================
-# 插图题注(Figure)
-# ===========================================================================
-figures:
- <<: *global_format
- font_size: '五号'
- builtin_style_name: '题注'
- alignment: '居中对齐'
- first_line_indent: '0字符'
- # -- 插图特有字段 --
- caption_prefix: '图' # 图注编号前缀
- # -- 图片段落格式(包含内联图片的段落)--
- 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: '表' # 表注编号前缀
- # -- 表格对象格式(表格整体对齐、环绕)--
- object:
- <<: *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
- separator: '.'
- label_number_space: false
-
-# ===========================================================================
-# 参考文献
-# ===========================================================================
-references:
- # -- 参考文献标题("参考文献")--
- title:
- <<: *global_format
- alignment: '居中对齐'
- first_line_indent: '0字符'
- chinese_font_name: '黑体'
- font_size: '三号'
- bold: false
-
- # -- 参考文献条目内容 --
- content:
- <<: *global_format
- alignment: '两端对齐'
- first_line_indent: '-2.2字符' # 悬挂缩进(负值=悬挂)
- left_indent: '0.26字符' # 文本之前
- chinese_font_name: '宋体'
- english_font_name: 'Times New Roman'
- font_size: '五号'
-
-# ===========================================================================
-# 致谢
-# ===========================================================================
-acknowledgements:
- # -- 致谢标题("致谢")--
- title:
- <<: *global_format
- alignment: '居中对齐'
- first_line_indent: '0字符'
- chinese_font_name: '黑体'
- font_size: '小二'
- bold: false
-
- # -- 致谢正文 --
- content:
- <<: *global_format
- alignment: '两端对齐'
- font_size: '五号'
-
-# ===========================================================================
-# 标题自动编号
-# ===========================================================================
-numbering:
- enabled: true # 总开关:是否启用自动编号
-
- # -- 题注编号校验/修正 --
- captions:
- enabled: true # 是否启用题注编号校验/修正
- separator: '.' # 章节号与题注编号间的分隔符
- label_number_space: false # 标签与编号间加空格(图 1.1 vs 图1.1)
-
- # -- 一级标题编号(如"第1章")--
- level_1:
- enabled: true
- template: '%1' # %1=本级序号
- suffix: 'space' # 编号后分隔符:tab / space / nothing
- numbering_indent: # [可选] 编号缩进,如 '0字符'
- text_indent: # [可选] 文本缩进(悬挂缩进),如 '0.75cm'
-
- # -- 二级标题编号(如"1.1")--
- level_2:
- enabled: true
- template: '%1.%2' # %1=上级序号, %2=本级序号
- suffix: 'space'
- numbering_indent:
- text_indent:
-
- # -- 三级标题编号(如"1.1.1")--
- level_3:
- enabled: true
- template: '%1.%2.%3'
- suffix: 'space'
- numbering_indent:
- text_indent:
-
- # -- 参考文献条目编号(如"[1]")--
- references:
- enabled: true
- template: '[%1]'
- suffix: 'space'
- numbering_indent:
- text_indent: # 悬挂缩进,如 '2.2字符'(优先级高于 references.content.first_line_indent)
diff --git a/pyproject.toml b/pyproject.toml
index 602251a..26fd853 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -24,16 +24,20 @@ classifiers = [
"Operating System :: OS Independent",
]
dependencies = [
+ "fastapi>=0.128.1",
"loguru>=0.7.3",
"numpy>=1.24.0",
"onnxruntime>=1.17.0",
"pydantic>=2.12.5",
"python-docx>=1.2.0",
"python-dotenv>=1.2.1",
+ "python-multipart>=0.0.22",
"pyyaml>=6.0.3",
"requests>=2.32.5",
+ "rich>=13.0.0",
"tenacity>=9.1.2",
"tokenizers>=0.22.2",
+ "uvicorn>=0.40.0",
"webcolors>=25.10.0",
]
@@ -92,14 +96,11 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
max-complexity = 10
[tool.ruff.lint.per-file-ignores]
-"__init__.py" = ["F401", "F403", "E501"]
-"tree.py" = ["T201", "C901"]
-"cli.py" = ["T201", "C901"]
-"body.py" = ["C901"]
-"heading.py" = ["C901"]
-"numbering.py" = ["C901"]
-"set_style.py" = ["C901"]
-"utils.py" = ["C901"]
+"__init__.py" = ["F401", "F403"]
+"cli.py" = ["C901"]
+"**/body.py" = ["C901"]
+"**/pipeline/stages.py" = ["C901"]
+"**/utils/_text.py" = ["C901"]
[tool.ruff.format]
quote-style = "double"
@@ -129,18 +130,13 @@ wordf = "wordformat.cli:main"
[project.optional-dependencies]
-api = [
- "fastapi>=0.128.1",
- "uvicorn>=0.40.0",
- "python-multipart>=0.0.22",
-]
test = [
"pytest>=9.0.2",
"pytest-asyncio>=1.3.0",
"pytest-cov>=7.0.0",
]
dev = [
- "wordformat[test,api]",
+ "wordformat[test]",
"pre-commit>=4.5.1",
"ruff>=0.14.14",
"pyinstaller>=6.18.0",
diff --git a/pyproject.toml.bak b/pyproject.toml.bak
new file mode 100644
index 0000000..8dfea52
--- /dev/null
+++ b/pyproject.toml.bak
@@ -0,0 +1,147 @@
+[project]
+name = "wordformat"
+version = "1.4.0"
+description = "论文格式自动化处理工具"
+readme = "README.md"
+license = "Apache-2.0"
+requires-python = ">=3.10"
+authors = [
+ {name = "afish", email = "1593699665@qq.com"},
+]
+keywords = ["word", "docx", "thesis", "format", "论文", "格式", "校验", "自动化"]
+classifiers = [
+ "Development Status :: 3 - Alpha",
+ "Intended Audience :: Developers",
+ "Intended Audience :: Education",
+ "Intended Audience :: Science/Research",
+ "Programming Language :: Python :: 3",
+ "Programming Language :: Python :: 3.10",
+ "Programming Language :: Python :: 3.11",
+ "Programming Language :: Python :: 3.12",
+ "Programming Language :: Python :: 3.13",
+ "Topic :: Scientific/Engineering",
+ "Topic :: Text Processing",
+ "Operating System :: OS Independent",
+]
+dependencies = [
+ "loguru>=0.7.3",
+ "numpy>=1.24.0",
+ "onnxruntime>=1.17.0",
+ "pydantic>=2.12.5",
+ "python-docx>=1.2.0",
+ "python-dotenv>=1.2.1",
+ "pyyaml>=6.0.3",
+ "requests>=2.32.5",
+ "tenacity>=9.1.2",
+ "tokenizers>=0.22.2",
+ "webcolors>=25.10.0",
+]
+
+[project.urls]
+Homepage = "https://github.com/AfishInLake/WordFormat"
+Repository = "https://github.com/AfishInLake/WordFormat"
+"Bug Tracker" = "https://github.com/AfishInLake/WordFormat/issues"
+
+[tool.ruff.lint]
+select = [
+ "E", # pycodestyle errors
+ "W", # pycodestyle warnings
+ "F", # pyflakes
+ "I", # isort
+ "C", # flake8-comprehensions
+ "B", # flake8-bugbear
+ "T20", # flake8-print
+ "TC", # flake8-type-checking
+ "ASYNC", # flake8-asyncio
+]
+ignore = [
+ "ASYNC210",
+ "ASYNC251",
+ "ASYNC230",
+ "TC003",
+ "TC002"
+]
+fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
+unfixable = []
+exclude = [
+ ".bzr",
+ ".direnv",
+ ".eggs",
+ ".git",
+ ".hg",
+ ".mypy_cache",
+ ".nox",
+ ".pants.d",
+ ".pytype",
+ ".ruff_cache",
+ ".svn",
+ ".tox",
+ ".venv",
+ "wordformat-skill/",
+ "__pypackages__",
+ "_build",
+ "buck-out",
+ "build",
+ "dist",
+ "node_modules",
+ "venv",
+]
+dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
+
+[tool.ruff.lint.mccabe]
+max-complexity = 10
+
+[tool.ruff.lint.per-file-ignores]
+"__init__.py" = ["F401", "F403", "E501"]
+"tree.py" = ["T201", "C901"]
+"cli.py" = ["T201", "C901"]
+"**/body.py" = ["C901"]
+"**/heading.py" = ["C901"]
+"numbering.py" = ["C901"]
+"**/pipeline/stages.py" = ["C901"]
+"**/utils/_text.py" = ["C901"]
+
+[tool.ruff.format]
+quote-style = "double"
+indent-style = "space"
+skip-magic-trailing-comma = false
+line-ending = "auto"
+
+[tool.ruff.lint.pycodestyle]
+max-line-length = 108
+max-doc-length = 200
+ignore-overlong-task-comments = true
+
+[build-system]
+requires = ["setuptools>=61.0", "wheel"]
+build-backend = "setuptools.build_meta"
+
+# 显式声明使用 src 布局(推荐)
+[tool.setuptools.packages.find]
+where = ["src"]
+
+[tool.setuptools.package-data]
+wordformat = ["data/**/*", "api/static/**/*"]
+
+[project.scripts]
+wordformat = "wordformat.cli:main"
+wordf = "wordformat.cli:main"
+
+
+[project.optional-dependencies]
+api = [
+ "fastapi>=0.128.1",
+ "uvicorn>=0.40.0",
+ "python-multipart>=0.0.22",
+]
+test = [
+ "pytest>=9.0.2",
+ "pytest-asyncio>=1.3.0",
+ "pytest-cov>=7.0.0",
+]
+dev = [
+ "wordformat[test,api]",
+ "pre-commit>=4.5.1",
+ "ruff>=0.14.14",
+ "pyinstaller>=6.18.0",
+]
diff --git a/src/wordformat/__init__.py b/src/wordformat/__init__.py
index 20c4467..8ef2675 100644
--- a/src/wordformat/__init__.py
+++ b/src/wordformat/__init__.py
@@ -3,11 +3,24 @@
# @Author : afish
# @File : __init__.py.py
from wordformat._version import __version__
-from wordformat.set_style import auto_format_thesis_document
-from wordformat.set_tag import set_tag_main
__all__ = [
"__version__",
"auto_format_thesis_document",
"set_tag_main",
]
+
+
+def __getattr__(name: str):
+ """懒加载重导出,避免 import wordformat 时触发整个 ONNX / pipeline 依赖链。"""
+ if name == "auto_format_thesis_document":
+ from wordformat.pipeline.orchestrate import (
+ auto_format_thesis_document as _fn,
+ )
+
+ return _fn
+ if name == "set_tag_main":
+ from wordformat.classify.tag import set_tag_main as _fn
+
+ return _fn
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
diff --git a/src/wordformat/api/__init__.py b/src/wordformat/api/__init__.py
index 07bf362..4ae8c31 100644
--- a/src/wordformat/api/__init__.py
+++ b/src/wordformat/api/__init__.py
@@ -14,9 +14,10 @@
from pydantic import BaseModel
from starlette.responses import FileResponse
+from wordformat.classify.tag import set_tag_main
+
# 复用原有项目的核心函数和校验工具
-from wordformat.set_style import auto_format_thesis_document
-from wordformat.set_tag import set_tag_main
+from wordformat.pipeline.orchestrate import auto_format_thesis_document
from wordformat.settings import BASE_DIR, SERVER_HOST, VERSION
# ---------------------- 初始化FastAPI应用 ----------------------
@@ -38,14 +39,15 @@
)
# ---------------------- 全局配置 ----------------------
-# 临时文件目录(存储上传的docx/配置文件、生成的json),自动创建
TEMP_DIR = BASE_DIR / "temp"
-TEMP_DIR.mkdir(parents=True, exist_ok=True)
-logger.info(f"临时文件目录:{TEMP_DIR}")
-# 输出文件目录(存储校验/格式化后的docx)
OUTPUT_DIR = BASE_DIR / "output"
-OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
-logger.info(f"输出文件目录:{OUTPUT_DIR}") # 修复原日志笔误
+
+
+def _ensure_dirs() -> None:
+ """按需创建临时/输出目录(避免 import 时产生副作用)。"""
+ for d in (TEMP_DIR, OUTPUT_DIR):
+ if not d.exists():
+ d.mkdir(parents=True, exist_ok=True)
# ---------------------- 数据模型(接口参数校验) ----------------------
@@ -103,6 +105,7 @@ async def api_generate_json(
对应原命令行generate-json模式:仅生成JSON,不执行校验/格式化
- 上传docx和yaml配置文件,服务端自动生成JSON并返回数据
"""
+ _ensure_dirs()
try:
filename = docx_file.filename.lower()
@@ -148,6 +151,7 @@ async def api_check_format(
对应原命令行check-format模式:仅执行格式校验,生成【原文件名+--标注版.docx】
- 基于函数返回的真实路径拼接下载链接,保证路径100%匹配
"""
+ _ensure_dirs()
try:
# 1. 保存上传文件(仅返回实际路径)
docx_path = save_upload_file(docx_file, TEMP_DIR)
@@ -201,6 +205,7 @@ async def api_apply_format(
对应原命令行apply-format模式:自动应用格式,生成【原文件名+--修改版.docx】
- 基于函数返回的真实路径拼接下载链接,彻底解决404问题
"""
+ _ensure_dirs()
try:
# 1. 保存上传文件(仅返回实际路径)
docx_path = save_upload_file(docx_file, TEMP_DIR)
diff --git a/src/wordformat/base.py b/src/wordformat/base.py
index c745024..7b76fd2 100644
--- a/src/wordformat/base.py
+++ b/src/wordformat/base.py
@@ -10,17 +10,7 @@
from wordformat.agent.onnx_infer import onnx_batch_infer, onnx_single_infer
from wordformat.settings import BATCH_SIZE
-from wordformat.utils import get_paragraph_numbering_text
-
-
-def _para_contains_image(para) -> bool:
- """检查段落是否包含内联图片(w:drawing)。"""
- from docx.oxml.ns import qn
-
- for r in para._element.findall(qn("w:r")):
- if r.find(qn("w:drawing")) is not None:
- return True
- return False
+from wordformat.utils import get_paragraph_numbering_text, para_contains_image
class DocxBase:
@@ -57,7 +47,7 @@ def parse(self) -> list[dict]:
result = [None] * len(all_paras)
for i, para in enumerate(all_paras):
if i not in text_indices:
- has_image = _para_contains_image(para)
+ has_image = para_contains_image(para)
result[i] = {
"category": "figure_image" if has_image else "body_text",
"score": 1.0,
diff --git a/src/wordformat/set_tag.py b/src/wordformat/classify/tag.py
similarity index 100%
rename from src/wordformat/set_tag.py
rename to src/wordformat/classify/tag.py
diff --git a/src/wordformat/cli.py b/src/wordformat/cli.py
index 43fc021..1fbe2e8 100644
--- a/src/wordformat/cli.py
+++ b/src/wordformat/cli.py
@@ -10,12 +10,15 @@
from pathlib import Path
from loguru import logger
+from rich.console import Console
-from wordformat.set_style import auto_format_thesis_document
-from wordformat.set_tag import set_tag_main
+from wordformat.classify.tag import set_tag_main
+from wordformat.pipeline.orchestrate import auto_format_thesis_document
from wordformat.settings import VERSION
from wordformat.tree import print_tree
+console = Console()
+
def validate_file(
path: str, name: str, allowed_extensions: list[str] | None = None
@@ -42,7 +45,7 @@ def _show_config():
"""
from pydantic import BaseModel
- from wordformat.config.datamodel import NodeConfigRoot
+ from wordformat.config.models import NodeConfigRoot
def _default_str(info):
if info.default is not None:
@@ -113,27 +116,27 @@ def _walk(cls, path="", depth=0):
if _is_basemodel_subclass(target) and target_name not in LEAF_TYPES:
# 容器模型:打印节标题后递归
label = desc_text or target_name
- print(f"\n{' ' * depth}[{field_path}] — {label}")
+ console.print(f"\n{' ' * depth}[{field_path}] — {label}")
_walk(target, field_path, depth + 1)
elif _is_basemodel_subclass(target) and target_name in LEAF_TYPES:
# 叶子配置模型:展开打印字段,不递归
label = desc_text or target_name
- print(f"\n{' ' * depth}[{field_path}] — {label}")
+ console.print(f"\n{' ' * depth}[{field_path}] — {label}")
_print_leaf_fields(target, depth + 1)
elif target is str:
- print(
+ console.print(
f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}"
)
elif target is bool:
- print(
+ console.print(
f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}"
)
elif target is int:
- print(
+ console.print(
f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}"
)
elif target is float:
- print(
+ console.print(
f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}"
)
@@ -142,16 +145,16 @@ def _print_leaf_fields(cls, depth):
target = _resolve_type(info.annotation)
if _is_basemodel_subclass(target):
# 嵌套的叶子模型(如 label, content)
- print(f"{' ' * depth} [{name}] — {_desc(info)}")
+ console.print(f"{' ' * depth} [{name}] — {_desc(info)}")
_print_leaf_fields(target, depth + 1)
else:
- print(
+ console.print(
f"{' ' * depth} {name:<28s} {_desc(info):<50s} 默认: {_default_str(info)}"
)
- print("config.yaml 完整字段参考(自动生成自 NodeConfigRoot 模型)\n")
+ console.print("config.yaml 完整字段参考(自动生成自 NodeConfigRoot 模型)\n")
_walk(NodeConfigRoot)
- print()
+ console.print()
def main():
@@ -159,11 +162,11 @@ def main():
setup_logger()
- print(f"WordFormat v{VERSION}")
+ console.print(f"WordFormat v{VERSION}")
# 无参数直接展示完整帮助
if len(sys.argv) == 1:
- print("""📝 论文格式自动工具(极简命令)
+ console.print("""📝 论文格式自动工具(极简命令)
==================================================
【极简命令】
wordf gj 生成文档JSON结构
@@ -379,7 +382,7 @@ def _validate_port(x):
import yaml
- from wordformat.config.datamodel import NodeConfigRoot
+ from wordformat.config.models import NodeConfigRoot
cfg = NodeConfigRoot()
with warnings.catch_warnings():
diff --git a/src/wordformat/config/config.py b/src/wordformat/config/loader.py
similarity index 98%
rename from src/wordformat/config/config.py
rename to src/wordformat/config/loader.py
index 3d62f43..75f0834 100644
--- a/src/wordformat/config/config.py
+++ b/src/wordformat/config/loader.py
@@ -10,7 +10,7 @@
from wordformat.utils import load_yaml_with_merge
-from .datamodel import NodeConfigRoot
+from .models import NodeConfigRoot
# 单例配置存储(私有变量,仅通过接口访问)
_global_config: Optional[NodeConfigRoot] = None
diff --git a/src/wordformat/config/datamodel.py b/src/wordformat/config/models.py
similarity index 91%
rename from src/wordformat/config/datamodel.py
rename to src/wordformat/config/models.py
index 551eab8..ca61363 100644
--- a/src/wordformat/config/datamodel.py
+++ b/src/wordformat/config/models.py
@@ -7,7 +7,7 @@
from pydantic import BaseModel, Field, field_validator, model_validator
-from wordformat.style.style_enum import ChineseFontType, FontSizeLabel
+from wordformat.style.defs import ChineseFontType, FontSizeLabel
# -------------------------- 基础类型定义 --------------------------
# 对齐方式类型
@@ -422,9 +422,50 @@ class NumberingConfig(BaseModel):
# -------------------------- 根配置模型 --------------------------
+def _resolve_builtin_style_name(cfg) -> str | None:
+ """将 builtin_style_name 字段解析为英文样式名(如 '正文' → 'Normal')。"""
+ from wordformat.style.defs import BuiltInStyle
+
+ raw = getattr(cfg, "builtin_style_name", None)
+ if not raw:
+ return None
+ try:
+ return BuiltInStyle(raw).rel_value
+ except Exception:
+ return raw
+
+
+def _walk_config_for_styles(obj, style_map: dict[str, object]) -> None:
+ """递归遍历配置树,将 GlobalFormatConfig 按样式名收集到 style_map。"""
+ if isinstance(obj, GlobalFormatConfig):
+ eng_name = _resolve_builtin_style_name(obj)
+ if eng_name:
+ style_map[eng_name] = obj
+ return
+ if not isinstance(obj, BaseModel):
+ return
+ for val in (getattr(obj, f_name) for f_name in type(obj).model_fields):
+ if isinstance(val, BaseModel):
+ _walk_config_for_styles(val, style_map)
+ elif isinstance(val, dict):
+ for v in val.values():
+ if isinstance(v, BaseModel):
+ _walk_config_for_styles(v, style_map)
+
+
class NodeConfigRoot(BaseModel):
"""配置根节点模型"""
+ def collect_style_configs(self) -> dict[str, object]:
+ """遍历配置树,收集 (英文样式名 → GlobalFormatConfig) 映射。
+
+ 配置优先级:后序覆盖前序。global_format 先被遍历,
+ 具体段后遍历,同一样式名时具体配置覆盖全局配置。
+ """
+ style_map: dict[str, object] = {}
+ _walk_config_for_styles(self, style_map)
+ return style_map
+
template_name: str = Field(
default="未知模板", description="模板名称(用于检测报告中显示)"
)
diff --git a/src/wordformat/hyperlinks.py b/src/wordformat/hyperlinks.py
index 3630d7c..9e7ebcd 100644
--- a/src/wordformat/hyperlinks.py
+++ b/src/wordformat/hyperlinks.py
@@ -9,7 +9,6 @@
"""
import re
-from collections import deque
from copy import deepcopy
from docx.oxml import OxmlElement
@@ -17,6 +16,7 @@
from loguru import logger
from wordformat.rules.body import _CITATION_PATTERN
+from wordformat.tree import bfs_walk
def create_citation_hyperlinks(root_node, document):
@@ -65,25 +65,15 @@ def create_citation_hyperlinks(root_node, document):
def _collect_nodes_of_type(root_node, node_type, result, collect_body=False):
- """遍历树,收集指定类型的节点(按文档顺序)。
-
- Args:
- root_node: 树根节点
- node_type: 要收集的节点类型(如 ReferenceEntry),None 时按 collect_body 收集
- result: 收集结果列表(原地修改)
- collect_body: 当 node_type 为 None 时,收集 BodyText 节点
- """
+ """遍历树,收集指定类型的节点(按文档顺序)。"""
from wordformat.rules.body import BodyText
- queue = deque([root_node])
- while queue:
- node = queue.popleft()
+ for node in bfs_walk(root_node):
if node_type is not None:
if isinstance(node, node_type):
result.append(node)
elif collect_body and isinstance(node, BodyText):
result.append(node)
- queue.extend(node.children)
# ---------------------------------------------------------------------------
diff --git a/src/wordformat/log_config.py b/src/wordformat/log_config.py
index 417d8f2..a0b8df3 100644
--- a/src/wordformat/log_config.py
+++ b/src/wordformat/log_config.py
@@ -8,36 +8,34 @@
from loguru import logger
-def setup_logger():
- """配置日志"""
- # 确定基础目录
- if getattr(sys, "frozen", False):
- BASE_DIR = Path(sys.executable).parent
- else:
- BASE_DIR = Path(__file__).parent.parent.parent
+def setup_logger(log_dir: str | None = None):
+ """配置日志。
- LOG_FILE = BASE_DIR / "api.log"
-
- # 移除默认输出
+ Args:
+ log_dir: 日志文件目录。为 None 时仅输出到控制台(测试/开发模式)。
+ """
logger.remove()
- # 添加文件输出
- # 注意:不使用 enqueue=True,因为在沙箱环境中 multiprocessing.SimpleQueue()
- # 会因缺少 /dev/shm 而失败(FileNotFoundError)
- logger.add(
- LOG_FILE, # 日志文件
- rotation="500 MB", # 按大小分割
- retention="7 days", # 保留7天
- encoding="utf-8",
- backtrace=True, # 显示完整堆栈
- diagnose=True, # 显示变量信息
- )
+ # 文件输出(仅指定目录时启用)
+ if log_dir:
+ log_path = Path(log_dir) / "api.log"
+ log_path.parent.mkdir(parents=True, exist_ok=True)
+ logger.add(
+ str(log_path),
+ rotation="500 MB",
+ retention="7 days",
+ encoding="utf-8",
+ level="DEBUG",
+ backtrace=True,
+ diagnose=True,
+ )
- # 添加控制台输出(非 -w 模式)
+ # 控制台输出:仅 INFO 及以上(DEBUG 不刷屏)
if not sys.stdout.closed:
logger.add(
sys.stdout,
colorize=True,
+ level="INFO",
format=(
"{time:YYYY-MM-DD HH:mm:ss} | "
"{level: <8} | "
diff --git a/src/wordformat/numbering.py b/src/wordformat/numbering.py
index db0453c..cc04d6c 100644
--- a/src/wordformat/numbering.py
+++ b/src/wordformat/numbering.py
@@ -19,7 +19,7 @@
from docx.shared import Cm, Inches, Mm, Pt
from loguru import logger
-from wordformat.style.utils import extract_unit_from_string
+from wordformat.style.units import extract_unit_from_string
# EMU 到 twips 的换算系数
_EMU_PER_TWIP = 635
@@ -223,167 +223,76 @@ def _set_indent_value(ind_element, indent_type: str, value_str: str):
# 中文字号到半磅值(half-points)的映射
# Word 内部字号单位为 half-points(半磅),如 12pt = 24 half-points
-_FONT_SIZE_HALF_PT_MAP = {
- "一号": 52,
- "小一": 48,
- "二号": 44,
- "小二": 36,
- "三号": 32,
- "小三": 30,
- "四号": 28,
- "小四": 24,
- "五号": 21,
- "小五": 18,
- "六号": 15,
- "七号": 11,
-}
-
-
def _build_numbering_rPr(headings_config, level_key: str):
- """
- 为编号构建 w:rPr 元素,使编号的字体/字号跟随标题样式。
-
- Args:
- headings_config: 标题配置对象(config.headings)
- level_key: "level_1" / "level_2" / "level_3"
+ """为编号构建 w:rPr 元素,使编号的字体/字号跟随标题样式。"""
+ from wordformat.style.defs import FontColor, FontSize
+ from wordformat.style.xml_ops import (
+ rPr_set_bold,
+ rPr_set_font,
+ rPr_set_font_color,
+ rPr_set_font_size,
+ )
- Returns:
- w:rPr OxmlElement 或 None
- """
level_cfg = getattr(headings_config, level_key, None)
if level_cfg is None:
return None
- # 获取字体、字号、颜色、加粗
chinese_font = getattr(level_cfg, "chinese_font_name", None)
english_font = getattr(level_cfg, "english_font_name", None)
font_size = getattr(level_cfg, "font_size", None)
font_color = getattr(level_cfg, "font_color", None)
bold = getattr(level_cfg, "bold", None)
- if (
- not chinese_font
- and not english_font
- and not font_size
- and not font_color
- and bold is None
- ):
+ if not any([chinese_font, english_font, font_size, font_color, bold is not None]):
return None
rPr = OxmlElement("w:rPr")
- # 设置字体
- rFonts = OxmlElement("w:rFonts")
- has_font = False
- if chinese_font:
- rFonts.set(qn("w:eastAsia"), chinese_font)
- has_font = True
- if english_font:
- rFonts.set(qn("w:ascii"), english_font)
- rFonts.set(qn("w:hAnsi"), english_font)
- has_font = True
- if has_font:
- rPr.append(rFonts)
-
- # 设置字号(Word 内部单位为 half-points)
+ if chinese_font or english_font:
+ rPr_set_font(rPr, cn_name=chinese_font, en_name=english_font)
+
if font_size:
- half_pt = _FONT_SIZE_HALF_PT_MAP.get(font_size)
- if half_pt is None:
- try:
- half_pt = int(float(font_size) * 2)
- except (ValueError, TypeError):
- half_pt = None
- if half_pt is not None:
- sz = OxmlElement("w:sz")
- sz.set(qn("w:val"), str(half_pt))
- rPr.append(sz)
- szCs = OxmlElement("w:szCs")
- szCs.set(qn("w:val"), str(half_pt))
- rPr.append(szCs)
-
- # 设置字体颜色(避免继承主题蓝色)
- if font_color:
- from wordformat.style.style_enum import FontColor
+ try:
+ pt_val = FontSize(font_size).rel_value
+ if pt_val == font_size:
+ # 非中文标签(如 "15.5" 或 14),直接作为磅值使用
+ pt_val = float(font_size)
+ rPr_set_font_size(rPr, pt_val)
+ except (ValueError, TypeError):
+ pass
+ if font_color:
try:
- fc = FontColor(font_color)
- rgb = fc.rel_value
- hex_color = f"{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
- color = OxmlElement("w:color")
- color.set(qn("w:val"), hex_color)
- rPr.append(color)
+ rPr_set_font_color(rPr, FontColor(font_color).rel_value)
except Exception:
pass
- # 设置加粗
- if bold is True:
- b = OxmlElement("w:b")
- rPr.append(b)
- bCs = OxmlElement("w:bCs")
- rPr.append(bCs)
+ if bold is not None:
+ rPr_set_bold(rPr, bold)
return rPr
-def process_heading_numbering(root_node, document, config, headings_config=None):
- """
- 处理所有标题节点和参考文献条目节点的编号:自动清除手动编号 + 应用自动编号。
-
- Args:
- root_node: 文档树根节点
- document: docx Document 对象
- config: NumberingConfig 配置对象
- headings_config: 标题配置(用于编号字体/字号跟随标题样式)
- """
- if not config.enabled:
- return
-
- # 创建编号定义(标题 + 参考文献)
- definitions = create_numbering_definition(document, config, headings_config)
- heading_num_map = definitions.get("headings", {})
- reference_num_id = definitions.get("references")
-
- # 标题节点类型到配置级别和 ilvl 的映射
- heading_map = {
- "level_1": ("0", "level_1"),
- "level_2": ("1", "level_2"),
- "level_3": ("2", "level_3"),
- }
-
- from wordformat.config.datamodel import NumberingLevelConfig
-
- ref_config = getattr(config, "references", None)
- ref_enabled = isinstance(ref_config, NumberingLevelConfig) and ref_config.enabled
-
+def _traverse_numbering(
+ node, heading_map, heading_num_map, config, ref_enabled, reference_num_id, counters
+):
+ """递归遍历节点树,应用标题和参考文献编号。"""
from wordformat.rules.references import ReferenceEntry
- heading_count = 0
- ref_count = 0
-
- def traverse(node):
- nonlocal heading_count, ref_count
- category = (
- node.value.get("category", "") if isinstance(node.value, dict) else ""
- )
-
- paragraph = getattr(node, "paragraph", None)
- if not paragraph:
- for child in node.children:
- traverse(child)
- return
+ category = node.value.get("category", "") if isinstance(node.value, dict) else ""
+ paragraph = getattr(node, "paragraph", None)
+ if paragraph:
# 处理标题节点
for level_key, (ilvl_str, config_key) in heading_map.items():
if category == f"heading_{level_key}":
level_config = getattr(config, config_key, None)
- if not level_config or not level_config.enabled:
- break
-
- _auto_strip_numbering(paragraph, int(ilvl_str))
- num_id = heading_num_map.get(config_key)
- if num_id:
- apply_auto_numbering(paragraph, num_id, ilvl_str)
- heading_count += 1
+ if level_config and level_config.enabled:
+ _auto_strip_numbering(paragraph, int(ilvl_str))
+ num_id = heading_num_map.get(config_key)
+ if num_id:
+ apply_auto_numbering(paragraph, num_id, ilvl_str)
+ counters["heading"] += 1
break
# 处理参考文献条目节点
@@ -391,13 +300,52 @@ def traverse(node):
_strip_reference_numbering(paragraph)
if reference_num_id:
apply_auto_numbering(paragraph, reference_num_id, "0")
- ref_count += 1
+ counters["ref"] += 1
+
+ for child in node.children:
+ _traverse_numbering(
+ child,
+ heading_map,
+ heading_num_map,
+ config,
+ ref_enabled,
+ reference_num_id,
+ counters,
+ )
+
+
+def process_heading_numbering(root_node, document, config, headings_config=None):
+ """处理所有标题和参考文献条目的自动编号。"""
+ if not config.enabled:
+ return
+
+ definitions = create_numbering_definition(document, config, headings_config)
+
+ heading_map = {
+ "level_1": ("0", "level_1"),
+ "level_2": ("1", "level_2"),
+ "level_3": ("2", "level_3"),
+ }
- for child in node.children:
- traverse(child)
+ from wordformat.config.models import NumberingLevelConfig
- traverse(root_node)
- logger.info(f"编号处理完成:标题 {heading_count} 个,参考文献条目 {ref_count} 个")
+ ref_config = getattr(config, "references", None)
+ ref_enabled = isinstance(ref_config, NumberingLevelConfig) and ref_config.enabled
+
+ counters = {"heading": 0, "ref": 0}
+ _traverse_numbering(
+ root_node,
+ heading_map,
+ definitions.get("headings", {}),
+ config,
+ ref_enabled,
+ definitions.get("references"),
+ counters,
+ )
+ logger.info(
+ f"编号处理完成:标题 {counters['heading']} 个,"
+ f"参考文献条目 {counters['ref']} 个"
+ )
def apply_auto_numbering(paragraph, num_id: str, ilvl: str = "0"):
@@ -530,7 +478,7 @@ def create_numbering_definition(document, config, headings_config=None) -> dict:
# ========================
# 2. 参考文献条目编号定义
# ========================
- from wordformat.config.datamodel import NumberingLevelConfig
+ from wordformat.config.models import NumberingLevelConfig
ref_config = getattr(config, "references", None)
if (
diff --git a/src/wordformat/pipeline/__init__.py b/src/wordformat/pipeline/__init__.py
new file mode 100644
index 0000000..1b9fb34
--- /dev/null
+++ b/src/wordformat/pipeline/__init__.py
@@ -0,0 +1,10 @@
+#! /usr/bin/env python
+# @Time : 2026/7/7 12:24
+# @Author : afish
+# @File : __init__.py.py
+from .context import FormatContext, PipelineStage
+
+__all__ = [
+ "FormatContext",
+ "PipelineStage",
+]
diff --git a/src/wordformat/pipeline/context.py b/src/wordformat/pipeline/context.py
new file mode 100644
index 0000000..e2e2f71
--- /dev/null
+++ b/src/wordformat/pipeline/context.py
@@ -0,0 +1,33 @@
+#! /usr/bin/env python
+# @Time : 2026/7/7 12:30
+# @Author : afish
+# @File : Structure.py
+
+from dataclasses import dataclass
+from pathlib import Path
+from typing import Protocol
+
+from docx.document import Document as DocumentObject
+
+from wordformat.config.models import NodeConfigRoot
+from wordformat.rules.node import FormatNode
+
+
+@dataclass
+class FormatContext:
+ json_path: str
+ docx_path: str
+ check: bool
+ config_path: str
+ save_dir: str = "/output"
+ # 运行时对象(由各阶段填充)
+ document: DocumentObject | None = None
+ root_node: FormatNode = None
+ config_model: NodeConfigRoot = NodeConfigRoot()
+ output_path: Path | str = ""
+
+
+class PipelineStage(Protocol):
+ """pipeline stage"""
+
+ def process(self, ctx: FormatContext) -> FormatContext: ...
diff --git a/src/wordformat/pipeline/orchestrate.py b/src/wordformat/pipeline/orchestrate.py
new file mode 100644
index 0000000..4b513e0
--- /dev/null
+++ b/src/wordformat/pipeline/orchestrate.py
@@ -0,0 +1,92 @@
+#! /usr/bin/env python
+# @Time : 2026/1/11 19:51
+# @Author : afish
+# @File : set_style.py
+from __future__ import annotations
+
+from typing import TYPE_CHECKING, Optional
+
+if TYPE_CHECKING:
+ from wordformat.pipeline import PipelineStage
+
+from wordformat.pipeline.context import FormatContext
+from wordformat.pipeline.stages import (
+ DocumentSavingStage,
+ FormattingExecutionStage,
+ LoadConfigStage,
+ LoadDocxStage,
+ ParagraphAlignmentStage,
+ PostProcessingStage,
+ StyleDefinitionFixStage,
+ SummaryGenerationStage,
+ TreeBuildingStage,
+ TreeNormalizationStage,
+)
+
+
+def auto_format_thesis_document(
+ jsonpath: str | list,
+ docxpath: str,
+ configpath: Optional[str] = None,
+ savepath: str = "output/",
+ check=True,
+):
+ """自动对学位论文文档进行格式校验与批注。
+
+ 该函数根据结构化 JSON 描述和 YAML 格式配置,对指定的 Word 文档进行格式合规性检查,
+ 并在不符合规范的位置插入批注(comments)。主要用于学术论文(如本科/硕士/博士论文)
+ 的自动化格式审查。
+
+ 流程说明:
+ 1. 从 JSON 文件加载文档逻辑结构树;
+ 2. 加载 Word 文档,并将每个非空段落匹配到对应的结构节点;
+ 4. 对特定子树(如中英文摘要、参考文献)执行节点提升操作,确保内容节点正确挂载;
+ 5. 遍历所有结构节点,依据配置文件中的格式规则进行校验,并在文档中添加批注;
+ 6. 保存带批注的文档到指定路径。
+
+ Args:
+ check (bool): 用来控制是仅检查还是仅修改
+ jsonpath (str): 文档逻辑结构的 JSON 文件路径 或 json 数据,描述各章节/段落的语义类型。
+ docxpath (str): 待处理的原始 Word (.docx) 文档路径。
+ savepath (str): 处理完成后带批注的文档保存路径。
+ configpath (Optional[str]): 格式规范配置文件(YAML)路径,支持继承与合并。
+ 为 None 时使用内置默认配置。
+
+ Side Effects:
+ - 读取 jsonpath、docxpath 和 configpath 指定的文件;
+ - 在 docx 文档中插入批注(不修改原文内容,仅添加审阅意见);
+ - 将结果文档写入 savepath。
+
+ Example:
+ >>> auto_format_thesis_document(
+ ... docxpath="draft.docx",
+ ... jsonpath="thesis_structure.json",
+ ... configpath="format_rules.yaml",
+ ... savepath="output/",
+ ... check=True
+ ... )
+ """
+
+ ctx = FormatContext(
+ docx_path=docxpath,
+ json_path=jsonpath,
+ config_path=configpath,
+ save_dir=savepath,
+ check=check,
+ )
+ # 2. 组装流水线
+ pipeline: list[PipelineStage] = [
+ LoadConfigStage(),
+ LoadDocxStage(),
+ TreeBuildingStage(),
+ ParagraphAlignmentStage(),
+ TreeNormalizationStage(),
+ StyleDefinitionFixStage(),
+ FormattingExecutionStage(),
+ SummaryGenerationStage(),
+ PostProcessingStage(),
+ DocumentSavingStage(),
+ ]
+ for stage in pipeline:
+ ctx = stage.process(ctx)
+ return ctx.output_path
diff --git a/src/wordformat/pipeline/stages.py b/src/wordformat/pipeline/stages.py
new file mode 100644
index 0000000..4f72629
--- /dev/null
+++ b/src/wordformat/pipeline/stages.py
@@ -0,0 +1,578 @@
+#! /usr/bin/env python
+# @Time : 2026/7/7 12:25
+# @Author : afish
+# @File : loadpipline.py
+# 加载配置、文档
+from pathlib import Path
+
+from docx import Document
+from docx.document import Document as DocumentObject
+
+from wordformat.config.loader import get_config, init_config
+from wordformat.hyperlinks import create_citation_hyperlinks
+from wordformat.log_config import logger
+from wordformat.rules.abstract import (
+ AbstractContentCN,
+ AbstractContentEN,
+ AbstractTitleCN,
+ AbstractTitleEN,
+)
+from wordformat.rules.caption import CaptionFigure, CaptionTable
+from wordformat.rules.keywords import KeywordsCN, KeywordsEN
+from wordformat.rules.node import FormatNode
+from wordformat.rules.references import ReferenceEntry, References
+from wordformat.settings import VOIDNODELIST
+from wordformat.structure.document_builder import DocumentBuilder
+from wordformat.structure.utils import promote_bodytext_in_subtrees_of_type
+from wordformat.style.defs import (
+ Alignment,
+ FirstLineIndent,
+ FontColor,
+ FontSize,
+ LeftIndent,
+ LineSpacing,
+ LineSpacingRule,
+ RightIndent,
+ SpaceAfter,
+ SpaceBefore,
+ ensure_style_exists,
+)
+from wordformat.style.writer import (
+ SetFirstLineIndent,
+ SetIndent,
+ SetLineSpacing,
+ SetSpacing,
+)
+from wordformat.style.xml_ops import (
+ ensure_pPr,
+ ensure_rPr,
+ line_rule_to_xml,
+ line_spacing_val_to_xml,
+ pPr_set_alignment,
+ rPr_set_bold,
+ rPr_set_font,
+ rPr_set_font_color,
+ rPr_set_font_size,
+ rPr_set_italic,
+ rPr_set_underline,
+)
+from wordformat.utils import (
+ count_chinese_chars,
+ ensure_directory_exists,
+ get_file_name,
+ has_chinese,
+ parse_caption_text,
+)
+
+from .context import FormatContext
+
+
+class LoadConfigStage:
+ """加载配置pipline"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ configpath = ctx.config_path
+ if configpath:
+ init_config(configpath)
+ try:
+ ctx.config_model = get_config()
+ logger.info("配置文件验证通过")
+ except Exception as e:
+ logger.error(f"配置加载失败: {str(e)}")
+ raise
+ else:
+ logger.info("未提供配置文件,使用默认配置")
+ return ctx
+
+
+class LoadDocxStage:
+ """加载docx的pipline"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ """加载docx"""
+ ctx.document = Document(ctx.docx_path)
+ return ctx
+
+
+class TreeBuildingStage:
+ """构建tree pipline"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ ctx.root_node = DocumentBuilder.build_from_json(
+ ctx.json_path, config=ctx.config_model
+ )
+ return ctx
+
+
+class ParagraphAlignmentStage:
+ """展平树并对齐段落 pipline"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ nodes = self._flatten_tree_nodes(ctx.root_node)
+ for node, para in zip(nodes, ctx.document.paragraphs, strict=False):
+ node.paragraph = para
+ return ctx
+
+ def _flatten_tree_nodes(self, root_node):
+ """DFS 前序遍历展平树中所有节点(排除虚拟根节点),顺序与文档段落一致。"""
+ result = []
+
+ def dfs(node):
+ for child in node.children:
+ result.append(child)
+ dfs(child)
+
+ dfs(root_node)
+ return result
+
+
+class TreeNormalizationStage:
+ """提升子树(摘要、参考文献)"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ mappings = {
+ AbstractTitleCN: AbstractContentCN,
+ AbstractTitleEN: AbstractContentEN,
+ References: ReferenceEntry,
+ }
+ for parent_cls, target_cls in mappings.items():
+ promote_bodytext_in_subtrees_of_type(
+ ctx.root_node, parent_type=parent_cls, target_type=target_cls
+ )
+ return ctx
+
+
+class StyleDefinitionFixStage:
+ """修正样式定义(仅 apply 模式)"""
+
+ def _fix_style_run_properties(self, style, cfg, style_name: str):
+ """修正样式定义中的字符格式属性(w:rPr)。"""
+ rPr = ensure_rPr(style.element)
+
+ cn_name = getattr(cfg, "chinese_font_name", None)
+ en_name = getattr(cfg, "english_font_name", None)
+ if cn_name or en_name:
+ rPr_set_font(rPr, cn_name=cn_name, en_name=en_name)
+
+ font_size = getattr(cfg, "font_size", None)
+ if font_size is not None:
+ try:
+ rPr_set_font_size(rPr, FontSize(font_size).rel_value)
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' 字号失败: {e}")
+
+ font_color = getattr(cfg, "font_color", None)
+ if font_color is not None:
+ try:
+ rPr_set_font_color(rPr, FontColor(font_color).rel_value)
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' 颜色失败: {e}")
+
+ bold = getattr(cfg, "bold", None)
+ if bold is not None:
+ rPr_set_bold(rPr, bold)
+
+ italic = getattr(cfg, "italic", None)
+ if italic is not None:
+ rPr_set_italic(rPr, italic)
+
+ underline = getattr(cfg, "underline", None)
+ if underline is not None:
+ rPr_set_underline(rPr, underline)
+
+ def _fix_style_paragraph_properties(self, style, cfg, style_name: str):
+ """修正样式定义中的段落格式属性(w:pPr)。
+
+ 设置:对齐方式、段前/段后间距、行距、首行缩进、左右缩进。
+ 直接在 w:pPr 元素上操作 XML,确保样式定义级别生效。
+ """
+ pPr = ensure_pPr(style.element)
+
+ # --- 对齐方式 ---
+ alignment = getattr(cfg, "alignment", None)
+ if alignment is not None:
+ try:
+ pPr_set_alignment(pPr, Alignment(alignment).rel_value)
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' 对齐方式失败: {e}")
+
+ # --- 段前/段后间距 ---
+ for attr_name, cls, spacing_type in [
+ ("space_before", SpaceBefore, "before"),
+ ("space_after", SpaceAfter, "after"),
+ ]:
+ val = getattr(cfg, attr_name, None)
+ if val is None:
+ continue
+ try:
+ inst = cls(val)
+ if inst.rel_unit == "hang":
+ SetSpacing._set_hang_on_pPr(pPr, spacing_type, inst.rel_value)
+ else:
+ logger.warning(
+ f"样式 '{style_name}' {attr_name} 使用了 '{inst.rel_unit}' 单位,"
+ f"样式定义仅支持'行'单位,已跳过"
+ )
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' {attr_name} 失败: {e}")
+
+ # --- 行距 ---
+ line_spacingrule = getattr(cfg, "line_spacingrule", None)
+ if line_spacingrule is not None:
+ try:
+ lsr = LineSpacingRule(line_spacingrule)
+ line_rule = line_rule_to_xml(lsr.rel_value)
+
+ line_spacing = getattr(cfg, "line_spacing", None)
+ if line_spacing is not None:
+ ls = LineSpacing(line_spacing)
+ line_val = line_spacing_val_to_xml(ls.rel_value, ls.rel_unit)
+ SetLineSpacing._set_on_pPr(pPr, line_rule, line_val)
+ else:
+ logger.warning(
+ f"样式 '{style_name}' 设置了 line_spacingrule 但未设置 line_spacing,已跳过行距"
+ )
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' 行距失败: {e}")
+
+ # --- 缩进:先设置首行缩进,再设置左右缩进,避免 _clear_ind_on_pPr 清除 *Chars 属性 ---
+ first_line_indent = getattr(cfg, "first_line_indent", None)
+ if first_line_indent is not None:
+ try:
+ inst = FirstLineIndent(first_line_indent)
+ if inst.rel_unit == "char":
+ SetFirstLineIndent._clear_ind_on_pPr(pPr)
+ SetFirstLineIndent._set_char_on_pPr(pPr, inst.rel_value)
+ else:
+ logger.warning(
+ f"样式 '{style_name}' first_line_indent 使用了 '{inst.rel_unit}' 单位,"
+ f"样式定义仅支持'字符'单位,已跳过"
+ )
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' first_line_indent 失败: {e}")
+
+ for attr_name, cls, indent_type in [
+ ("left_indent", LeftIndent, "R"),
+ ("right_indent", RightIndent, "X"),
+ ]:
+ val = getattr(cfg, attr_name, None)
+ if val is None:
+ continue
+ try:
+ inst = cls(val)
+ if inst.rel_unit == "char":
+ SetIndent._set_char_on_pPr(pPr, indent_type, inst.rel_value)
+ else:
+ logger.warning(
+ f"样式 '{style_name}' {attr_name} 使用了 '{inst.rel_unit}' 单位,"
+ f"样式定义仅支持'字符'单位,已跳过"
+ )
+ except Exception as e:
+ logger.warning(f"设置样式 '{style_name}' {attr_name} 失败: {e}")
+
+ def _fix_all_style_definitions(self, document: DocumentObject, config_model):
+ """在格式化开始前,统一修正文档中所有使用的样式定义。
+
+ 遍历配置中所有段(body_text、headings、abstract、figures、tables、
+ references、acknowledgements 等),收集唯一的 builtin_style_name,
+ 然后修正每个样式定义的字符格式和段落格式,使其与配置一致。
+
+ 修正内容:
+ 1. 字符格式:中英文字体、字号、颜色(清除主题色)、加粗、斜体、下划线
+ 2. 段落格式:对齐方式
+ 3. 确保样式定义存在(不存在则创建)
+ """
+
+ style_configs = config_model.collect_style_configs()
+
+ for eng_style_name, cfg in style_configs.items():
+ ensure_style_exists(document, eng_style_name)
+
+ try:
+ style = document.styles[eng_style_name]
+ except KeyError:
+ logger.warning(f"样式 '{eng_style_name}' 创建失败,跳过修正")
+ continue
+
+ self._fix_style_run_properties(style, cfg, eng_style_name)
+ self._fix_style_paragraph_properties(style, cfg, eng_style_name)
+
+ logger.debug(f"已修正样式定义: {eng_style_name}")
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ if not ctx.check:
+ self._fix_all_style_definitions(ctx.document, ctx.config_model)
+ return ctx
+
+
+class FormattingExecutionStage:
+ """执行格式化/检查(核心遍历)"""
+
+ def apply_format_check_to_all_nodes(
+ self, root_node: FormatNode, document, config, check=True
+ ):
+ """
+ 递归遍历文档树中的所有节点,
+ 对每个具有 check_format 方法的节点执行该方法。
+
+ :param root_node: 树的根节点(FormatNode 或其子类实例)
+ :param document: docx文档的实例
+ :param config: 配置文件
+ :param check: 用来控制是仅检查还是仅修改
+ """
+
+ chapter_index: int = 0
+ figure_counter: dict[int, int] = {}
+ table_counter: dict[int, int] = {}
+
+ def traverse(node, parent_category="", current_chapter: int = 0):
+ nonlocal chapter_index
+
+ category = (
+ node.value.get("category", "") if isinstance(node.value, dict) else ""
+ )
+
+ # 遇到一级标题时递增章节号
+ if category == "heading_level_1":
+ chapter_index += 1
+ current_chapter = chapter_index
+
+ if hasattr(node, "check_format"):
+ try:
+ # top 节点直接关联的 body_text 不参与格式化(如封面页、原创性声明等)
+ # 但间接关联的 body_text(作为 heading 子节点)正常格式化
+ is_top_direct_body_text = (
+ parent_category == "top" and category == "body_text"
+ )
+ if category not in VOIDNODELIST and not is_top_direct_body_text:
+ node.load_config(config)
+
+ # 对题注节点注入章节号和顺序号
+ if isinstance(node, (CaptionFigure, CaptionTable)):
+ # 检查是否为续表/续图:保留原标题注编号,不递增计数器
+ text = node.paragraph.text.strip() if node.paragraph else ""
+ parsed = parse_caption_text(text)
+ if (
+ parsed
+ and parsed.get("is_continued")
+ and parsed.get("chapter_num") is not None
+ and parsed.get("number_num") is not None
+ ):
+ chapter = parsed["chapter_num"]
+ seq = parsed["number_num"]
+ else:
+ chapter = current_chapter if current_chapter > 0 else 0
+ if isinstance(node, CaptionFigure):
+ counter = figure_counter
+ else:
+ counter = table_counter
+ counter[chapter] = counter.get(chapter, 0) + 1
+ seq = counter[chapter]
+ node.value["chapter_number"] = chapter
+ node.value["sequence_number"] = seq
+
+ # 给所有节点注入章节号(BodyText 引用上标需要)
+ if isinstance(node.value, dict):
+ node.value.setdefault("chapter_number", current_chapter)
+
+ if node.paragraph:
+ # 先执行内容替换(check/format 两种模式均执行)
+ node.apply_replace(document)
+ if check:
+ node.check_format(document)
+ else:
+ node.apply_format(document)
+ except Exception as e:
+ logger.warning(f"Node {node} not format, because: {str(e)}")
+ raise e
+
+ # 目录、附录、封面/声明的子节点跳过格式化
+ SKIP_CHILDREN_CATEGORIES = {"heading_mulu", "heading_fulu", "other"}
+ if category not in SKIP_CHILDREN_CATEGORIES:
+ for child in node.children:
+ traverse(
+ child, parent_category=category, current_chapter=current_chapter
+ )
+
+ traverse(root_node)
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ if ctx.check:
+ FormatNode.reset_stats()
+ self.apply_format_check_to_all_nodes(
+ ctx.root_node, ctx.document, ctx.config_model, ctx.check
+ )
+ return ctx
+
+
+class SummaryGenerationStage:
+ """生成检测报告摘要(仅 check 模式)"""
+
+ def _build_check_summary(self, root_node, document, config_model) -> str:
+ """遍历树和错误统计,生成检测报告摘要文本。"""
+ stats = FormatNode._error_stats
+ total = stats["total"]
+
+ # 遍历树,收集文档级统计
+
+ def _collect_section(node, sections):
+ cls_name = type(node).__name__
+ para = node.paragraph
+ if para and para.text.strip():
+ text = para.text.strip()
+ if cls_name == "AbstractContentCN":
+ cn_chars = count_chinese_chars(text)
+ if cn_chars:
+ sections["abstract_cn_chars"] = (
+ sections.get("abstract_cn_chars", 0) + cn_chars
+ )
+ elif cls_name == "AbstractContentEN":
+ sections["abstract_en_words"] = sections.get(
+ "abstract_en_words", 0
+ ) + len(text.split())
+ elif cls_name == "KeywordsCN":
+ kws = KeywordsCN.extract_keywords(text)
+ if kws:
+ sections["keyword_cn_count"] = len(kws)
+ elif cls_name == "KeywordsEN":
+ kws = KeywordsEN.extract_keywords(text)
+ if kws:
+ sections["keyword_en_count"] = len(kws)
+ elif cls_name == "ReferenceEntry":
+ if has_chinese(text):
+ sections["ref_cn"] = sections.get("ref_cn", 0) + 1
+ else:
+ sections["ref_en"] = sections.get("ref_en", 0) + 1
+ # 处理混合节点:AbstractTitleContentCN/EN 的子 BodyText 是摘要正文
+ if cls_name == "AbstractTitleContentCN":
+ for child in node.children:
+ cp = child.paragraph
+ if cp and cp.text.strip():
+ cnt = count_chinese_chars(cp.text.strip())
+ sections["abstract_cn_chars"] = (
+ sections.get("abstract_cn_chars", 0) + cnt
+ )
+ elif cls_name == "AbstractTitleContentEN":
+ for child in node.children:
+ cp = child.paragraph
+ if cp and cp.text.strip():
+ cnt = len(cp.text.strip().split())
+ sections["abstract_en_words"] = (
+ sections.get("abstract_en_words", 0) + cnt
+ )
+ for child in node.children:
+ _collect_section(child, sections)
+
+ sections: dict = {}
+ _collect_section(root_node, sections)
+
+ # 计算万字差错率
+ total_chars = sum(
+ len(p.text) for p in document.paragraphs if p.text and p.text.strip()
+ )
+ error_rate = (total / max(total_chars, 1)) * 10000 if total else 0
+
+ # 模板名(从 config 读取)
+ template_name = getattr(config_model, "template_name", None) or "未知模板"
+
+ lines = [
+ "检测结果:",
+ f"检测模板:《{template_name}》",
+ f"检测错误数:{total},万字差错率:{error_rate:.1f}",
+ f"严重错误:{stats.get('严重', 0)},一般错误:{stats.get('一般', 0)},"
+ f"提醒:{stats.get('提醒', 0)}",
+ ]
+
+ # 字数问题
+ word_issues = []
+ if sections.get("abstract_cn_chars"):
+ word_issues.append(
+ f"中文摘要:规范:300字左右,原文:{sections['abstract_cn_chars']}字"
+ )
+ if sections.get("abstract_en_words"):
+ word_issues.append(
+ f"英文摘要:规范:300字左右,原文:{sections['abstract_en_words']}词"
+ )
+ if sections.get("keyword_cn_count"):
+ word_issues.append(
+ f"中文关键词:规范:3-5个,原文:{sections['keyword_cn_count']}个"
+ )
+ if sections.get("keyword_en_count"):
+ word_issues.append(
+ f"英文关键词:规范:3-5个,原文:{sections['keyword_en_count']}个"
+ )
+ ref_cn = sections.get("ref_cn", 0)
+ ref_en = sections.get("ref_en", 0)
+ if ref_cn or ref_en:
+ word_issues.append(
+ f"参考文献:规范:不少于15条,原文:中文{ref_cn}条;外文{ref_en}条"
+ )
+ if word_issues:
+ lines.append("字数问题:")
+ lines.extend(word_issues)
+
+ lines.append("说明:")
+ lines.append(
+ "1.请确保文档中正确使用换行符,硬回车(Enter):指换行且生成新段落;软回车(Shift+Enter):指换行但不生成新段落。"
+ )
+ lines.append("2.图片请使用“嵌入型”环绕方式,表格为无环绕方式。")
+ lines.append("3.提醒不计算错误。")
+
+ return "\n".join(lines)
+
+ def _add_summary_comment(self, document, summary: str) -> None:
+ """将检测报告摘要作为批注添加到文档第一段。空段临时塞空 run 做锚点。"""
+ para = document.paragraphs[0]
+ if not para.runs:
+ para.add_run("")
+ document.add_comment(
+ runs=para.runs, text=summary, author="Wordformat", initials="afish"
+ )
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ if ctx.check:
+ summary = self._build_check_summary(
+ ctx.root_node, ctx.document, ctx.config_model
+ )
+ if summary:
+ self._add_summary_comment(ctx.document, summary)
+ return ctx
+
+
+class PostProcessingStage:
+ """后处理(编号 + 超链接,仅 apply 模式)"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ if ctx.check:
+ return ctx
+ config_model = ctx.config_model
+ # 标题编号
+ if hasattr(config_model, "numbering") and config_model.numbering.enabled:
+ from wordformat.numbering import process_heading_numbering
+
+ process_heading_numbering(
+ ctx.root_node,
+ ctx.document,
+ config_model.numbering,
+ config_model.headings,
+ )
+
+ # 引用超链接
+ create_citation_hyperlinks(ctx.root_node, ctx.document)
+
+ return ctx
+
+
+class DocumentSavingStage:
+ """保存文档"""
+
+ def process(self, ctx: FormatContext) -> FormatContext:
+ ensure_directory_exists(ctx.save_dir)
+ filename = get_file_name(ctx.docx_path)
+ suffix = "--标注版.docx" if ctx.check else "--修改版.docx"
+ out_path = Path(ctx.save_dir) / f"{filename}{suffix}"
+
+ ctx.document.save(str(out_path))
+ logger.info(f"保存文件到 {out_path}")
+ ctx.output_path = str(out_path)
+ return ctx
diff --git a/src/wordformat/rules/abstract.py b/src/wordformat/rules/abstract.py
index 7166280..55f707f 100644
--- a/src/wordformat/rules/abstract.py
+++ b/src/wordformat/rules/abstract.py
@@ -4,13 +4,13 @@
# @File : abstract.py
import re
-from wordformat.config.datamodel import (
+from wordformat.config.models import (
AbstractChineseConfig,
AbstractEnglishConfig,
AbstractTitleConfig,
)
from wordformat.rules.node import FormatNode
-from wordformat.style.check_format import CharacterStyle, ParagraphStyle
+from wordformat.style.diff import CharacterStyle, ParagraphStyle
class AbstractTitleCN(FormatNode[AbstractTitleConfig]):
diff --git a/src/wordformat/rules/acknowledgement.py b/src/wordformat/rules/acknowledgement.py
index 07c626b..015d240 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.datamodel import (
+from wordformat.config.models import (
AcknowledgementsContentConfig,
AcknowledgementsTitleConfig,
)
diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py
index c3859ce..5161a02 100644
--- a/src/wordformat/rules/body.py
+++ b/src/wordformat/rules/body.py
@@ -9,7 +9,7 @@
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
-from wordformat.config.datamodel import BodyTextConfig, PunctuationRule
+from wordformat.config.models import BodyTextConfig, PunctuationRule
from wordformat.rules.node import FormatNode
# 匹配正文中的参考文献交叉引用标记
diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py
index 5461539..c303c69 100644
--- a/src/wordformat/rules/caption.py
+++ b/src/wordformat/rules/caption.py
@@ -4,7 +4,7 @@
# @File : caption.py
-from wordformat.config.datamodel import (
+from wordformat.config.models import (
CaptionNumberingConfig,
FiguresConfig,
TablesConfig,
@@ -43,7 +43,7 @@ def _check_caption_numbering(
return
parsed = parse_caption_text(text)
- from wordformat.style.comment_format import format_comment
+ from wordformat.style.comments import format_comment
target = getattr(node, "NODE_LABEL", "题注")
diff --git a/src/wordformat/rules/heading.py b/src/wordformat/rules/heading.py
index ccee8d2..3b4e127 100644
--- a/src/wordformat/rules/heading.py
+++ b/src/wordformat/rules/heading.py
@@ -5,7 +5,7 @@
from loguru import logger
-from wordformat.config.datamodel import HeadingLevelConfig, NodeConfigRoot
+from wordformat.config.models import HeadingLevelConfig, NodeConfigRoot
from wordformat.rules.node import FormatNode
diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py
index eb024de..bbdfb99 100644
--- a/src/wordformat/rules/keywords.py
+++ b/src/wordformat/rules/keywords.py
@@ -4,14 +4,14 @@
from docx.oxml.ns import qn
-from wordformat.config.datamodel import (
+from wordformat.config.models import (
KeywordCountRule,
KeywordsConfig,
NodeConfigRoot,
TrailingPunctRule,
)
from wordformat.rules.node import FormatNode
-from wordformat.style.check_format import CharacterStyle, ParagraphStyle
+from wordformat.style.diff import CharacterStyle, ParagraphStyle
# 第一步:提取关键词基类,复用通用逻辑
@@ -122,6 +122,15 @@ class KeywordsEN(BaseKeywordsNode):
NODE_LABEL = "英文关键词"
RULES = {"keyword_count": "_check_keyword_count"}
+ _LABEL_RE = re.compile(r"Keywords?:?\s*", re.IGNORECASE)
+ _SEPARATOR_RE = re.compile(r"[,;]")
+
+ @staticmethod
+ def extract_keywords(text: str) -> list[str]:
+ """从文本中提取英文关键词列表(去除标签前缀)。"""
+ kw_text = KeywordsEN._LABEL_RE.sub("", text)
+ return [k.strip() for k in KeywordsEN._SEPARATOR_RE.split(kw_text) if k.strip()]
+
def _check_keyword_label(self, run) -> bool:
"""判断 run 是否属于英文关键词标签部分。"""
if not run.text.strip():
@@ -149,11 +158,8 @@ def _get_label_split_pattern(self) -> re.Pattern | None:
def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False):
"""校验英文关键词数量"""
keyword_text = "".join([run.text for run in self.paragraph.runs])
- keyword_list = re.split(
- r"[,;]", re.sub(r"Keywords?:", "", keyword_text, flags=re.IGNORECASE)
- )
- keyword_list = [k.strip() for k in keyword_list if k.strip()]
- from wordformat.style.comment_format import format_comment
+ keyword_list = KeywordsEN.extract_keywords(keyword_text)
+ from wordformat.style.comments import format_comment
target = self.NODE_LABEL
if len(keyword_list) < rule_cfg.count_min:
@@ -229,6 +235,15 @@ class KeywordsCN(BaseKeywordsNode):
"trailing_punctuation": "_check_trailing_punctuation",
}
+ _LABEL_RE = re.compile(r"关键词[::]?\s*")
+ _SEPARATOR_RE = re.compile(r"[;;]")
+
+ @staticmethod
+ def extract_keywords(text: str) -> list[str]:
+ """从文本中提取中文关键词列表(去除标签前缀)。"""
+ kw_text = KeywordsCN._LABEL_RE.sub("", text)
+ return [k.strip() for k in KeywordsCN._SEPARATOR_RE.split(kw_text) if k.strip()]
+
def _check_keyword_label(self, run) -> bool:
"""判断 run 是否属于中文关键词标签部分(防拆分)。"""
if not run.text.strip():
@@ -261,9 +276,8 @@ def _get_label_split_pattern(self) -> re.Pattern | None:
def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False):
"""校验中文关键词数量"""
keyword_text = "".join([run.text for run in self.paragraph.runs])
- keyword_list = re.split(r";", re.sub(r"关键词[::]", "", keyword_text))
- keyword_list = [k.strip() for k in keyword_list if k.strip()]
- from wordformat.style.comment_format import format_comment
+ keyword_list = KeywordsCN.extract_keywords(keyword_text)
+ from wordformat.style.comments import format_comment
target = self.NODE_LABEL
if len(keyword_list) < rule_cfg.count_min:
@@ -287,7 +301,7 @@ def _check_trailing_punctuation(
self, doc, rule_cfg: TrailingPunctRule, p: bool = False
):
"""校验中文关键词末尾标点"""
- from wordformat.style.comment_format import format_comment
+ from wordformat.style.comments import format_comment
keyword_text = "".join([run.text for run in self.paragraph.runs])
if (
diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py
index e4f9378..db80539 100644
--- a/src/wordformat/rules/node.py
+++ b/src/wordformat/rules/node.py
@@ -13,7 +13,7 @@
from loguru import logger
from pydantic import ValidationError
-from wordformat.config.datamodel import BaseModel, NodeConfigRoot
+from wordformat.config.models import BaseModel, NodeConfigRoot
class TreeNode:
@@ -128,7 +128,7 @@ def load_yaml_config(cls, config_path: str | Path) -> Dict[str, Any]:
with open(config_path, encoding="utf-8") as f:
raw_config = yaml.safe_load(f)
# 使用根模型验证整个配置结构
- from wordformat.config.datamodel import NodeConfigRoot
+ from wordformat.config.models import NodeConfigRoot
root_config = NodeConfigRoot(**raw_config)
return root_config.model_dump()
@@ -225,7 +225,7 @@ def _handle_paragraph_style(self, doc, rule_cfg, p: bool):
"""默认段落样式检查/应用。配置需继承 GlobalFormatConfig。"""
if self.paragraph is None or not self.paragraph.runs:
return
- from wordformat.style.check_format import ParagraphStyle
+ from wordformat.style.diff import ParagraphStyle
cfg = self.pydantic_config
if not hasattr(cfg, "alignment"):
@@ -246,7 +246,7 @@ def _handle_character_style(self, doc, rule_cfg, p: bool):
"""默认字符样式检查/应用。配置需继承 GlobalFormatConfig。"""
if not self.paragraph.runs:
return
- from wordformat.style.check_format import CharacterStyle
+ from wordformat.style.diff import CharacterStyle
cfg = self.pydantic_config
if not hasattr(cfg, "chinese_font_name"):
@@ -311,7 +311,7 @@ def _key(runs):
def _write_comment_groups(self, doc, groups) -> None:
"""将分组后的批注写入文档并更新统计。"""
- from wordformat.style.comment_format import SEVERITY_ORDER, get_severity
+ from wordformat.style.comments import SEVERITY_ORDER, get_severity
for runs, texts in groups.values():
merged = "\n".join(texts)
diff --git a/src/wordformat/rules/object.py b/src/wordformat/rules/object.py
index 936db1f..af0c99c 100644
--- a/src/wordformat/rules/object.py
+++ b/src/wordformat/rules/object.py
@@ -1,8 +1,8 @@
"""图片段落和表格对象节点。"""
-from wordformat.config.datamodel import ImageFormatConfig, TableObjectConfig
+from wordformat.config.models import ImageFormatConfig, TableObjectConfig
from wordformat.rules.node import FormatNode
-from wordformat.style.comment_format import format_comment
+from wordformat.style.comments import format_comment
class FigureImage(FormatNode[ImageFormatConfig]):
@@ -19,8 +19,8 @@ class FigureImage(FormatNode[ImageFormatConfig]):
def _base(self, doc, p: bool, r: bool):
"""仅检查对齐和首行缩进。"""
- from wordformat.style.check_format import _format_para_value
- from wordformat.style.style_enum import Alignment, FirstLineIndent
+ from wordformat.style.defs import Alignment, FirstLineIndent
+ from wordformat.style.diff import _format_para_value
cfg = self.pydantic_config
expected_align = Alignment(str(cfg.alignment))
diff --git a/src/wordformat/rules/references.py b/src/wordformat/rules/references.py
index 30dfdf1..b54aeae 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.datamodel import ReferencesContentConfig, ReferencesTitleConfig
+from wordformat.config.models import ReferencesContentConfig, ReferencesTitleConfig
from wordformat.rules.node import FormatNode
diff --git a/src/wordformat/set_style.py b/src/wordformat/set_style.py
deleted file mode 100644
index 8495358..0000000
--- a/src/wordformat/set_style.py
+++ /dev/null
@@ -1,771 +0,0 @@
-#! /usr/bin/env python
-# @Time : 2026/1/11 19:51
-# @Author : afish
-# @File : set_style.py
-from pathlib import Path
-from typing import Optional
-
-from docx import Document
-from loguru import logger
-
-from wordformat.config.config import get_config, init_config
-from wordformat.config.datamodel import BaseModel, NodeConfigRoot
-from wordformat.rules import (
- AbstractContentCN,
- AbstractContentEN,
- AbstractTitleCN,
- AbstractTitleEN,
- FormatNode,
- ReferenceEntry,
- References,
-)
-from wordformat.settings import VOIDNODELIST
-from wordformat.style.check_format import CharacterStyle, ParagraphStyle
-from wordformat.utils import ensure_directory_exists
-from wordformat.word_structure.document_builder import DocumentBuilder
-from wordformat.word_structure.utils import promote_bodytext_in_subtrees_of_type
-
-
-def _collect_all_style_configs(config_model) -> dict:
- """遍历 NodeConfigRoot,收集所有唯一的 (英文样式名 → 配置段) 映射。
-
- 对于被多个段引用的同一样式(如 body_text 和 references.content 都用 "Normal"),
- 保留先遇到的配置段。
- """
- from wordformat.config.datamodel import GlobalFormatConfig
- from wordformat.style.style_enum import BuiltInStyle
-
- style_map: dict[str, object] = {}
-
- def _resolve_style_name(cfg) -> str | None:
- raw = getattr(cfg, "builtin_style_name", None)
- if not raw:
- return None
- try:
- return BuiltInStyle(raw).rel_value # "正文" → "Normal", "题注" → "Caption"
- except Exception:
- return raw # 自定义样式名直接使用
-
- def _walk(obj, path: str = ""):
- if isinstance(obj, GlobalFormatConfig):
- eng_name = _resolve_style_name(obj)
- if eng_name and eng_name not in style_map:
- style_map[eng_name] = obj
- if isinstance(obj, BaseModel):
- for f_name in type(obj).model_fields:
- val = getattr(obj, f_name)
- if isinstance(val, BaseModel):
- _walk(val, f"{path}.{f_name}")
- elif isinstance(val, dict):
- for _k, v in val.items():
- if isinstance(v, BaseModel):
- _walk(v, f"{path}.{_k}")
-
- _walk(config_model)
- return style_map
-
-
-def _fix_style_run_properties(style, cfg, style_name: str):
- """修正样式定义中的字符格式属性(w:rPr)。"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- from wordformat.style.style_enum import FontColor, FontSize
-
- style_element = style.element
-
- # 确保 w:rPr 存在
- rPr = style_element.find(qn("w:rPr"))
- if rPr is None:
- rPr = OxmlElement("w:rPr")
- style_element.insert(0, rPr)
-
- # --- 字体名称 ---
- rFonts = rPr.find(qn("w:rFonts"))
- if rFonts is None:
- rFonts = OxmlElement("w:rFonts")
- rPr.insert(0, rFonts)
- # 中文字体(eastAsia)
- cn_name = getattr(cfg, "chinese_font_name", None)
- if cn_name:
- rFonts.set(qn("w:eastAsia"), str(cn_name))
- # 英文字体(ascii + hAnsi)
- en_name = getattr(cfg, "english_font_name", None)
- if en_name:
- rFonts.set(qn("w:ascii"), str(en_name))
- rFonts.set(qn("w:hAnsi"), str(en_name))
-
- # --- 字号 ---
- font_size = getattr(cfg, "font_size", None)
- if font_size is not None:
- try:
- fs = FontSize(font_size)
- pt_val = fs.rel_value # e.g. "小四" → 12.0
- half_pt = str(int(round(pt_val * 2)))
- # 更新或创建 w:sz
- sz = rPr.find(qn("w:sz"))
- if sz is None:
- sz = OxmlElement("w:sz")
- rPr.append(sz)
- sz.set(qn("w:val"), half_pt)
- # w:szCs(复杂脚本字号)
- szCs = rPr.find(qn("w:szCs"))
- if szCs is None:
- szCs = OxmlElement("w:szCs")
- rPr.append(szCs)
- szCs.set(qn("w:val"), half_pt)
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' 字号失败: {e}")
-
- # --- 字体颜色(清除主题色) ---
- font_color = getattr(cfg, "font_color", None)
- if font_color is not None:
- try:
- fc = FontColor(font_color)
- rgb = fc.rel_value
- hex_color = f"{rgb[0]:02X}{rgb[1]:02X}{rgb[2]:02X}"
- # 移除旧的 w:color(尤其是有主题色的)
- for old_color in rPr.findall(qn("w:color")):
- rPr.remove(old_color)
- new_color = OxmlElement("w:color")
- new_color.set(qn("w:val"), hex_color)
- rPr.append(new_color)
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' 颜色失败: {e}")
-
- # --- 加粗 ---
- bold = getattr(cfg, "bold", None)
- if bold is not None:
- b = rPr.find(qn("w:b"))
- if bold:
- if b is None:
- rPr.append(OxmlElement("w:b"))
- else:
- if b is not None:
- rPr.remove(b)
-
- # --- 斜体 ---
- italic = getattr(cfg, "italic", None)
- if italic is not None:
- i = rPr.find(qn("w:i"))
- if italic:
- if i is None:
- rPr.append(OxmlElement("w:i"))
- else:
- if i is not None:
- rPr.remove(i)
-
- # --- 下划线 ---
- underline = getattr(cfg, "underline", None)
- if underline is not None:
- u = rPr.find(qn("w:u"))
- if underline:
- if u is None:
- u = OxmlElement("w:u")
- u.set(qn("w:val"), "single")
- rPr.append(u)
- else:
- if u is not None:
- rPr.remove(u)
-
-
-def _fix_style_paragraph_properties(style, cfg, style_name: str):
- """修正样式定义中的段落格式属性(w:pPr)。
-
- 设置:对齐方式、段前/段后间距、行距、首行缩进、左右缩进。
- 直接在 w:pPr 元素上操作 XML,确保样式定义级别生效。
- """
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- from wordformat.style.style_enum import (
- Alignment,
- FirstLineIndent,
- LeftIndent,
- LineSpacing,
- LineSpacingRule,
- RightIndent,
- SpaceAfter,
- SpaceBefore,
- )
-
- style_element = style.element
-
- pPr = style_element.find(qn("w:pPr"))
- if pPr is None:
- pPr = OxmlElement("w:pPr")
- style_element.append(pPr)
-
- # --- 对齐方式 ---
- alignment = getattr(cfg, "alignment", None)
- if alignment is not None:
- try:
- al = Alignment(alignment)
- jc = pPr.find(qn("w:jc"))
- if jc is None:
- jc = OxmlElement("w:jc")
- pPr.append(jc)
- xml_val_map = {
- 0: "left",
- 1: "center",
- 2: "right",
- 3: "both",
- 4: "distribute",
- }
- jc.set(qn("w:val"), xml_val_map.get(al.rel_value, "left"))
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' 对齐方式失败: {e}")
-
- # --- 段前/段后间距 ---
- from wordformat.style.set_some import _SetSpacing
-
- for attr_name, cls, spacing_type in [
- ("space_before", SpaceBefore, "before"),
- ("space_after", SpaceAfter, "after"),
- ]:
- val = getattr(cfg, attr_name, None)
- if val is None:
- continue
- try:
- inst = cls(val)
- if inst.rel_unit == "hang":
- _SetSpacing._set_hang_on_pPr(pPr, spacing_type, inst.rel_value)
- else:
- logger.warning(
- f"样式 '{style_name}' {attr_name} 使用了 '{inst.rel_unit}' 单位,"
- f"样式定义仅支持'行'单位,已跳过"
- )
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' {attr_name} 失败: {e}")
-
- # --- 行距 ---
- from wordformat.style.set_some import _SetLineSpacing
-
- line_spacingrule = getattr(cfg, "line_spacingrule", None)
- if line_spacingrule is not None:
- try:
- lsr = LineSpacingRule(line_spacingrule)
- rule_map = {
- 0: "auto",
- 1: "auto",
- 2: "auto",
- 3: "atLeast",
- 4: "exact",
- 5: "auto",
- }
- line_rule = rule_map.get(lsr.rel_value, "auto")
-
- line_spacing = getattr(cfg, "line_spacing", None)
- if line_spacing is not None:
- ls = LineSpacing(line_spacing)
- ls_val = ls.rel_value
- if ls.rel_unit == "倍":
- line_val = ls_val * 240 # 倍数 → w:line
- elif ls.rel_unit in ("pt",):
- line_val = ls_val * 20 # pt → twips
- else:
- line_val = ls_val * 240
- _SetLineSpacing._set_on_pPr(pPr, line_rule, line_val)
- else:
- logger.warning(
- f"样式 '{style_name}' 设置了 line_spacingrule 但未设置 line_spacing,已跳过行距"
- )
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' 行距失败: {e}")
-
- # --- 缩进:先设置首行缩进,再设置左右缩进,避免 _clear_ind_on_pPr 清除 *Chars 属性 ---
- from wordformat.style.set_some import _SetFirstLineIndent, _SetIndent
-
- first_line_indent = getattr(cfg, "first_line_indent", None)
- if first_line_indent is not None:
- try:
- inst = FirstLineIndent(first_line_indent)
- if inst.rel_unit == "char":
- _SetFirstLineIndent._clear_ind_on_pPr(pPr)
- _SetFirstLineIndent._set_char_on_pPr(pPr, inst.rel_value)
- else:
- logger.warning(
- f"样式 '{style_name}' first_line_indent 使用了 '{inst.rel_unit}' 单位,"
- f"样式定义仅支持'字符'单位,已跳过"
- )
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' first_line_indent 失败: {e}")
-
- for attr_name, cls, indent_type in [
- ("left_indent", LeftIndent, "R"),
- ("right_indent", RightIndent, "X"),
- ]:
- val = getattr(cfg, attr_name, None)
- if val is None:
- continue
- try:
- inst = cls(val)
- if inst.rel_unit == "char":
- _SetIndent._set_char_on_pPr(pPr, indent_type, inst.rel_value)
- else:
- logger.warning(
- f"样式 '{style_name}' {attr_name} 使用了 '{inst.rel_unit}' 单位,"
- f"样式定义仅支持'字符'单位,已跳过"
- )
- except Exception as e:
- logger.warning(f"设置样式 '{style_name}' {attr_name} 失败: {e}")
-
-
-def _fix_all_style_definitions(document: Document, config_model):
- """在格式化开始前,统一修正文档中所有使用的样式定义。
-
- 遍历配置中所有段(body_text、headings、abstract、figures、tables、
- references、acknowledgements 等),收集唯一的 builtin_style_name,
- 然后修正每个样式定义的字符格式和段落格式,使其与配置一致。
-
- 修正内容:
- 1. 字符格式:中英文字体、字号、颜色(清除主题色)、加粗、斜体、下划线
- 2. 段落格式:对齐方式
- 3. 确保样式定义存在(不存在则创建)
- """
- from wordformat.style.style_enum import _ensure_style_exists
-
- style_configs = _collect_all_style_configs(config_model)
-
- for eng_style_name, cfg in style_configs.items():
- _ensure_style_exists(document, eng_style_name)
-
- try:
- style = document.styles[eng_style_name]
- except KeyError:
- logger.warning(f"样式 '{eng_style_name}' 创建失败,跳过修正")
- continue
-
- _fix_style_run_properties(style, cfg, eng_style_name)
- _fix_style_paragraph_properties(style, cfg, eng_style_name)
-
- logger.debug(f"已修正样式定义: {eng_style_name}")
-
-
-def apply_format_check_to_all_nodes(
- root_node: FormatNode, document, config, check=True
-):
- """
- 递归遍历文档树中的所有节点,
- 对每个具有 check_format 方法的节点执行该方法。
-
- :param root_node: 树的根节点(FormatNode 或其子类实例)
- :param document: docx文档的实例
- :param config: 配置文件
- :param check: 用来控制是仅检查还是仅修改
- """
- from wordformat.rules.caption import CaptionFigure, CaptionTable
- from wordformat.utils import parse_caption_text
-
- chapter_index: int = 0
- figure_counter: dict[int, int] = {}
- table_counter: dict[int, int] = {}
-
- def traverse(node, parent_category="", current_chapter: int = 0):
- nonlocal chapter_index
-
- category = (
- node.value.get("category", "") if isinstance(node.value, dict) else ""
- )
-
- # 遇到一级标题时递增章节号
- if category == "heading_level_1":
- chapter_index += 1
- current_chapter = chapter_index
-
- if hasattr(node, "check_format"):
- try:
- # top 节点直接关联的 body_text 不参与格式化(如封面页、原创性声明等)
- # 但间接关联的 body_text(作为 heading 子节点)正常格式化
- is_top_direct_body_text = (
- parent_category == "top" and category == "body_text"
- )
- if category not in VOIDNODELIST and not is_top_direct_body_text:
- node.load_config(config)
-
- # 对题注节点注入章节号和顺序号
- if isinstance(node, (CaptionFigure, CaptionTable)):
- # 检查是否为续表/续图:保留原标题注编号,不递增计数器
- text = node.paragraph.text.strip() if node.paragraph else ""
- parsed = parse_caption_text(text)
- if (
- parsed
- and parsed.get("is_continued")
- and parsed.get("chapter_num") is not None
- and parsed.get("number_num") is not None
- ):
- chapter = parsed["chapter_num"]
- seq = parsed["number_num"]
- else:
- chapter = current_chapter if current_chapter > 0 else 0
- if isinstance(node, CaptionFigure):
- counter = figure_counter
- else:
- counter = table_counter
- counter[chapter] = counter.get(chapter, 0) + 1
- seq = counter[chapter]
- node.value["chapter_number"] = chapter
- node.value["sequence_number"] = seq
-
- # 给所有节点注入章节号(BodyText 引用上标需要)
- if isinstance(node.value, dict):
- node.value.setdefault("chapter_number", current_chapter)
-
- if node.paragraph:
- # 先执行内容替换(check/format 两种模式均执行)
- node.apply_replace(document)
- if check:
- node.check_format(document)
- else:
- node.apply_format(document)
- except Exception as e:
- logger.warning(f"Node {node} not format, because: {str(e)}")
- raise e
-
- # 目录、附录、封面/声明的子节点跳过格式化
- SKIP_CHILDREN_CATEGORIES = {"heading_mulu", "heading_fulu", "other"}
- if category not in SKIP_CHILDREN_CATEGORIES:
- for child in node.children:
- traverse(
- child, parent_category=category, current_chapter=current_chapter
- )
-
- traverse(root_node)
-
-
-def _flatten_tree_nodes(root_node):
- """DFS 前序遍历展平树中所有节点(排除虚拟根节点),顺序与文档段落一致。"""
- result = []
-
- def dfs(node):
- for child in node.children:
- result.append(child)
- dfs(child)
-
- dfs(root_node)
- return result
-
-
-def format_table_content(
- document: Document, config: NodeConfigRoot, check: bool = True
-):
- """对文档中所有表格的单元格内容进行格式校验或应用。
-
- 遍历 document.tables → rows → cells → paragraphs,
- 根据 config.tables.content 中的格式配置对每个单元格段落进行
- 段落样式和字符样式的检查(check=True)或应用(check=False)。
-
- Args:
- document: docx 文档对象
- config: Pydantic 配置根模型
- check: True 为仅检查(diff),False 为应用格式
- """
- try:
- content_cfg = config.tables.content
- except AttributeError:
- return
-
- ps = ParagraphStyle.from_config(content_cfg)
- cstyle = CharacterStyle(
- font_name_cn=content_cfg.chinese_font_name,
- font_name_en=content_cfg.english_font_name,
- font_size=content_cfg.font_size,
- font_color=content_cfg.font_color,
- bold=content_cfg.bold,
- italic=content_cfg.italic,
- underline=content_cfg.underline,
- )
-
- for table in document.tables:
- for row in table.rows:
- for cell in row.cells:
- for paragraph in cell.paragraphs:
- if not paragraph.text.strip():
- continue
-
- # 段落样式
- if check:
- para_issues = ps.diff_from_paragraph(paragraph)
- else:
- para_issues = ps.apply_to_paragraph(paragraph)
- para_text = ParagraphStyle.to_string(para_issues, target="表格内容")
- if para_text.strip():
- document.add_comment(
- runs=paragraph.runs,
- text=para_text,
- author="Wordformat",
- initials="afish",
- )
-
- # 字符样式
- for run in paragraph.runs:
- if not run.text.strip():
- continue
- if check:
- diff = cstyle.diff_from_run(run)
- else:
- diff = cstyle.apply_to_run(run)
- run_text = CharacterStyle.to_string(diff, target="表格内容")
- if run_text.strip():
- document.add_comment(
- runs=run,
- text=run_text,
- author="Wordformat",
- initials="afish",
- )
-
-
-def _build_check_summary(root_node, document, config_model) -> str:
- """遍历树和错误统计,生成检测报告摘要文本。"""
- from wordformat.rules.node import FormatNode
-
- stats = FormatNode._error_stats
- total = stats["total"]
-
- # 遍历树,收集文档级统计
- import re
-
- def _collect_section(node, sections):
- cls_name = type(node).__name__
- para = node.paragraph
- if para and para.text.strip():
- text = para.text.strip()
- if cls_name == "AbstractContentCN":
- chinese_only = "".join(ch for ch in text if "一" <= ch <= "鿿")
- if chinese_only:
- sections["abstract_cn_chars"] = sections.get(
- "abstract_cn_chars", 0
- ) + len(chinese_only)
- elif cls_name == "AbstractContentEN":
- sections["abstract_en_words"] = sections.get(
- "abstract_en_words", 0
- ) + len(text.split())
- elif cls_name == "KeywordsCN":
- kw_text = re.sub(r"关键词[::]?\s*", "", text)
- kws = [k.strip() for k in re.split(r"[;;]", kw_text) if k.strip()]
- if kws:
- sections["keyword_cn_count"] = len(kws)
- elif cls_name == "KeywordsEN":
- kw_text = re.sub(r"Keywords?:?\s*", "", text, flags=re.IGNORECASE)
- kws = [k.strip() for k in re.split(r"[,;]", kw_text) if k.strip()]
- if kws:
- sections["keyword_en_count"] = len(kws)
- elif cls_name == "ReferenceEntry":
- has_chinese = any("一" <= ch <= "鿿" for ch in text)
- if has_chinese:
- sections["ref_cn"] = sections.get("ref_cn", 0) + 1
- else:
- sections["ref_en"] = sections.get("ref_en", 0) + 1
- # 处理混合节点:AbstractTitleContentCN/EN 的子 BodyText 是摘要正文
- if cls_name == "AbstractTitleContentCN":
- for child in node.children:
- cp = child.paragraph
- if cp and cp.text.strip():
- cn = "".join(ch for ch in cp.text.strip() if "一" <= ch <= "鿿")
- cnt = len(cn)
- cur = sections.get("abstract_cn_chars", 0)
- sections["abstract_cn_chars"] = cur + cnt
- elif cls_name == "AbstractTitleContentEN":
- for child in node.children:
- cp = child.paragraph
- if cp and cp.text.strip():
- cnt = len(cp.text.strip().split())
- sections["abstract_en_words"] = (
- sections.get("abstract_en_words", 0) + cnt
- )
- for child in node.children:
- _collect_section(child, sections)
-
- sections: dict = {}
- _collect_section(root_node, sections)
-
- # 计算万字差错率
- total_chars = sum(
- len(p.text) for p in document.paragraphs if p.text and p.text.strip()
- )
- error_rate = (total / max(total_chars, 1)) * 10000 if total else 0
-
- # 模板名(从 config 读取)
- template_name = getattr(config_model, "template_name", None) or "未知模板"
-
- lines = [
- "检测结果:",
- f"检测模板:《{template_name}》",
- f"检测错误数:{total},万字差错率:{error_rate:.1f}",
- f"严重错误:{stats.get('严重', 0)},一般错误:{stats.get('一般', 0)},提醒:{stats.get('提醒', 0)}",
- ]
-
- # 字数问题
- word_issues = []
- if sections.get("abstract_cn_chars"):
- word_issues.append(
- f"中文摘要:规范:300字左右,原文:{sections['abstract_cn_chars']}字"
- )
- if sections.get("abstract_en_words"):
- word_issues.append(
- f"英文摘要:规范:300字左右,原文:{sections['abstract_en_words']}词"
- )
- if sections.get("keyword_cn_count"):
- word_issues.append(
- f"中文关键词:规范:3-5个,原文:{sections['keyword_cn_count']}个"
- )
- if sections.get("keyword_en_count"):
- word_issues.append(
- f"英文关键词:规范:3-5个,原文:{sections['keyword_en_count']}个"
- )
- ref_cn = sections.get("ref_cn", 0)
- ref_en = sections.get("ref_en", 0)
- if ref_cn or ref_en:
- word_issues.append(
- f"参考文献:规范:不少于15条,原文:中文{ref_cn}条;外文{ref_en}条"
- )
- if word_issues:
- lines.append("字数问题:")
- lines.extend(word_issues)
-
- lines.append("说明:")
- lines.append(
- "1.请确保文档中正确使用换行符,硬回车(Enter):指换行且生成新段落;软回车(Shift+Enter):指换行但不生成新段落。"
- )
- lines.append("2.图片请使用“嵌入型”环绕方式,表格为无环绕方式。")
- lines.append("3.提醒不计算错误。")
-
- return "\n".join(lines)
-
-
-def _add_summary_comment(document, summary: str) -> None:
- """将检测报告摘要作为批注添加到文档第一段。空段临时塞空 run 做锚点。"""
- para = document.paragraphs[0]
- if not para.runs:
- para.add_run("")
- document.add_comment(
- runs=para.runs, text=summary, author="Wordformat", initials="afish"
- )
-
-
-def auto_format_thesis_document(
- jsonpath: str | list,
- docxpath: str,
- configpath: Optional[str] = None,
- savepath: str = "output/",
- check=True,
-):
- """自动对学位论文文档进行格式校验与批注。
-
- 该函数根据结构化 JSON 描述和 YAML 格式配置,对指定的 Word 文档进行格式合规性检查,
- 并在不符合规范的位置插入批注(comments)。主要用于学术论文(如本科/硕士/博士论文)
- 的自动化格式审查。
-
- 流程说明:
- 1. 从 JSON 文件加载文档逻辑结构树;
- 2. 加载 Word 文档,并将每个非空段落匹配到对应的结构节点;
- 4. 对特定子树(如中英文摘要、参考文献)执行节点提升操作,确保内容节点正确挂载;
- 5. 遍历所有结构节点,依据配置文件中的格式规则进行校验,并在文档中添加批注;
- 6. 保存带批注的文档到指定路径。
-
- Args:
- check (bool): 用来控制是仅检查还是仅修改
- jsonpath (str): 文档逻辑结构的 JSON 文件路径 或 json 数据,描述各章节/段落的语义类型。
- docxpath (str): 待处理的原始 Word (.docx) 文档路径。
- savepath (str): 处理完成后带批注的文档保存路径。
- configpath (Optional[str]): 格式规范配置文件(YAML)路径,支持继承与合并。
- 为 None 时使用内置默认配置。
-
- Side Effects:
- - 读取 jsonpath、docxpath 和 configpath 指定的文件;
- - 在 docx 文档中插入批注(不修改原文内容,仅添加审阅意见);
- - 将结果文档写入 savepath。
-
- Example:
- >>> auto_format_thesis_document(
- ... "thesis_structure.json",
- ... "draft.docx",
- ... "formatted_with_comments.docx",
- ... "format_rules.yaml"
- ... )
- """
- from wordformat.utils import get_file_name
-
- if configpath:
- init_config(configpath)
- try:
- config_model = get_config()
- logger.info("配置文件验证通过")
- except Exception as e:
- logger.error(f"配置加载失败: {str(e)}")
- raise
- else:
- config_model = NodeConfigRoot()
- logger.info("未提供配置文件,使用默认配置")
-
- ensure_directory_exists(savepath)
-
- filename_without_ext = get_file_name(docxpath)
- root_node = DocumentBuilder.build_from_json(jsonpath, config=config_model)
- # 注意:不再过滤 body_text 节点,body_text 也需要格式化(首行缩进、字体等)
- document = Document(docxpath)
-
- if not check:
- style_list = []
- for style in document.styles:
- style_list.append(style.name)
- logger.info(f"可用的样式有:{style_list}")
-
- nodes = _flatten_tree_nodes(root_node)
- for node, paragraph in zip(nodes, document.paragraphs, strict=False):
- node.paragraph = paragraph
-
- # 替换摘要节点
- subtress_dict = {
- AbstractTitleCN: AbstractContentCN,
- AbstractTitleEN: AbstractContentEN,
- References: ReferenceEntry,
- }
- for key, value in subtress_dict.items():
- promote_bodytext_in_subtrees_of_type(
- root_node, parent_type=key, target_type=value
- )
- # 执行格式化前,先统一修正样式定义(清除主题色、设置字体等)
- if not check:
- _fix_all_style_definitions(document, config_model)
-
- # 执行格式化
- if check:
- FormatNode.reset_stats()
- apply_format_check_to_all_nodes(root_node, document, config_model, check)
-
- # 表格内容格式化(已移除:表格内格式由 Word 表格样式控制,不做段落/字符级检查)
- # format_table_content(document, config_model, check)
-
- # 检测报告摘要(仅 check 模式)
- if check:
- summary = _build_check_summary(root_node, document, config_model)
- if summary:
- _add_summary_comment(document, summary)
-
- # 处理标题自动编号(仅在格式化模式下执行,检查模式不修改编号)
- if (
- not check
- and hasattr(config_model, "numbering")
- and config_model.numbering.enabled
- ):
- from wordformat.numbering import process_heading_numbering
-
- process_heading_numbering(
- root_node, document, config_model.numbering, config_model.headings
- )
-
- # 创建引用超链接(仅在格式化模式下执行)
- if not check:
- from wordformat.hyperlinks import create_citation_hyperlinks
-
- create_citation_hyperlinks(root_node, document)
-
- savepath = Path(savepath)
- if check:
- docx_path = str(savepath / f"{filename_without_ext}--标注版.docx")
- else:
- docx_path = str(savepath / f"{filename_without_ext}--修改版.docx")
- logger.info(f"保存文件到 {docx_path}")
- document.save(docx_path)
- return docx_path
diff --git a/src/wordformat/word_structure/__init__.py b/src/wordformat/structure/__init__.py
similarity index 100%
rename from src/wordformat/word_structure/__init__.py
rename to src/wordformat/structure/__init__.py
diff --git a/src/wordformat/word_structure/document_builder.py b/src/wordformat/structure/document_builder.py
similarity index 94%
rename from src/wordformat/word_structure/document_builder.py
rename to src/wordformat/structure/document_builder.py
index ef716c0..2cd9d24 100644
--- a/src/wordformat/word_structure/document_builder.py
+++ b/src/wordformat/structure/document_builder.py
@@ -9,7 +9,7 @@
from loguru import logger
from wordformat.rules.node import FormatNode
-from wordformat.word_structure.tree_builder import DocumentTreeBuilder
+from wordformat.structure.tree_builder import DocumentTreeBuilder
class DocumentBuilder:
diff --git a/src/wordformat/word_structure/node_factory.py b/src/wordformat/structure/node_factory.py
similarity index 92%
rename from src/wordformat/word_structure/node_factory.py
rename to src/wordformat/structure/node_factory.py
index 2894114..3fc030a 100644
--- a/src/wordformat/word_structure/node_factory.py
+++ b/src/wordformat/structure/node_factory.py
@@ -7,7 +7,7 @@
from loguru import logger
from wordformat.rules.node import FormatNode
-from wordformat.word_structure.settings import CATEGORY_TO_CLASS
+from wordformat.structure.settings import CATEGORY_TO_CLASS
def create_node(
diff --git a/src/wordformat/word_structure/settings.py b/src/wordformat/structure/settings.py
similarity index 100%
rename from src/wordformat/word_structure/settings.py
rename to src/wordformat/structure/settings.py
diff --git a/src/wordformat/word_structure/tree_builder.py b/src/wordformat/structure/tree_builder.py
similarity index 94%
rename from src/wordformat/word_structure/tree_builder.py
rename to src/wordformat/structure/tree_builder.py
index 84d8932..713288b 100644
--- a/src/wordformat/word_structure/tree_builder.py
+++ b/src/wordformat/structure/tree_builder.py
@@ -5,8 +5,8 @@
# wordformat/tree_builder.py
from wordformat.rules.node import FormatNode
+from wordformat.structure.settings import CATEGORY_TO_CLASS, LEVEL_MAP
from wordformat.tree import Stack
-from wordformat.word_structure.settings import CATEGORY_TO_CLASS, LEVEL_MAP
class DocumentTreeBuilder:
@@ -47,7 +47,7 @@ def _create_root_node(self) -> FormatNode:
)
def _create_node_from_item(self, item: dict) -> FormatNode | None:
- from wordformat.word_structure.node_factory import create_node
+ from wordformat.structure.node_factory import create_node
level = self._determine_level(item["category"])
return create_node(item=item, level=level, config=self._config)
diff --git a/src/wordformat/word_structure/utils.py b/src/wordformat/structure/utils.py
similarity index 79%
rename from src/wordformat/word_structure/utils.py
rename to src/wordformat/structure/utils.py
index 8783a88..34c4381 100644
--- a/src/wordformat/word_structure/utils.py
+++ b/src/wordformat/structure/utils.py
@@ -7,23 +7,14 @@
from wordformat.rules.body import BodyText
from wordformat.rules.node import FormatNode
+from wordformat.tree import bfs_walk
def find_and_modify_first(root: FormatNode, condition: Callable[[FormatNode], bool]):
- """
- 找到第一个满足 condition 的节点,调用 modifier(node) 修改它,并返回该节点。
- :param root: 树的根节点(FormatNode 实例)
- :param condition: 函数,接收 node,返回 bool
- :return: 被修改的节点(FormatNode) if found, else None
- """
- from collections import deque
-
- queue = deque([root])
- while queue:
- node = queue.popleft()
+ """BFS 查找第一个满足 condition 的节点并返回。"""
+ for node in bfs_walk(root):
if condition(node):
return node
- queue.extend(node.children)
return None
diff --git a/src/wordformat/style/comment_format.py b/src/wordformat/style/comments.py
similarity index 100%
rename from src/wordformat/style/comment_format.py
rename to src/wordformat/style/comments.py
diff --git a/src/wordformat/style/style_enum.py b/src/wordformat/style/defs.py
similarity index 90%
rename from src/wordformat/style/style_enum.py
rename to src/wordformat/style/defs.py
index 58706e9..3f1ae0c 100644
--- a/src/wordformat/style/style_enum.py
+++ b/src/wordformat/style/defs.py
@@ -2,7 +2,6 @@
# @Time : 2026/1/26 10:34
# @Author : afish
# @File : style_enmu.py
-# from src.settings import CHARACTER_STYLE_CHECKS
import re
from abc import abstractmethod
from enum import Enum
@@ -15,7 +14,7 @@
from docx.text.run import Run
from loguru import logger
-from wordformat.style.get_some import (
+from wordformat.style.reader import (
GetIndent,
paragraph_get_alignment,
paragraph_get_builtin_style_name,
@@ -24,14 +23,14 @@
paragraph_get_space_after,
paragraph_get_space_before,
)
-from wordformat.style.set_some import (
- _SetFirstLineIndent,
- _SetIndent,
- _SetLineSpacing,
- _SetSpacing,
+from wordformat.style.units import _get_with_style_fallback, extract_unit_from_string
+from wordformat.style.writer import (
+ SetFirstLineIndent,
+ SetIndent,
+ SetLineSpacing,
+ SetSpacing,
run_set_font_name,
)
-from wordformat.style.utils import extract_unit_from_string
class UnitEnumMeta(type):
@@ -274,6 +273,7 @@ class FontSize(UnitLabelEnum):
"六号": 7.5,
"七号": 5.5,
}
+ _LABEL_MAP_REVERSE = {v: k for k, v in _LABEL_MAP.items()}
def base_set(self, docx_obj: Run, **kwargs):
"""仅作为将字符串转化为数值操作"""
@@ -413,11 +413,20 @@ class Alignment(UnitLabelEnum):
"""
_LABEL_MAP = {
- "左对齐": WD_ALIGN_PARAGRAPH.LEFT, # 左侧对齐,
- "居中对齐": WD_ALIGN_PARAGRAPH.CENTER, # 居中对齐,
- "右对齐": WD_ALIGN_PARAGRAPH.RIGHT, # 右侧对齐,
- "两端对齐": WD_ALIGN_PARAGRAPH.JUSTIFY, # 两端对齐,
- "分散对齐": WD_ALIGN_PARAGRAPH.DISTRIBUTE, # 分散对齐(较少用)
+ "左对齐": WD_ALIGN_PARAGRAPH.LEFT,
+ "居中对齐": WD_ALIGN_PARAGRAPH.CENTER,
+ "右对齐": WD_ALIGN_PARAGRAPH.RIGHT,
+ "两端对齐": WD_ALIGN_PARAGRAPH.JUSTIFY,
+ "分散对齐": WD_ALIGN_PARAGRAPH.DISTRIBUTE,
+ }
+
+ # WD_ALIGN_PARAGRAPH → OOXML w:jc/@val
+ XML_VAL_MAP = {
+ WD_ALIGN_PARAGRAPH.LEFT: "left",
+ WD_ALIGN_PARAGRAPH.CENTER: "center",
+ WD_ALIGN_PARAGRAPH.RIGHT: "right",
+ WD_ALIGN_PARAGRAPH.JUSTIFY: "both",
+ WD_ALIGN_PARAGRAPH.DISTRIBUTE: "distribute",
}
def base_set(self, docx_obj: Paragraph, **kwargs):
@@ -447,18 +456,16 @@ class Spacing(UnitLabelEnum):
"""
class Meta:
- hang = _SetSpacing.set_hang
- pt = _SetSpacing.set_pt
- mm = _SetSpacing.set_mm
- cm = _SetSpacing.set_cm
- inch = _SetSpacing.set_inch
+ hang = SetSpacing.set_hang
+ pt = SetSpacing.set_pt
+ mm = SetSpacing.set_mm
+ cm = SetSpacing.set_cm
+ inch = SetSpacing.set_inch
def get_from_paragraph(self, paragraph: Paragraph) -> float | None:
- unit = self.rel_unit
- if unit == "hang":
- # 注意:需要区分 space_before / space_after!
- # 所以这个方法需要知道是 before 还是 after
- raise NotImplementedError("Spacing 需要知道是 before 还是 after")
+ # 注意:需要区分 space_before / space_after!
+ # 所以这个方法需要知道是 before 还是 after
+ raise NotImplementedError("Spacing 需要知道是 before 还是 after")
class SpaceBefore(Spacing):
@@ -485,23 +492,6 @@ def get_from_paragraph(self, paragraph: Paragraph) -> float | None:
return None
-def _get_with_style_fallback(paragraph, attr: str, default):
- """从段落或样式链读取属性值。"""
- val = getattr(paragraph.paragraph_format, attr, None)
- if val is not None:
- return val
- style = paragraph.style
- while style is not None:
- try:
- val = getattr(style.paragraph_format, attr, None)
- if val is not None:
- return val
- except AttributeError:
- pass
- style = style.base_style
- return default
-
-
class LineSpacingRule(UnitLabelEnum):
"""
设置行距选项
@@ -516,6 +506,16 @@ class LineSpacingRule(UnitLabelEnum):
"多倍行距": WD_LINE_SPACING.MULTIPLE,
}
+ # WD_LINE_SPACING → OOXML w:spacing/@w:lineRule
+ XML_RULE_MAP = {
+ WD_LINE_SPACING.SINGLE: "auto",
+ WD_LINE_SPACING.ONE_POINT_FIVE: "auto",
+ WD_LINE_SPACING.DOUBLE: "auto",
+ WD_LINE_SPACING.AT_LEAST: "atLeast",
+ WD_LINE_SPACING.EXACTLY: "exact",
+ WD_LINE_SPACING.MULTIPLE: "auto",
+ }
+
def base_set(self, docx_obj: Paragraph, **kwargs):
"""仅设置倍为单位的数据"""
line_spacing = self._LABEL_MAP.get(self.value, None)
@@ -551,10 +551,10 @@ class LineSpacing(UnitLabelEnum):
"""
class Meta:
- pt = _SetLineSpacing.set_pt
- mm = _SetLineSpacing.set_mm
- cm = _SetLineSpacing.set_cm
- inch = _SetLineSpacing.set_inch
+ pt = SetLineSpacing.set_pt
+ mm = SetLineSpacing.set_mm
+ cm = SetLineSpacing.set_cm
+ inch = SetLineSpacing.set_inch
def base_set(self, docx_obj: Paragraph, **kwargs):
"""仅设置倍为单位的数据"""
@@ -581,11 +581,11 @@ class Indent(UnitLabelEnum):
"""
class Meta:
- char = _SetIndent.set_char
- pt = _SetIndent.set_pt
- mm = _SetIndent.set_mm
- cm = _SetIndent.set_cm
- inch = _SetIndent.set_inch
+ char = SetIndent.set_char
+ pt = SetIndent.set_pt
+ mm = SetIndent.set_mm
+ cm = SetIndent.set_cm
+ inch = SetIndent.set_inch
class LeftIndent(Indent):
@@ -618,11 +618,11 @@ class FirstLineIndent(UnitLabelEnum):
"""
class Meta:
- char = _SetFirstLineIndent.set_char
- pt = _SetFirstLineIndent.set_pt
- mm = _SetFirstLineIndent.set_mm
- cm = _SetFirstLineIndent.set_cm
- inch = _SetFirstLineIndent.set_inch
+ char = SetFirstLineIndent.set_char
+ pt = SetFirstLineIndent.set_pt
+ mm = SetFirstLineIndent.set_mm
+ cm = SetFirstLineIndent.set_cm
+ inch = SetFirstLineIndent.set_inch
def get_from_paragraph(self, paragraph: Paragraph) -> float | None:
unit = self.rel_unit
@@ -674,7 +674,7 @@ def base_set(self, docx_obj: Paragraph, **kwargs):
except KeyError:
# 样式不存在,创建新样式
doc = docx_obj.part.document
- _ensure_style_exists(doc, style_name)
+ ensure_style_exists(doc, style_name)
docx_obj.style = style_name
def get_from_paragraph(self, paragraph: Paragraph):
@@ -703,7 +703,7 @@ def get_from_paragraph(self, paragraph: Paragraph):
}
-def _ensure_style_exists(doc, style_name: str):
+def ensure_style_exists(doc, style_name: str):
"""
确保文档中存在指定名称的样式,不存在则创建。
diff --git a/src/wordformat/style/check_format.py b/src/wordformat/style/diff.py
similarity index 97%
rename from src/wordformat/style/check_format.py
rename to src/wordformat/style/diff.py
index 4782eb5..ba09756 100644
--- a/src/wordformat/style/check_format.py
+++ b/src/wordformat/style/diff.py
@@ -9,15 +9,16 @@
from docx.text.run import Run
from loguru import logger
-from wordformat.config.config import get_config
-from wordformat.config.datamodel import WarningFieldConfig
-from wordformat.style.get_some import (
+from wordformat.config.loader import get_config
+from wordformat.config.models import WarningFieldConfig
+from wordformat.style.reader import (
run_get_font_color,
run_get_font_name,
run_get_font_size_pt,
)
+from wordformat.utils import has_chinese
-from .style_enum import (
+from .defs import (
Alignment,
BuiltInStyle,
FirstLineIndent,
@@ -51,26 +52,9 @@ def _char_warning_enabled(diff_type: str) -> bool:
return mapping.get(diff_type, True)
-# 磅值 → 中文字号反向映射
-_FONT_SIZE_PT_LABELS: dict[float, str] = {
- 26: "一号",
- 24: "小一",
- 22: "二号",
- 18: "小二",
- 16: "三号",
- 15: "小三",
- 14: "四号",
- 12: "小四",
- 10.5: "五号",
- 9: "小五",
- 7.5: "六号",
- 5.5: "七号",
-}
-
-
def _pt_to_label(pt: float) -> str:
"""磅值 → 中文标签。精确匹配显示字号,否则直接显示 Xpt。"""
- return _FONT_SIZE_PT_LABELS.get(pt, f"{pt}pt")
+ return FontSize._LABEL_MAP_REVERSE.get(pt, f"{pt}pt")
def _format_char_value(diff_type: str, value) -> str:
@@ -278,7 +262,7 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901
# 6. 东亚字体(仅当 run 含中文字符时才检查)
font_name = run_get_font_name(run) or ""
- has_cjk = any("一" <= ch <= "鿿" for ch in run.text)
+ has_cjk = has_chinese(run.text)
if has_cjk and str(font_name).lower() != str(self.font_name_cn).lower():
diffs.append(
DIFFResult(
@@ -346,7 +330,7 @@ def apply_to_run(self, run: Run):
@staticmethod
def to_string(value: list[DIFFResult], target: str = "") -> str:
"""将 DIFFResult 列表转为标准格式批注文本。"""
- from .comment_format import CHAR_DIFF_LABELS, format_comment
+ from .comments import CHAR_DIFF_LABELS, format_comment
t = []
for diff in value:
@@ -585,7 +569,7 @@ def diff_from_paragraph(self, paragraph: Paragraph) -> list[DIFFResult]: # noqa
@staticmethod
def to_string(value: list[DIFFResult], target: str = "") -> str:
"""将 DIFFResult 列表转为标准格式批注文本。"""
- from .comment_format import PARA_DIFF_LABELS, format_comment
+ from .comments import PARA_DIFF_LABELS, format_comment
t = []
for diff in value:
diff --git a/src/wordformat/style/get_some.py b/src/wordformat/style/reader.py
similarity index 99%
rename from src/wordformat/style/get_some.py
rename to src/wordformat/style/reader.py
index b9b46ed..cf0c0e6 100644
--- a/src/wordformat/style/get_some.py
+++ b/src/wordformat/style/reader.py
@@ -12,6 +12,8 @@
from docx.text.run import Run
from loguru import logger
+from wordformat.style.units import _get_with_style_fallback
+
def paragraph_get_alignment(paragraph: Paragraph) -> object:
"""
@@ -211,8 +213,6 @@ def paragraph_get_space_after(paragraph) -> float | None:
def paragraph_get_line_spacing(paragraph): # noqa c901
"""Return line spacing as float; fallback to style chain."""
try:
- from wordformat.style.style_enum import _get_with_style_fallback
-
rule = _get_with_style_fallback(paragraph, "line_spacing_rule", None)
if rule is None:
return None
diff --git a/src/wordformat/style/utils.py b/src/wordformat/style/units.py
similarity index 89%
rename from src/wordformat/style/utils.py
rename to src/wordformat/style/units.py
index 5f2533d..ca398b2 100644
--- a/src/wordformat/style/utils.py
+++ b/src/wordformat/style/units.py
@@ -138,3 +138,20 @@ def extract_unit_from_string(text: str) -> UnitResult:
result.is_valid = True
return result
+
+
+def _get_with_style_fallback(paragraph, attr: str, default):
+ """从段落或样式链读取属性值。"""
+ val = getattr(paragraph.paragraph_format, attr, None)
+ if val is not None:
+ return val
+ style = paragraph.style
+ while style is not None:
+ try:
+ val = getattr(style.paragraph_format, attr, None)
+ if val is not None:
+ return val
+ except AttributeError:
+ pass
+ style = style.base_style
+ return default
diff --git a/src/wordformat/style/set_some.py b/src/wordformat/style/writer.py
similarity index 94%
rename from src/wordformat/style/set_some.py
rename to src/wordformat/style/writer.py
index 03ee76a..d28b822 100644
--- a/src/wordformat/style/set_some.py
+++ b/src/wordformat/style/writer.py
@@ -89,7 +89,7 @@ def set_paragraph_space_after_by_lines(paragraph: Paragraph, lines: float) -> No
_paragraph_space_by_lines(paragraph, after_lines=lines)
-class _SetSpacing:
+class SetSpacing:
"""设置间距距函数"""
@staticmethod
@@ -150,7 +150,7 @@ def set_hang(paragraph: Paragraph, spacing_type: str, value: float):
r''
)
p.append(pPr)
- _SetSpacing._set_hang_on_pPr(pPr, spacing_type, value)
+ SetSpacing._set_hang_on_pPr(pPr, spacing_type, value)
except Exception as e:
logger.error(f"设置段落{spacing_type}间距{value}行失败: {e}")
@@ -163,7 +163,7 @@ def set_pt(paragraph: Paragraph, spacing_type: str, target_value: float):
spacing_type: 间距类型("before"=段前,"after"=段后)
target_value: PT/磅数值
"""
- _SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
+ SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
if spacing_type == "before":
paragraph.paragraph_format.space_before = Pt(target_value)
else:
@@ -178,7 +178,7 @@ def set_cm(paragraph: Paragraph, spacing_type: str, target_value: float):
spacing_type: 间距类型("before"=段前,"after"=段后)
target_value: 厘米数值
"""
- _SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
+ SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
if spacing_type == "before":
paragraph.paragraph_format.space_before = Cm(target_value)
else:
@@ -193,7 +193,7 @@ def set_inch(paragraph: Paragraph, spacing_type: str, target_value: float):
spacing_type: 间距类型("before"=段前,"after"=段后)
target_value: 英寸数值
"""
- _SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
+ SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
if spacing_type == "before":
paragraph.paragraph_format.space_before = Inches(target_value)
else:
@@ -208,14 +208,14 @@ def set_mm(paragraph: Paragraph, spacing_type: str, target_value: float):
spacing_type: 间距类型("before"=段前,"after"=段后)
target_value: 毫米数值
"""
- _SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
+ SetSpacing._clear_conflicting_attrs(paragraph, spacing_type)
if spacing_type == "before":
paragraph.paragraph_format.space_before = Mm(target_value)
else:
paragraph.paragraph_format.space_after = Mm(target_value)
-class _SetLineSpacing:
+class SetLineSpacing:
"""
设置段落行距
"""
@@ -279,7 +279,7 @@ def set_mm(paragraph: Paragraph, value: float):
paragraph.paragraph_format.line_spacing = Mm(value)
-class _SetIndent:
+class SetIndent:
"""
缩进 文本之前(R)/ 文本之后(X):
"""
@@ -319,7 +319,7 @@ def set_char(paragraph: Paragraph, indent_type: str, value: float) -> bool:
"""
try:
pPr = paragraph._element.get_or_add_pPr()
- _SetIndent._set_char_on_pPr(pPr, indent_type, value)
+ SetIndent._set_char_on_pPr(pPr, indent_type, value)
return True
except Exception as e:
logger.error(f"设置字符缩进失败 (type={indent_type}, value={value}): {e}")
@@ -335,7 +335,7 @@ def set_pt(paragraph: Paragraph, indent_type: str, value: float):
value: PT/磅数值
"""
value = Pt(value)
- _SetIndent._apply_indent(paragraph, indent_type, value)
+ SetIndent._apply_indent(paragraph, indent_type, value)
@staticmethod
def set_cm(paragraph: Paragraph, indent_type: str, value: float):
@@ -347,7 +347,7 @@ def set_cm(paragraph: Paragraph, indent_type: str, value: float):
value: 厘米数值
"""
value = Cm(value)
- _SetIndent._apply_indent(paragraph, indent_type, value)
+ SetIndent._apply_indent(paragraph, indent_type, value)
@staticmethod
def set_inch(paragraph: Paragraph, indent_type: str, value: float):
@@ -359,7 +359,7 @@ def set_inch(paragraph: Paragraph, indent_type: str, value: float):
value: 英寸数值
"""
value = Inches(value)
- _SetIndent._apply_indent(paragraph, indent_type, value)
+ SetIndent._apply_indent(paragraph, indent_type, value)
@staticmethod
def set_mm(paragraph: Paragraph, indent_type: str, value: float):
@@ -371,7 +371,7 @@ def set_mm(paragraph: Paragraph, indent_type: str, value: float):
value: 毫米数值
"""
value = Mm(value)
- _SetIndent._apply_indent(paragraph, indent_type, value)
+ SetIndent._apply_indent(paragraph, indent_type, value)
@staticmethod
def _apply_indent(paragraph: Paragraph, indent_type: str, value):
@@ -388,7 +388,7 @@ def _apply_indent(paragraph: Paragraph, indent_type: str, value):
)
-class _SetFirstLineIndent:
+class SetFirstLineIndent:
"""
缩进 特殊格式:首行缩进(>0)/悬挂缩进(<0)
"""
@@ -420,7 +420,7 @@ def clear(paragraph: Paragraph):
同时保留左缩进(left)和右缩进(right)
"""
pPr = paragraph._element.get_or_add_pPr()
- _SetFirstLineIndent._clear_ind_on_pPr(pPr)
+ SetFirstLineIndent._clear_ind_on_pPr(pPr)
@staticmethod
def _set_char_on_pPr(pPr, value: float, existing_left=None, existing_right=None):
@@ -463,7 +463,7 @@ def set_char( # noqa C901
:param value: 缩进值(正数为首行缩进,负数为悬挂缩进,如 -2.2 表示悬挂 2.2 字符)
:return: 设置成功返回 True,失败返回 False
"""
- _SetFirstLineIndent.clear(paragraph)
+ SetFirstLineIndent.clear(paragraph)
try:
pPr = paragraph._element.get_or_add_pPr()
existing_left = None
@@ -473,7 +473,7 @@ def set_char( # noqa C901
existing_left = ind.get(qn("w:left"))
existing_right = ind.get(qn("w:right"))
pPr.remove(ind)
- _SetFirstLineIndent._set_char_on_pPr(
+ SetFirstLineIndent._set_char_on_pPr(
pPr, value, existing_left, existing_right
)
return True
@@ -489,7 +489,7 @@ def set_inch(paragraph: Paragraph, value: float):
:param value: 缩进值(英寸单位为浮点数,如0.5)
:return: 设置成功返回True,失败返回False
"""
- _SetFirstLineIndent.clear(paragraph)
+ SetFirstLineIndent.clear(paragraph)
# 强制清除firstLineChars,避免优先级冲突
pPr = paragraph._element.get_or_add_pPr()
ind = pPr.find(qn("w:ind"))
@@ -505,7 +505,7 @@ def set_mm(paragraph: Paragraph, value: float):
:param value: 缩进值(毫米单位为浮点数,如5.0)
:return:
"""
- _SetFirstLineIndent.clear(paragraph)
+ SetFirstLineIndent.clear(paragraph)
# 强制清除firstLineChars,避免优先级冲突
pPr = paragraph._element.get_or_add_pPr()
ind = pPr.find(qn("w:ind"))
@@ -521,7 +521,7 @@ def set_pt(paragraph: Paragraph, value: float):
:param value: 缩进值(磅单位为浮点数,如0.5)
:return:
"""
- _SetFirstLineIndent.clear(paragraph)
+ SetFirstLineIndent.clear(paragraph)
# 强制清除firstLineChars,避免优先级冲突
pPr = paragraph._element.get_or_add_pPr()
ind = pPr.find(qn("w:ind"))
@@ -537,7 +537,7 @@ def set_cm(paragraph: Paragraph, value: float):
:param value: 缩进值(厘米单位为浮点数,如0.5)
:return:
"""
- _SetFirstLineIndent.clear(paragraph)
+ SetFirstLineIndent.clear(paragraph)
# 强制清除firstLineChars,避免优先级冲突
pPr = paragraph._element.get_or_add_pPr()
ind = pPr.find(qn("w:ind"))
diff --git a/src/wordformat/style/xml_ops.py b/src/wordformat/style/xml_ops.py
new file mode 100644
index 0000000..3390522
--- /dev/null
+++ b/src/wordformat/style/xml_ops.py
@@ -0,0 +1,154 @@
+#! /usr/bin/env python
+"""Word OOXML 样式定义级别的 rPr / pPr XML 原子操作。
+
+本模块仅供底层 XML 写入,不依赖 python-docx 对象模型(Paragraph / Run),
+可被 set_some.py、stages.py 等模块复用。
+
+值转换遵循 set_some.py 中 _Set* 类的模式:
+- 行距:倍数→w:line(×240),pt→twips(×20)
+- 段间距:行→w:beforeLines/w:afterLines(×100)
+- 缩进:字符→w:leftChars/w:rightChars/w:firstLineChars(×100)
+"""
+
+from docx.oxml import OxmlElement
+from docx.oxml.ns import qn
+from docx.shared import Pt
+
+from wordformat.style.defs import Alignment, LineSpacingRule
+
+# --- w:rPr 字符格式 ---
+
+
+def rPr_set_font(rPr, cn_name=None, en_name=None):
+ """在任意 w:rPr 元素上设置中英文字体(w:rFonts)。"""
+ rFonts = rPr.find(qn("w:rFonts"))
+ if rFonts is None:
+ rFonts = OxmlElement("w:rFonts")
+ rPr.insert(0, rFonts)
+ if cn_name:
+ rFonts.set(qn("w:eastAsia"), str(cn_name))
+ if en_name:
+ rFonts.set(qn("w:ascii"), str(en_name))
+ rFonts.set(qn("w:hAnsi"), str(en_name))
+
+
+def rPr_set_font_size(rPr, pt_val):
+ """在任意 w:rPr 元素上设置字号(w:sz / w:szCs)。pt_val 单位为磅。"""
+ half_pt = str(int(round(pt_val * 2)))
+ sz = rPr.find(qn("w:sz"))
+ if sz is None:
+ sz = OxmlElement("w:sz")
+ rPr.append(sz)
+ sz.set(qn("w:val"), half_pt)
+ szCs = rPr.find(qn("w:szCs"))
+ if szCs is None:
+ szCs = OxmlElement("w:szCs")
+ rPr.append(szCs)
+ szCs.set(qn("w:val"), half_pt)
+
+
+def rPr_set_font_color(rPr, rgb_tuple):
+ """在任意 w:rPr 元素上设置字体颜色,清除旧的主题色 w:color。"""
+ hex_color = f"{rgb_tuple[0]:02X}{rgb_tuple[1]:02X}{rgb_tuple[2]:02X}"
+ for old_color in rPr.findall(qn("w:color")):
+ rPr.remove(old_color)
+ new_color = OxmlElement("w:color")
+ new_color.set(qn("w:val"), hex_color)
+ rPr.append(new_color)
+
+
+def rPr_set_bold(rPr, bold):
+ """在任意 w:rPr 元素上设置/移除加粗(w:b / w:bCs)。"""
+ b = rPr.find(qn("w:b"))
+ bCs = rPr.find(qn("w:bCs"))
+ if bold:
+ if b is None:
+ rPr.append(OxmlElement("w:b"))
+ if bCs is None:
+ rPr.append(OxmlElement("w:bCs"))
+ else:
+ if b is not None:
+ rPr.remove(b)
+ if bCs is not None:
+ rPr.remove(bCs)
+
+
+def rPr_set_italic(rPr, italic):
+ """在任意 w:rPr 元素上设置/移除斜体(w:i)。"""
+ i = rPr.find(qn("w:i"))
+ if italic:
+ if i is None:
+ rPr.append(OxmlElement("w:i"))
+ else:
+ if i is not None:
+ rPr.remove(i)
+
+
+def rPr_set_underline(rPr, underline):
+ """在任意 w:rPr 元素上设置/移除下划线(w:u)。"""
+ u = rPr.find(qn("w:u"))
+ if underline:
+ if u is None:
+ u = OxmlElement("w:u")
+ u.set(qn("w:val"), "single")
+ rPr.append(u)
+ else:
+ if u is not None:
+ rPr.remove(u)
+
+
+# --- w:pPr 段落格式 ---
+
+
+def pPr_set_alignment(pPr, wd_alignment):
+ """在任意 w:pPr 元素上设置对齐方式(w:jc)。
+
+ wd_alignment: WD_ALIGN_PARAGRAPH 枚举值,通过 Alignment.rel_value 获取。
+ """
+ xml_val = Alignment.XML_VAL_MAP.get(wd_alignment, "left")
+ jc = pPr.find(qn("w:jc"))
+ if jc is None:
+ jc = OxmlElement("w:jc")
+ pPr.append(jc)
+ jc.set(qn("w:val"), xml_val)
+
+
+def line_spacing_val_to_xml(rel_value, rel_unit):
+ """将 LineSpacing 的 (value, unit) 转为 Word w:line 内部值。
+
+ 遵循 _SetLineSpacing 的模式:倍数→240ths,pt→twips(1pt=20twips)。
+ """
+ if rel_unit == "倍":
+ return rel_value * 240
+ if rel_unit in ("pt",):
+ return int(Pt(rel_value)) // 635 # EMU → twips
+ return rel_value * 240
+
+
+def line_rule_to_xml(wd_line_spacing_rule):
+ """将 WD_LINE_SPACING 枚举值转为 w:lineRule XML 字符串。
+
+ wd_line_spacing_rule: 通过 LineSpacingRule.rel_value 获取。
+ """
+ return LineSpacingRule.XML_RULE_MAP.get(wd_line_spacing_rule, "auto")
+
+
+# --- 元素创建辅助 ---
+
+
+def ensure_rPr(style_element):
+ """确保 style 元素中存在 w:rPr,不存在则创建并返回。"""
+ rPr = style_element.find(qn("w:rPr"))
+ if rPr is None:
+ rPr = OxmlElement("w:rPr")
+ style_element.insert(0, rPr)
+ return rPr
+
+
+def ensure_pPr(style_element):
+ """确保 style 元素中存在 w:pPr,不存在则创建并返回。"""
+ pPr = style_element.find(qn("w:pPr"))
+ if pPr is None:
+ pPr = OxmlElement("w:pPr")
+ style_element.append(pPr)
+ return pPr
diff --git a/src/wordformat/tree.py b/src/wordformat/tree.py
index 773677e..33371bc 100644
--- a/src/wordformat/tree.py
+++ b/src/wordformat/tree.py
@@ -5,6 +5,8 @@
from collections.abc import Callable, Iterator
from typing import Any, Generic, Optional, TypeVar
+from rich.tree import Tree as RichTree
+
from wordformat.rules.node import TreeNode
@@ -141,72 +143,52 @@ def __bool__(self) -> bool:
def print_tree(
node_or_jsonpath: TreeNode | str,
- prefix: str = "",
- is_last: bool = True,
show_confidence: bool = False,
show_index: bool = False,
filter_categories: list[str] | None = None,
- _counter: list[int] | None = None,
) -> None:
- """
- 以树形结构打印多叉树(类似 Linux 的 tree 命令)
+ """以树形结构打印多叉树(使用 rich 渲染)。
支持直接传入 TreeNode 或 JSON 文件路径。
-
- Args:
- node_or_jsonpath: 树节点或 JSON 文件路径
- prefix: 前缀(递归内部使用)
- is_last: 是否为同级最后一个节点
- show_confidence: 是否显示置信度
- show_index: 是否显示节点序号
- filter_categories: 仅显示指定类别(None 表示全部显示)
- _counter: 内部计数器(递归内部使用)
"""
- # 如果传入的是 JSON 文件路径,先构建树再打印
- if isinstance(node_or_jsonpath, str):
- import json
-
- from wordformat.rules.node import TreeNode
+ from rich.console import Console
+ from rich.table import Table
- with open(node_or_jsonpath, encoding="utf-8") as f:
- paragraphs = json.load(f)
+ console = Console()
- # 直接从 JSON 构建简单树(不依赖 config)
- root_node = TreeNode({"category": "top", "paragraph": ""})
- _build_simple_tree(root_node, paragraphs)
+ if isinstance(node_or_jsonpath, str):
+ node_or_jsonpath, paragraphs = _load_tree_from_json(node_or_jsonpath)
- # 统计各类别数量
+ # 统计各类别
from collections import Counter
cat_counter = Counter()
for p in paragraphs:
cat_counter[p.get("category", "unknown")] += 1
- print(f"\n📄 文档结构树 ({len(paragraphs)} 个段落)")
- print("=" * 60)
- for cat, count in cat_counter.most_common():
- print(f" {cat:<30s} {count:>4d}")
- print("=" * 60)
-
- _print_tree_node(
- root_node,
- "",
- True,
- show_confidence,
- show_index,
- filter_categories,
+ console.print(
+ f"\n:page_facing_up: 文档结构树 ([bold]{len(paragraphs)}[/] 个段落)"
)
- return
-
- # 递归打印节点
- _print_tree_node(
- node_or_jsonpath,
- prefix,
- is_last,
- show_confidence,
- show_index,
- filter_categories,
+ table = Table(show_header=False, box=None, padding=(0, 4))
+ for cat, count in cat_counter.most_common():
+ table.add_row(f" {cat}", f"{count:>4d}")
+ console.print(table)
+
+ rich_tree = _build_rich_tree(
+ node_or_jsonpath, show_confidence, show_index, filter_categories
)
+ console.print(rich_tree)
+
+
+def _load_tree_from_json(json_path: str) -> tuple[TreeNode, list[dict]]:
+ """从 JSON 文件加载段落列表并构建简单树。"""
+ import json
+
+ with open(json_path, encoding="utf-8") as f:
+ paragraphs = json.load(f)
+ root_node = TreeNode({"category": "top", "paragraph": ""})
+ _build_simple_tree(root_node, paragraphs)
+ return root_node, paragraphs
def _build_simple_tree(root: TreeNode, items: list[dict]) -> None:
@@ -273,82 +255,102 @@ def _build_simple_tree(root: TreeNode, items: list[dict]) -> None:
parent.add_child_node(node)
-def _print_tree_node(
+def _tree_node_category(value) -> str:
+ """从节点 value 中提取类别字符串。"""
+ if isinstance(value, dict):
+ return value.get("category", "")
+ if hasattr(value, "paragraph") and isinstance(value.paragraph, dict):
+ return value.paragraph.get("category", "")
+ return ""
+
+
+def _format_node_label(
+ value, show_confidence: bool, show_index: bool, counter: int
+) -> str:
+ """格式化单个节点的显示标签。"""
+ idx_str = f"[{counter:>3d}] " if show_index else ""
+ if isinstance(value, dict):
+ cat = value.get("category", "unknown")
+ para = str(value.get("paragraph", ""))[:50]
+ conf = value.get("confidence", None)
+ conf_str = f" ({conf:.0%})" if show_confidence and conf is not None else ""
+ return f"{idx_str}[dim]{cat}[/dim]{conf_str} {para}"
+ if hasattr(value, "paragraph") and isinstance(value.paragraph, dict):
+ cat = value.paragraph.get("category", "unknown")
+ para = str(value.paragraph.get("paragraph", ""))[:50]
+ return f"{idx_str}[dim]{cat}[/dim] {para}"
+ return f"{idx_str}{str(value)[:60]}"
+
+
+def _build_rich_tree(
node: TreeNode,
- prefix: str,
- is_last: bool,
show_confidence: bool,
show_index: bool,
filter_categories: list[str] | None,
_counter: list[int] | None = None,
-) -> None:
- """递归打印单个节点及其子树"""
+) -> RichTree | None:
+ """递归构建 rich Tree。"""
+
if _counter is None:
_counter = [0]
value = node.value
- category = ""
-
- # 提取类别
- if isinstance(value, dict):
- category = value.get("category", "")
- elif hasattr(value, "paragraph") and isinstance(value.paragraph, dict):
- category = value.paragraph.get("category", "")
-
- # 过滤类别
- should_show = True
- if filter_categories is not None:
- should_show = category in filter_categories
- # 如果当前节点被过滤,但其子节点有匹配的,仍需遍历子节点
- if not should_show:
- if hasattr(node, "children"):
- for child in node.children:
- _print_tree_node(
- child,
- prefix,
- is_last,
- show_confidence,
- show_index,
- filter_categories,
- _counter,
- )
- return
+ category = _tree_node_category(value)
- # 打印当前节点
- connector = "└── " if is_last else "├── "
+ if filter_categories is not None and category not in filter_categories:
+ # 跳过当前节点但继续递归子节点
+ for child in getattr(node, "children", []):
+ subtree = _build_rich_tree(
+ child, show_confidence, show_index, filter_categories, _counter
+ )
+ if subtree is not None:
+ return subtree
+ return None
- if isinstance(value, dict):
- cat = value.get("category", "unknown")
- para = value.get("paragraph", "")[:50]
- idx_str = f"[{_counter[0]:>3d}] " if show_index else ""
- conf = value.get("confidence", None)
- conf_str = f" ({conf:.0%})" if show_confidence and conf is not None else ""
- display = f"{idx_str}【{cat}】{conf_str} {para}"
- elif hasattr(value, "paragraph") and isinstance(value.paragraph, dict):
- cat = value.paragraph.get("category", "unknown")
- para = value.paragraph.get("paragraph", "")[:50]
- idx_str = f"[{_counter[0]:>3d}] " if show_index else ""
- display = f"{idx_str}【{cat}】 {para}"
- else:
- idx_str = f"[{_counter[0]:>3d}] " if show_index else ""
- display = f"{idx_str}{str(value)[:60]}"
-
- print(prefix + connector + display) # noqa t201
+ label = _format_node_label(value, show_confidence, show_index, _counter[0])
_counter[0] += 1
+ rich_node = RichTree(label)
- # 递归打印子节点
if hasattr(node, "children"):
- children = node.children
- for i, child in enumerate(children):
- is_last_child = i == len(children) - 1
- extension = " " if is_last else "│ "
- new_prefix = prefix + extension
- _print_tree_node(
- child,
- new_prefix,
- is_last_child,
- show_confidence,
- show_index,
- filter_categories,
- _counter,
+ for child in node.children:
+ child_tree = _build_rich_tree(
+ child, show_confidence, show_index, filter_categories, _counter
)
+ if child_tree is not None:
+ rich_node.add(child_tree)
+
+ return rich_node
+
+
+# ---------------------------------------------------------------------------
+# 独立于 Tree 类的通用遍历工具(直接作用于 TreeNode / FormatNode)
+# ---------------------------------------------------------------------------
+
+
+def bfs_walk(root: TreeNode):
+ """BFS 层序遍历生成器,按文档顺序 yield 每个节点(含根节点)。
+
+ 用法:
+ for node in bfs_walk(root_node):
+ if isinstance(node, SomeType):
+ ...
+ """
+ from collections import deque
+
+ queue = deque([root])
+ while queue:
+ node = queue.popleft()
+ yield node
+ queue.extend(node.children)
+
+
+def dfs_walk(root: TreeNode):
+ """DFS 前序遍历生成器,yield 每个子节点(不含根节点)。
+
+ 用法:
+ for node in dfs_walk(root_node):
+ ...
+ """
+ for child in root.children:
+ yield child
+ yield from dfs_walk(child)
diff --git a/src/wordformat/utils/__init__.py b/src/wordformat/utils/__init__.py
new file mode 100644
index 0000000..b554cd5
--- /dev/null
+++ b/src/wordformat/utils/__init__.py
@@ -0,0 +1,24 @@
+"""通用工具包(子模块拆分,顶层重导出保持向后兼容)。"""
+
+from wordformat.utils._docx import para_contains_image, remove_all_numbering
+from wordformat.utils._fs import (
+ ensure_directory_exists,
+ ensure_is_directory,
+ get_file_name,
+)
+from wordformat.utils._text import (
+ _count_numbering_levels,
+ _format_number,
+ _from_chinese_num,
+ _from_roman,
+ _get_level_fmt,
+ _to_chinese_num,
+ _to_roman,
+ count_chinese_chars,
+ extract_chinese_chars,
+ get_paragraph_numbering_text,
+ has_chinese,
+ is_chinese_char,
+ parse_caption_text,
+)
+from wordformat.utils._yaml import load_yaml_with_merge
diff --git a/src/wordformat/utils/_docx.py b/src/wordformat/utils/_docx.py
new file mode 100644
index 0000000..5cc3b50
--- /dev/null
+++ b/src/wordformat/utils/_docx.py
@@ -0,0 +1,66 @@
+"""docx 文档级操作。"""
+
+import os
+
+from docx.oxml.ns import qn
+from loguru import logger
+
+
+def remove_all_numbering(doc):
+ """
+ 强制解除样式与列表的绑定
+ Args:
+ doc:
+ Returns:
+
+ """
+ title_style_names = ["Heading 1", "Heading 2", "Heading 3"]
+
+ for style_name in title_style_names:
+ if style_name in doc.styles:
+ style = doc.styles[style_name]
+ style_element = style._element
+
+ # 删除 中的 numPr(样式级别的编号)
+ pPr = style_element.find(qn("w:pPr"))
+ if pPr is not None:
+ numPr = pPr.find(qn("w:numPr"))
+ if numPr is not None:
+ pPr.remove(numPr)
+
+ # 可选:也删除 outlineLvl(大纲级别,有时触发编号)
+ outlineLvl = pPr.find(qn("w:outlineLvl"))
+ if outlineLvl is not None:
+ pPr.remove(outlineLvl)
+
+ logger.debug(f"已解除样式 '{style_name}' 的编号绑定")
+
+
+def ensure_directory_exists(path):
+ """
+ 检查路径是否存在,如果不存在则创建对应的文件夹。
+
+ 参数:
+ path (str): 需要检查或创建的文件夹路径
+
+ 说明:
+ - 如果路径已存在且是文件夹,则不做任何操作
+ - 如果路径不存在,则递归创建所有必需的父目录
+ - 如果路径存在但是是文件,则抛出 ValueError
+ """
+ if os.path.exists(path):
+ if not os.path.isdir(path):
+ raise ValueError(f"路径已存在但不是文件夹:'{path}'")
+ else:
+ os.makedirs(path, exist_ok=True)
+ logger.info(f"已创建文件夹:'{path}'")
+
+
+def para_contains_image(para) -> bool:
+ """检查段落是否包含内联图片(w:drawing)。"""
+ from docx.oxml.ns import qn
+
+ for r in para._element.findall(qn("w:r")):
+ if r.find(qn("w:drawing")) is not None:
+ return True
+ return False
diff --git a/src/wordformat/utils/_fs.py b/src/wordformat/utils/_fs.py
new file mode 100644
index 0000000..4729cc9
--- /dev/null
+++ b/src/wordformat/utils/_fs.py
@@ -0,0 +1,29 @@
+"""文件系统工具。"""
+
+import os
+
+from loguru import logger
+
+
+def get_file_name(file_name: str) -> str:
+ basename = os.path.basename(file_name)
+ filename_without_ext, _ = os.path.splitext(basename)
+ return filename_without_ext
+
+
+def ensure_is_directory(path):
+ """检查 path 是否为已存在的文件夹,否则抛出 ValueError。"""
+ if not os.path.exists(path):
+ raise ValueError(f"路径不存在: '{path}'")
+ if not os.path.isdir(path):
+ raise ValueError(f"路径不是一个文件夹(它可能是一个文件): '{path}'")
+
+
+def ensure_directory_exists(path):
+ """确保路径存在,不存在则递归创建,是文件则抛出 ValueError。"""
+ if os.path.exists(path):
+ if not os.path.isdir(path):
+ raise ValueError(f"路径已存在但不是文件夹:'{path}'")
+ else:
+ os.makedirs(path, exist_ok=True)
+ logger.info(f"已创建文件夹:'{path}'")
diff --git a/src/wordformat/utils.py b/src/wordformat/utils/_text.py
similarity index 62%
rename from src/wordformat/utils.py
rename to src/wordformat/utils/_text.py
index 91dc93b..4b9eefe 100644
--- a/src/wordformat/utils.py
+++ b/src/wordformat/utils/_text.py
@@ -1,43 +1,9 @@
-#! /usr/bin/env python
-# @Time : 2025/12/22 22:20
-# @Author : afish
-# @File : utils.py
-import os
-from typing import Any
-
-import yaml
-from docx.oxml.ns import qn
-from docx.text.paragraph import Paragraph
-from loguru import logger
+"""文本工具:CJK 字符检测、编号文字、题注解析。"""
+import re
-def load_yaml_with_merge(file_path: str) -> dict[str, Any]:
- """
- 加载 YAML 文件,并正确处理 <<: *anchor 合并语法。
-
- 要求 YAML 文件使用标准的 YAML merge key (<<)。
- """
- with open(file_path, encoding="utf-8") as f:
- # 使用 FullLoader 支持 << 合并
- config = yaml.load(f, Loader=yaml.FullLoader)
- return config
-
-
-def ensure_is_directory(path):
- """
- 检查 path 是否为一个已存在的文件夹路径。
- 如果不是(是文件、或路径不存在),则抛出 ValueError。
- """
- if not os.path.exists(path):
- raise ValueError(f"路径不存在: '{path}'")
- if not os.path.isdir(path):
- raise ValueError(f"路径不是一个文件夹(它可能是一个文件): '{path}'")
-
-
-def get_file_name(file_name: str) -> str:
- basename = os.path.basename(file_name)
- filename_without_ext, _ = os.path.splitext(basename) # 提取docx文件名称
- return filename_without_ext
+from docx.oxml.ns import qn
+from docx.text.paragraph import Paragraph
def get_paragraph_numbering_text(paragraph: Paragraph) -> str:
@@ -355,116 +321,78 @@ def _from_chinese_num(chinese: str) -> int:
return result
+def _make_caption_result(
+ label, ch_text, ch_num, sep, num_text, num_num, name, is_continued
+):
+ """构建题注解析结果字典。"""
+ return {
+ "label": label,
+ "chapter_text": ch_text,
+ "chapter_num": ch_num,
+ "separator": sep,
+ "number_text": num_text,
+ "number_num": num_num,
+ "name": name.strip(),
+ "is_continued": is_continued,
+ }
+
+
+def _try_parse_num(text: str, parser):
+ """安全解析数字,失败返回 None。"""
+ try:
+ return parser(text)
+ except ValueError:
+ return None
+
+
def parse_caption_text(text: str) -> dict | None:
"""解析题注文本为结构化组件。
支持格式:[续][标签][章节号][分隔符][题注编号] [题注名称]
- 章节号支持阿拉伯数字、中文数字、罗马数字。
- 分隔符支持 . - : — –
- 支持 "续表"/"续图" 前缀,解析后 is_continued 为 True。
-
- Returns:
- {"label", "chapter_text", "separator", "number_text", "name",
- "chapter_num", "number_num", "is_continued"} 或 None
"""
- import re
-
text = text.strip()
if not text:
return None
- # 检测 "续" 前缀(续表/续图)
is_continued = text.startswith("续")
if is_continued:
text = text[1:].strip()
-
if not text:
return None
- # 分隔符:句点 . 、连字符 - 、冒号 : 、长划线 — (U+2014)、短划线 – (U+2013)
SEP = r"[.\-:—–]"
- # 标签后可选空格(支持 "图 1.2 xxx" 和 "图1.2 xxx")
LABEL = r"([图表])\s*"
-
- # 模式1:图/表 + 阿拉伯数字章节 + 分隔符 + 阿拉伯数字编号 + 可选空格 + 名称
- m = re.match(rf"^{LABEL}(\d+)({SEP})(\d+)[\s ]+(.+)$", text)
- if m:
- label, ch_text, sep, num_text, name = m.groups()
- return {
- "label": label,
- "chapter_text": ch_text,
- "chapter_num": int(ch_text),
- "separator": sep,
- "number_text": num_text,
- "number_num": int(num_text),
- "name": name.strip(),
- "is_continued": is_continued,
- }
-
- # 模式2:图/表 + 中文数字章节 + 分隔符 + 阿拉伯数字编号 + 可选空格 + 名称
- chinese_num_chars = r"[一二三四五六七八九十百千零壹贰叁肆伍陆柒捌玖拾佰仟]+"
- m = re.match(rf"^{LABEL}({chinese_num_chars})({SEP})(\d+)[\s ]+(.+)$", text)
- if m:
- label, ch_text, sep, num_text, name = m.groups()
- try:
- ch_num = _from_chinese_num(ch_text)
- except ValueError:
- ch_num = None
- return {
- "label": label,
- "chapter_text": ch_text,
- "chapter_num": ch_num,
- "separator": sep,
- "number_text": num_text,
- "number_num": int(num_text),
- "name": name.strip(),
- "is_continued": is_continued,
- }
-
- # 模式3:图/表 + 罗马数字章节 + 分隔符 + 阿拉伯数字编号 + 可选空格 + 名称
- roman_chars = r"[IVXLCDMivxlcdm]+"
- m = re.match(rf"^{LABEL}({roman_chars})({SEP})(\d+)[\s ]+(.+)$", text)
- if m:
- label, ch_text, sep, num_text, name = m.groups()
- try:
- ch_num = _from_roman(ch_text)
- except ValueError:
- ch_num = None
- return {
- "label": label,
- "chapter_text": ch_text,
- "chapter_num": ch_num,
- "separator": sep,
- "number_text": num_text,
- "number_num": int(num_text),
- "name": name.strip(),
- "is_continued": is_continued,
- }
-
- # 尝试匹配编号部分也使用中文数字或罗马数字
- m = re.match(
- rf"^{LABEL}(\d+)({SEP})({chinese_num_chars}|{roman_chars})[\s ]+(.+)$", text
- )
- if m:
- label, ch_text, sep, num_text, name = m.groups()
- ch_num = int(ch_text)
- try:
- is_chinese = any(c in _digit_map for c in num_text)
- num_num = (
- _from_chinese_num(num_text) if is_chinese else _from_roman(num_text)
+ CN = r"[一二三四五六七八九十百千零壹贰叁肆伍陆柒捌玖拾佰仟]+"
+ ROMAN = r"[IVXLCDMivxlcdm]+"
+
+ # 按优先级依次尝试各模式: (pattern, ch_parser, num_parser)
+ patterns = [
+ (rf"^{LABEL}(\d+)({SEP})(\d+)[\s ]+(.+)$", int, int),
+ (rf"^{LABEL}({CN})({SEP})(\d+)[\s ]+(.+)$", _from_chinese_num, int),
+ (rf"^{LABEL}({ROMAN})({SEP})(\d+)[\s ]+(.+)$", _from_roman, int),
+ (
+ rf"^{LABEL}(\d+)({SEP})({CN}|{ROMAN})[\s ]+(.+)$",
+ int,
+ lambda n: _from_chinese_num(n)
+ if any(c in _digit_map for c in n)
+ else _from_roman(n),
+ ),
+ ]
+
+ for pattern, ch_parser, num_parser in patterns:
+ m = re.match(pattern, text)
+ if m:
+ label, ch_text, sep, num_text, name = m.groups()
+ return _make_caption_result(
+ label,
+ ch_text,
+ _try_parse_num(ch_text, ch_parser),
+ sep,
+ num_text,
+ _try_parse_num(num_text, num_parser),
+ name,
+ is_continued,
)
- except ValueError:
- num_num = None
- return {
- "label": label,
- "chapter_text": ch_text,
- "chapter_num": ch_num,
- "separator": sep,
- "number_text": num_text,
- "number_num": num_num,
- "name": name.strip(),
- "is_continued": is_continued,
- }
return None
@@ -493,51 +421,26 @@ def parse_caption_text(text: str) -> dict | None:
}
-def remove_all_numbering(doc):
- """
- 强制解除样式与列表的绑定
- Args:
- doc:
- Returns:
-
- """
- title_style_names = ["Heading 1", "Heading 2", "Heading 3"]
+# ---------------------------------------------------------------------------
+# CJK 字符检测工具
+# ---------------------------------------------------------------------------
- for style_name in title_style_names:
- if style_name in doc.styles:
- style = doc.styles[style_name]
- style_element = style._element
- # 删除 中的 numPr(样式级别的编号)
- pPr = style_element.find(qn("w:pPr"))
- if pPr is not None:
- numPr = pPr.find(qn("w:numPr"))
- if numPr is not None:
- pPr.remove(numPr)
+def is_chinese_char(ch: str) -> bool:
+ """判定单个字符是否为 CJK 统一表意文字。"""
+ return "一" <= ch <= "鿿"
- # 可选:也删除 outlineLvl(大纲级别,有时触发编号)
- outlineLvl = pPr.find(qn("w:outlineLvl"))
- if outlineLvl is not None:
- pPr.remove(outlineLvl)
- logger.debug(f"已解除样式 '{style_name}' 的编号绑定")
+def count_chinese_chars(text: str) -> int:
+ """统计文本中 CJK 字符数量。"""
+ return sum(1 for ch in text if is_chinese_char(ch))
-def ensure_directory_exists(path):
- """
- 检查路径是否存在,如果不存在则创建对应的文件夹。
+def extract_chinese_chars(text: str) -> str:
+ """提取文本中的纯 CJK 字符序列。"""
+ return "".join(ch for ch in text if is_chinese_char(ch))
- 参数:
- path (str): 需要检查或创建的文件夹路径
- 说明:
- - 如果路径已存在且是文件夹,则不做任何操作
- - 如果路径不存在,则递归创建所有必需的父目录
- - 如果路径存在但是是文件,则抛出 ValueError
- """
- if os.path.exists(path):
- if not os.path.isdir(path):
- raise ValueError(f"路径已存在但不是文件夹:'{path}'")
- else:
- os.makedirs(path, exist_ok=True)
- logger.info(f"已创建文件夹:'{path}'")
+def has_chinese(text: str) -> bool:
+ """文本中是否包含至少一个 CJK 字符。"""
+ return any(is_chinese_char(ch) for ch in text)
diff --git a/src/wordformat/utils/_yaml.py b/src/wordformat/utils/_yaml.py
new file mode 100644
index 0000000..db98eb0
--- /dev/null
+++ b/src/wordformat/utils/_yaml.py
@@ -0,0 +1,11 @@
+"""YAML 配置加载。"""
+
+from typing import Any
+
+import yaml
+
+
+def load_yaml_with_merge(file_path: str) -> dict[str, Any]:
+ """加载 YAML 文件,正确处理 <<: *anchor 合并语法。"""
+ with open(file_path, encoding="utf-8") as f:
+ return yaml.load(f, Loader=yaml.FullLoader)
diff --git a/tests/conftest.py b/tests/conftest.py
index 7674416..24ed9ba 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -14,7 +14,6 @@
# 项目根目录
PROJECT_ROOT = Path(__file__).parent.parent
-EXAMPLE_CONFIG = PROJECT_ROOT / "example" / "undergrad_thesis.yaml"
# ==================== Document Fixtures ====================
@@ -78,32 +77,7 @@ def temp_json(tmp_path):
# ==================== Config Fixtures ====================
-@pytest.fixture
-def config_path():
- """返回示例配置文件路径"""
- return str(EXAMPLE_CONFIG)
-
-
-@pytest.fixture
-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,
- )
- config.numbering = MagicMock(enabled=False)
- return config
-
-
-@pytest.fixture
-def sample_yaml_config(tmp_path):
- """创建一个最小化的 YAML 配置文件"""
- yaml_content = """
+_INLINE_YAML = """\
global_format:
alignment: '两端对齐'
space_before: "0.5行"
@@ -219,8 +193,38 @@ def sample_yaml_config(tmp_path):
alignment: '两端对齐'
first_line_indent: '2字符'
"""
+
+@pytest.fixture
+def config_path(tmp_path):
+ """用内联 YAML 生成临时配置文件,不依赖外部文件。"""
+ config_file = tmp_path / "test_config.yaml"
+ config_file.write_text(_INLINE_YAML, encoding="utf-8")
+ return str(config_file)
+
+
+@pytest.fixture
+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,
+ )
+ config.numbering = MagicMock(enabled=False)
+ return config
+
+
+
+
+@pytest.fixture
+def sample_yaml_config(tmp_path):
+ """用内联 YAML 生成临时配置文件。"""
path = tmp_path / "test_config.yaml"
- path.write_text(yaml_content, encoding="utf-8")
+ path.write_text(_INLINE_YAML, encoding="utf-8")
return str(path)
@@ -248,7 +252,7 @@ def mock_onnx_single():
@pytest.fixture(autouse=True)
def reset_config():
"""每个测试前后自动清理配置状态"""
- from wordformat.config.config import clear_config
+ from wordformat.config.loader import clear_config
clear_config()
yield
clear_config()
@@ -257,8 +261,8 @@ def reset_config():
@pytest.fixture(autouse=True)
def reset_style_warning():
"""每个测试前后重置 style_checks_warning 全局变量"""
- from wordformat.style import check_format
- original = check_format.style_checks_warning
- check_format.style_checks_warning = None
+ from wordformat.style import diff
+ original = diff.style_checks_warning
+ diff.style_checks_warning = None
yield
- check_format.style_checks_warning = original
+ diff.style_checks_warning = original
diff --git a/tests/test_api.py b/tests/test_api.py
new file mode 100644
index 0000000..5360677
--- /dev/null
+++ b/tests/test_api.py
@@ -0,0 +1,410 @@
+"""
+集成测试:跨模块交互与端到端行为验证
+
+覆盖模块:cli.py, config, agent, set_style.py, set_tag.py, word_structure
+"""
+import argparse
+import io
+import os
+import shutil
+import tempfile
+import threading
+from unittest import mock
+
+import pytest
+import numpy as np
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH
+from docx.shared import Pt
+from pydantic import ValidationError
+from fastapi.testclient import TestClient
+
+from wordformat.cli import validate_file, main
+from wordformat.config.loader import (
+ LazyConfig,
+ ConfigNotLoadedError,
+ init_config,
+ get_config,
+ clear_config,
+)
+from wordformat.config.models import (
+ KeywordCountRule,
+ GlobalFormatConfig,
+ NodeConfigRoot,
+)
+from wordformat.agent.message import MessageManager
+from wordformat.agent.onnx_infer import (
+ _get_best_onnx_providers,
+ _load_model,
+ onnx_single_infer,
+ onnx_batch_infer,
+ safe_batch_infer,
+)
+from wordformat.pipeline.stages import FormattingExecutionStage, ParagraphAlignmentStage
+from wordformat.classify.tag import set_tag_main
+from wordformat.structure.node_factory import create_node
+from wordformat.structure.tree_builder import DocumentTreeBuilder
+from wordformat.structure.document_builder import DocumentBuilder
+from wordformat.structure.utils import promote_bodytext_in_subtrees_of_type
+from wordformat.rules.node import FormatNode
+import wordformat.api
+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
+
+
+# ==================== (h) CLI 集成测试 ====================
+
+
+class TestCLIIntegration:
+ """validate_file + mock main() 各模式"""
+
+ def test_validate_file_accepts_real_file(self):
+ with tempfile.NamedTemporaryFile(delete=False) as f:
+ path = f.name
+ try:
+ result = validate_file(path, "文档")
+ assert result == os.path.abspath(path)
+ finally:
+ os.unlink(path)
+
+ def test_validate_file_rejects_missing(self):
+ with pytest.raises(argparse.ArgumentTypeError):
+ validate_file("/nonexistent/path.txt", "文档")
+
+ def test_validate_file_rejects_directory(self):
+ with tempfile.TemporaryDirectory() as d:
+ with pytest.raises(argparse.ArgumentTypeError):
+ validate_file(d, "文档")
+
+ def test_validate_file_rejects_wrong_extension(self):
+ with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
+ path = f.name
+ try:
+ with pytest.raises(argparse.ArgumentTypeError):
+ validate_file(path, "文档", [".docx"])
+ finally:
+ os.unlink(path)
+
+ @mock.patch("sys.argv")
+ def test_main_no_args_prints_help(self, mock_argv):
+ mock_argv.__getitem__.side_effect = lambda i: ["wf"][i]
+ mock_argv.__len__.return_value = 1
+ main() # 不应抛异常
+
+ @mock.patch("wordformat.cli.set_tag_main", return_value=[])
+ @mock.patch("wordformat.cli.json")
+ @mock.patch("sys.argv")
+ def test_main_gj_mode(self, mock_argv, mock_json, mock_set_tag):
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as df:
+ docx_path = df.name
+ with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as cf:
+ cfg_path = cf.name
+ out_dir = tempfile.mkdtemp()
+ try:
+ mock_argv.__getitem__.side_effect = lambda i: [
+ "wf", "gj", "-d", docx_path, "-c", cfg_path, "-o", out_dir
+ ][i]
+ mock_argv.__len__.return_value = 8
+ main()
+ mock_set_tag.assert_called_once()
+ mock_json.dump.assert_called_once()
+ finally:
+ os.unlink(docx_path)
+ os.unlink(cfg_path)
+ shutil.rmtree(out_dir, ignore_errors=True)
+
+ @mock.patch("wordformat.cli.auto_format_thesis_document")
+ @mock.patch("sys.argv")
+ def test_main_cf_mode(self, mock_argv, mock_auto):
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as df:
+ docx_path = df.name
+ with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as cf:
+ cfg_path = cf.name
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as jf:
+ json_path = jf.name
+ 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
+ ][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,
+ )
+ finally:
+ for p in [docx_path, cfg_path, json_path]:
+ os.unlink(p)
+ os.rmdir(out_dir)
+
+ @mock.patch("wordformat.cli.auto_format_thesis_document")
+ @mock.patch("sys.argv")
+ def test_main_af_mode(self, mock_argv, mock_auto):
+ with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as df:
+ docx_path = df.name
+ with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as cf:
+ cfg_path = cf.name
+ with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as jf:
+ json_path = jf.name
+ 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
+ ][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,
+ )
+ finally:
+ for p in [docx_path, cfg_path, json_path]:
+ os.unlink(p)
+ os.rmdir(out_dir)
+
+ @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.__len__.return_value = 4
+ with mock.patch.dict("sys.modules", {"uvicorn": mock.MagicMock()}):
+ with pytest.raises(SystemExit):
+ main()
+
+
+
+# ==================== (u) api/__init__.py 覆盖测试 ====================
+
+
+class TestSaveUploadFile:
+ """覆盖 api/__init__.py save_upload_file 函数"""
+
+ 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"):
+ from wordformat.api import save_upload_file
+ wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True)
+
+ mock_file = mock.MagicMock()
+ mock_file.filename = "test.docx"
+ mock_file.file.read.return_value = b"fake docx content"
+
+ result = save_upload_file(mock_file, wordformat.api.TEMP_DIR)
+ assert os.path.exists(result)
+ with open(result, "rb") as f:
+ assert f.read() == b"fake docx content"
+
+ 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"):
+ wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True)
+
+ # 预先创建同名文件
+ existing_file = wordformat.api.TEMP_DIR / "test.docx"
+ existing_file.write_bytes(b"existing content")
+
+ mock_file = mock.MagicMock()
+ mock_file.filename = "test.docx"
+ mock_file.file.read.return_value = b"new content"
+
+ result = save_upload_file(mock_file, wordformat.api.TEMP_DIR)
+ assert os.path.exists(result)
+ assert result.endswith("test_1.docx")
+ with open(result, "rb") as f:
+ assert f.read() == b"new content"
+ # 原文件未被覆盖
+ assert existing_file.read_bytes() == b"existing content"
+
+ 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"):
+ 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")
+ (wordformat.api.TEMP_DIR / "test_1.docx").write_bytes(b"b")
+ (wordformat.api.TEMP_DIR / "test_2.docx").write_bytes(b"c")
+
+ mock_file = mock.MagicMock()
+ mock_file.filename = "test.docx"
+ mock_file.file.read.return_value = b"d"
+
+ result = save_upload_file(mock_file, wordformat.api.TEMP_DIR)
+ assert result.endswith("test_3.docx")
+
+
+
+class TestAPIEndpoints:
+ """覆盖 api/__init__.py 所有 API 端点"""
+
+ @pytest.fixture
+ def api_client(self, tmp_path):
+ """创建 TestClient,mock BASE_DIR 使目录创建在 tmp_path"""
+ temp_dir = tmp_path / "temp"
+ output_dir = tmp_path / "output"
+ 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):
+ from wordformat.api import app
+ client = TestClient(app)
+ yield client, temp_dir, output_dir
+
+ 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"}]
+ 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")
+
+ response = client.post(
+ "/generate-json",
+ files={
+ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
+ "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["code"] == 200
+ assert "json_data" in data["data"]
+
+ def test_generate_json_non_docx_returns_400(self, api_client):
+ """POST /generate-json 上传非 docx 文件返回 code 400"""
+ client, temp_dir, output_dir = api_client
+
+ docx_bytes = io.BytesIO(b"fake content")
+ yaml_bytes = io.BytesIO(b"key: value")
+
+ response = client.post(
+ "/generate-json",
+ files={
+ "docx_file": ("test.pdf", docx_bytes, "application/octet-stream"),
+ "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ },
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["code"] == 400
+ assert ".docx" in data["msg"]
+
+ def test_check_format_success(self, api_client):
+ """POST /check-format 成功调用 auto_format_thesis_document(check=True)"""
+ 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):
+ docx_bytes = io.BytesIO(b"fake docx")
+ yaml_bytes = io.BytesIO(b"key: value")
+ json_str = '[{"category": "body_text", "score": 0.9}]'
+
+ response = client.post(
+ "/check-format",
+ files={
+ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
+ "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ },
+ data={"json_data": json_str},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["code"] == 200
+ assert "标注版" in data["data"]["final_filename"]
+ assert "download_url" in data["data"]
+
+ def test_apply_format_success(self, api_client):
+ """POST /apply-format 成功调用 auto_format_thesis_document(check=False)"""
+ 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):
+ docx_bytes = io.BytesIO(b"fake docx")
+ yaml_bytes = io.BytesIO(b"key: value")
+ json_str = '[{"category": "body_text", "score": 0.9}]'
+
+ response = client.post(
+ "/apply-format",
+ files={
+ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
+ "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
+ },
+ data={"json_data": json_str},
+ )
+
+ assert response.status_code == 200
+ data = response.json()
+ assert data["code"] == 200
+ assert "修改版" in data["data"]["final_filename"]
+ assert "download_url" in data["data"]
+
+ def test_download_file_exists(self, api_client):
+ """GET /download/{filename} 文件存在时返回文件"""
+ client, temp_dir, output_dir = api_client
+
+ # 在 output_dir 创建一个测试文件
+ test_file = output_dir / "result.docx"
+ test_file.write_bytes(b"docx content here")
+
+ response = client.get(f"/download/result.docx")
+ assert response.status_code == 200
+ assert response.content == b"docx content here"
+
+ def test_download_file_not_found(self, api_client):
+ """GET /download/{filename} 文件不存在时返回 404"""
+ client, temp_dir, output_dir = api_client
+
+ response = client.get("/download/nonexistent.docx")
+ # 已修复:重新抛出 HTTPException,正确返回 404
+ assert response.status_code == 404
+
+ 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")):
+ docx_bytes = io.BytesIO(b"fake docx")
+ yaml_bytes = io.BytesIO(b"key: value")
+
+ response = client.post(
+ "/generate-json",
+ files={
+ "docx_file": ("test.docx", docx_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()}):
+ with um.patch("wordformat.api.app", um.MagicMock()):
+ main()
+ # 验证 uvicorn.run 被调用
+ import sys
+ if "uvicorn" in sys.modules:
+ sys.modules["uvicorn"].run.assert_called_once()
+
diff --git a/tests/test_caption_numbering.py b/tests/test_caption_numbering.py
index 4ca3ac0..91e546f 100644
--- a/tests/test_caption_numbering.py
+++ b/tests/test_caption_numbering.py
@@ -5,7 +5,7 @@
import pytest
from docx import Document
-from wordformat.config.datamodel import CaptionNumberingConfig
+from wordformat.config.models import CaptionNumberingConfig
from wordformat.utils import _from_chinese_num, _from_roman, parse_caption_text
# ======================== _from_roman ========================
@@ -570,7 +570,7 @@ def caption_yaml(self, tmp_path):
return str(path)
def _init_config(self, caption_yaml):
- from wordformat.config.config import get_config, init_config
+ from wordformat.config.loader import get_config, init_config
init_config(caption_yaml)
return get_config()
@@ -610,7 +610,7 @@ def _make_paragraph(self, text):
def test_check_mode_injects_chapter_and_seq(self, caption_yaml):
"""验证遍历时章节号和顺序号被注入到 node.value 中。"""
- from wordformat.set_style import 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 系统架构图")
@@ -635,10 +635,10 @@ def _suppress_format_comments(self):
mock_cs.diff_from_run.return_value = {}
mock_cs.apply_to_run.return_value = {}
with patch(
- "wordformat.style.check_format.ParagraphStyle.from_config",
+ "wordformat.style.diff.ParagraphStyle.from_config",
return_value=mock_ps,
), patch(
- "wordformat.style.check_format.CharacterStyle",
+ "wordformat.style.diff.CharacterStyle",
return_value=mock_cs,
):
yield
@@ -646,7 +646,7 @@ def _suppress_format_comments(self):
@pytest.mark.usefixtures("_suppress_format_comments")
def test_check_mode_correct_no_comment(self, caption_yaml):
"""check 模式:格式正确,不添加批注。"""
- from wordformat.set_style import 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 系统架构图")
@@ -662,7 +662,7 @@ 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.set_style import 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 测试图")
@@ -679,7 +679,7 @@ 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.set_style import 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 旧名称")
@@ -694,7 +694,7 @@ 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.set_style import 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 图")
@@ -717,7 +717,7 @@ 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.set_style import 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 第一章图")
@@ -747,7 +747,7 @@ 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.set_style import 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 测试")
@@ -766,7 +766,7 @@ 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.set_style import 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 岗位信息表")
@@ -797,7 +797,7 @@ 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.set_style import 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接口测试结果")
diff --git a/tests/test_classify.py b/tests/test_classify.py
new file mode 100644
index 0000000..645e5bd
--- /dev/null
+++ b/tests/test_classify.py
@@ -0,0 +1,230 @@
+"""
+Core 模块综合测试
+
+覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py
+"""
+import os
+import pytest
+from io import StringIO
+from unittest.mock import MagicMock, patch
+
+from docx import Document
+from docx.oxml.ns import qn
+
+from wordformat.tree import Tree, Stack, print_tree
+from wordformat.rules.node import TreeNode, FormatNode
+from wordformat.numbering import (
+ _auto_strip_numbering,
+ _strip_reference_numbering,
+ apply_auto_numbering,
+ create_numbering_definition,
+ process_heading_numbering,
+)
+from wordformat.utils import (
+ get_file_name,
+ ensure_is_directory,
+ ensure_directory_exists,
+ _to_roman,
+ _to_chinese_num,
+ load_yaml_with_merge,
+ get_paragraph_numbering_text,
+ remove_all_numbering,
+ _format_number,
+ _get_level_fmt,
+ _count_numbering_levels,
+)
+from wordformat.style.reader import _get_style_spacing
+from wordformat.base import DocxBase
+from wordformat import settings
+
+
+# ============================================================
+# tree.py — Tree
+# ============================================================
+
+
+# ============================================================
+# base.py — DocxBase
+# ============================================================
+
+
+class TestDocxBase:
+ """测试 DocxBase 类的初始化和 parse 方法"""
+
+ def test_init(self, temp_docx):
+ """测试 DocxBase 初始化"""
+ base = DocxBase(temp_docx, "/fake/config.yaml")
+ assert base.docx_file == temp_docx
+ assert base.document is not None
+ assert base.re_dict == {}
+
+ def _create_multi_para_docx(self, tmp_path, texts):
+ """辅助方法:创建包含多个段落的 docx 文件"""
+ doc = Document()
+ for text in texts:
+ doc.add_paragraph(text)
+ path = str(tmp_path / "multi.docx")
+ doc.save(path)
+ return path
+
+ def test_parse_with_mocked_batch_infer(self, tmp_path):
+ """测试 parse 方法使用 mock 的批量推理"""
+ path = self._create_multi_para_docx(tmp_path, ["绪论", "研究背景", "正文内容"])
+ mock_batch_results = [
+ {"label": "heading_level_1", "score": 0.95},
+ {"label": "body_text", "score": 0.88},
+ {"label": "body_text", "score": 0.75},
+ ]
+
+ with patch("wordformat.base.onnx_batch_infer", return_value=mock_batch_results):
+ base = DocxBase(path, "/fake/config.yaml")
+ result = base.parse()
+
+ assert len(result) == 3
+ assert result[0]["category"] == "heading_level_1"
+ assert result[1]["category"] == "body_text"
+ assert "paragraph" in result[0]
+ assert "score" in result[0]
+
+ def test_parse_low_score_forced_to_body_text(self, tmp_path):
+ """测试低置信度结果被强制设为 body_text"""
+ path = self._create_multi_para_docx(tmp_path, ["绪论", "研究背景"])
+ mock_batch_results = [
+ {"label": "heading_level_1", "score": 0.95},
+ {"label": "heading_level_2", "score": 0.3},
+ ]
+
+ with patch("wordformat.base.onnx_batch_infer", return_value=mock_batch_results):
+ base = DocxBase(path, "/fake/config.yaml")
+ result = base.parse()
+
+ assert result[0]["category"] == "heading_level_1"
+ 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):
+ base = DocxBase(temp_docx, "/fake/config.yaml")
+ result = base.parse()
+
+ # temp_docx 只有一个段落
+ assert len(result) >= 1
+ assert all(item["category"] == "body_text" for item in result)
+
+ def test_parse_empty_document(self, tmp_path):
+ """测试空文档的解析"""
+ doc = Document()
+ path = str(tmp_path / "empty.docx")
+ doc.save(path)
+
+ with patch("wordformat.base.onnx_batch_infer", return_value=[]) as mock_infer:
+ base = DocxBase(path, "/fake/config.yaml")
+ result = base.parse()
+
+ assert result == []
+ mock_infer.assert_not_called()
+
+ def test_parse_batches_correctly(self, tmp_path):
+ """测试按 BATCH_SIZE 分批推理"""
+ path = self._create_multi_para_docx(tmp_path, [f"段落 {i}" for i in range(5)])
+
+ call_count = 0
+
+ def mock_batch(texts):
+ nonlocal call_count
+ call_count += 1
+ return [{"label": "body_text", "score": 0.9}] * len(texts)
+
+ with patch("wordformat.base.onnx_batch_infer", side_effect=mock_batch):
+ with patch("wordformat.base.BATCH_SIZE", 2):
+ base = DocxBase(path, "/fake/config.yaml")
+ result = base.parse()
+
+ assert len(result) == 5
+ # BATCH_SIZE=2, 5 个段落 → 3 次调用 (2+2+1)
+ assert call_count == 3
+
+ def test_parse_includes_numbering_text(self, tmp_path):
+ """测试解析时包含自动编号文字"""
+ from docx.oxml import OxmlElement
+ from docx.opc.constants import RELATIONSHIP_TYPE as RT
+ from docx.opc.packuri import PackURI
+ from docx.parts.numbering import NumberingPart
+
+ doc = Document()
+
+ # 先移除已有的 numbering 关系
+ rels = doc.part.rels
+ to_remove = [k for k, v in rels.items() if v.reltype == RT.NUMBERING]
+ for k in to_remove:
+ del rels[k]
+
+ p = doc.add_paragraph("绪论")
+
+ # 添加 numbering
+ numbering_elm = OxmlElement("w:numbering")
+ abstract_num = OxmlElement("w:abstractNum")
+ abstract_num.set(qn("w:abstractNumId"), "0")
+ lvl = OxmlElement("w:lvl")
+ lvl.set(qn("w:ilvl"), "0")
+ start = OxmlElement("w:start")
+ start.set(qn("w:val"), "1")
+ lvl.append(start)
+ numFmt = OxmlElement("w:numFmt")
+ numFmt.set(qn("w:val"), "decimal")
+ lvl.append(numFmt)
+ lvlText = OxmlElement("w:lvlText")
+ lvlText.set(qn("w:val"), "%1.")
+ lvl.append(lvlText)
+ abstract_num.append(lvl)
+ numbering_elm.append(abstract_num)
+
+ num = OxmlElement("w:num")
+ num.set(qn("w:numId"), "1")
+ abstract_num_id_ref = OxmlElement("w:abstractNumId")
+ abstract_num_id_ref.set(qn("w:val"), "0")
+ num.append(abstract_num_id_ref)
+ numbering_elm.append(num)
+
+ numbering_part = NumberingPart(
+ PackURI("/word/numbering.xml"),
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
+ numbering_elm,
+ doc.part.package,
+ )
+ doc.part.relate_to(numbering_part, RT.NUMBERING)
+
+ pPr = OxmlElement("w:pPr")
+ numPr = OxmlElement("w:numPr")
+ numId_elem = OxmlElement("w:numId")
+ numId_elem.set(qn("w:val"), "1")
+ numPr.append(numId_elem)
+ ilvl_elem = OxmlElement("w:ilvl")
+ ilvl_elem.set(qn("w:val"), "0")
+ numPr.append(ilvl_elem)
+ pPr.append(numPr)
+ p._element.insert(0, pPr)
+
+ 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}]):
+ base = DocxBase(path, "/fake/config.yaml")
+ result = base.parse()
+
+ assert len(result) == 1
+ # 段落文本应包含编号 "1. 绪论"
+ assert result[0]["paragraph"] == "1. 绪论"
+
+
+# ============================================================
+# utils.py — _format_number 额外覆盖测试
+# ============================================================
+
+
+
diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py
index 230fb6d..fc355cd 100644
--- a/tests/test_coverage_boost.py
+++ b/tests/test_coverage_boost.py
@@ -14,7 +14,7 @@
from docx.oxml.ns import qn
from docx.oxml import OxmlElement
-from wordformat.config.datamodel import NodeConfigRoot
+from wordformat.config.models import NodeConfigRoot
from wordformat.rules.heading import (
HeadingLevel1Node,
HeadingLevel2Node,
@@ -27,16 +27,20 @@
_build_numbering_rPr,
_auto_strip_numbering,
)
-from wordformat.style.style_enum import (
+from wordformat.style.defs import (
BuiltInStyle,
FirstLineIndent,
- _ensure_style_exists,
+ ensure_style_exists,
)
-from wordformat.set_style import (
- _fix_all_style_definitions,
- apply_format_check_to_all_nodes,
+from wordformat.pipeline.stages import (
+ StyleDefinitionFixStage,
+ FormattingExecutionStage,
)
-from wordformat.style.get_some import (
+
+_fix_stage = StyleDefinitionFixStage()
+_format_stage = FormattingExecutionStage()
+apply_format_check_to_all_nodes = _format_stage.apply_format_check_to_all_nodes
+from wordformat.style.reader import (
paragraph_get_space_before,
paragraph_get_space_after,
paragraph_get_line_spacing,
@@ -75,7 +79,7 @@ def _load_root_config(config_path):
@pytest.fixture
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
- from wordformat.config.config import init_config
+ from wordformat.config.loader import init_config
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -147,10 +151,8 @@ def test_apply_format_updates_existing_pstyle(self, root_config):
assert p.style.name == "Heading 2"
def test_fix_all_style_definitions_collects_all_styles(self, root_config):
- """_collect_all_style_configs 应收集配置中所有唯一的样式名。"""
- from wordformat.set_style import _collect_all_style_configs
-
- style_map = _collect_all_style_configs(root_config)
+ """collect_style_configs 应收集配置中所有唯一的样式名。"""
+ style_map = root_config.collect_style_configs()
assert len(style_map) > 0
# 应包含 Normal(body_text 使用)和 Heading 1/2/3
style_names = list(style_map.keys())
@@ -158,20 +160,18 @@ def test_fix_all_style_definitions_collects_all_styles(self, root_config):
def test_fix_all_style_definitions_handles_document(self, root_config):
"""_fix_all_style_definitions 应对文档中存在的样式定义进行修正。"""
- from wordformat.set_style import _fix_all_style_definitions
-
doc = Document()
- _fix_all_style_definitions(doc, root_config)
+ _fix_stage._fix_all_style_definitions(doc, root_config)
# 不应抛出异常
def test_fix_style_run_properties_clears_theme_color(self, root_config):
"""_fix_style_run_properties 应清除样式定义中的主题色。"""
- from wordformat.set_style import _fix_style_run_properties
- from wordformat.style.style_enum import _ensure_style_exists
+
+ from wordformat.style.defs import ensure_style_exists
doc = Document()
cfg = root_config.headings.level_1
- _ensure_style_exists(doc, "Heading 1")
+ ensure_style_exists(doc, "Heading 1")
style = doc.styles["Heading 1"]
# 手动设置主题色
@@ -184,7 +184,7 @@ def test_fix_style_run_properties_clears_theme_color(self, root_config):
color.set(qn("w:val"), "4472C4")
rPr.append(color)
- _fix_style_run_properties(style, cfg, "Heading 1")
+ _fix_stage._fix_style_run_properties(style, cfg, "Heading 1")
rPr_after = style.element.find(qn("w:rPr"))
color_after = rPr_after.find(qn("w:color"))
@@ -193,15 +193,15 @@ 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.set_style import _fix_style_run_properties
- from wordformat.style.style_enum import _ensure_style_exists
+
+ from wordformat.style.defs import ensure_style_exists
doc = Document()
cfg = root_config.body_text
- _ensure_style_exists(doc, "Normal")
+ ensure_style_exists(doc, "Normal")
style = doc.styles["Normal"]
- _fix_style_run_properties(style, cfg, "Normal")
+ _fix_stage._fix_style_run_properties(style, cfg, "Normal")
rPr = style.element.find(qn("w:rPr"))
rFonts = rPr.find(qn("w:rFonts"))
@@ -209,16 +209,16 @@ 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.set_style import _fix_style_run_properties
- from wordformat.style.style_enum import _ensure_style_exists
- from wordformat.config.datamodel import HeadingLevelConfig
+
+ from wordformat.style.defs import ensure_style_exists
+ from wordformat.config.models import HeadingLevelConfig
doc = Document()
cfg = HeadingLevelConfig(bold=True)
- _ensure_style_exists(doc, "Heading 1")
+ ensure_style_exists(doc, "Heading 1")
style = doc.styles["Heading 1"]
- _fix_style_run_properties(style, cfg, "Heading 1")
+ _fix_stage._fix_style_run_properties(style, cfg, "Heading 1")
rPr = style.element.find(qn("w:rPr"))
b = rPr.find(qn("w:b"))
@@ -226,16 +226,16 @@ def test_fix_style_run_properties_sets_bold(self, root_config):
def test_fix_style_run_properties_removes_italic(self, root_config):
"""_fix_style_run_properties 当 italic=False 时应移除斜体元素。"""
- from wordformat.set_style import _fix_style_run_properties
- from wordformat.style.style_enum import _ensure_style_exists
- from wordformat.config.datamodel import HeadingLevelConfig
+
+ from wordformat.style.defs import ensure_style_exists
+ from wordformat.config.models import HeadingLevelConfig
doc = Document()
cfg = HeadingLevelConfig(italic=False)
- _ensure_style_exists(doc, "Heading 1")
+ ensure_style_exists(doc, "Heading 1")
style = doc.styles["Heading 1"]
- _fix_style_run_properties(style, cfg, "Heading 1")
+ _fix_stage._fix_style_run_properties(style, cfg, "Heading 1")
rPr = style.element.find(qn("w:rPr"))
i = rPr.find(qn("w:i"))
@@ -243,16 +243,16 @@ def test_fix_style_run_properties_removes_italic(self, root_config):
def test_fix_style_paragraph_properties_sets_alignment(self, root_config):
"""_fix_style_paragraph_properties 应设置样式定义中的对齐方式。"""
- from wordformat.set_style import _fix_style_paragraph_properties
- from wordformat.style.style_enum import _ensure_style_exists
- from wordformat.config.datamodel import HeadingLevelConfig
+
+ from wordformat.style.defs import ensure_style_exists
+ from wordformat.config.models import HeadingLevelConfig
doc = Document()
cfg = HeadingLevelConfig(alignment="居中对齐")
- _ensure_style_exists(doc, "Heading 1")
+ ensure_style_exists(doc, "Heading 1")
style = doc.styles["Heading 1"]
- _fix_style_paragraph_properties(style, cfg, "Heading 1")
+ _fix_stage._fix_style_paragraph_properties(style, cfg, "Heading 1")
pPr = style.element.find(qn("w:pPr"))
jc = pPr.find(qn("w:jc"))
@@ -663,7 +663,7 @@ def test_ensure_style_exists_base_style_none(self):
# 先删除 Normal 样式(如果存在),确保需要创建
# 实际上 Normal 总是存在的,所以用另一个方式触发
# 使用一个不在映射中的样式名,其 base 默认为 "Normal"
- _ensure_style_exists(doc, "TestStyleNoBase")
+ ensure_style_exists(doc, "TestStyleNoBase")
# 清理
try:
doc.styles.element.remove(
@@ -685,7 +685,7 @@ def test_ensure_style_exists_with_outline_lvl(self):
except KeyError:
pass
- _ensure_style_exists(doc, "Heading 4")
+ ensure_style_exists(doc, "Heading 4")
# 验证 outlineLvl 已设置
style = doc.styles["Heading 4"]
@@ -699,7 +699,7 @@ def test_ensure_style_exists_already_exists(self):
"""覆盖行 630-633: 样式已存在时直接返回。"""
doc = Document()
# Normal 样式总是存在
- _ensure_style_exists(doc, "Normal")
+ ensure_style_exists(doc, "Normal")
# 不应抛出异常
def test_ensure_style_creates_with_base(self):
@@ -713,7 +713,7 @@ def test_ensure_style_creates_with_base(self):
except KeyError:
pass
- _ensure_style_exists(doc, style_name)
+ ensure_style_exists(doc, style_name)
style = doc.styles[style_name]
assert style is not None
# 清理
@@ -736,18 +736,18 @@ def test_fix_all_style_definitions_no_config_sections(self):
"""config_model 无任何有效的 GlobalFormatConfig 时不抛异常。"""
doc = Document()
# 使用一个只有 numbering 和 global_format 的空模型
- from wordformat.config.datamodel import NodeConfigRoot, GlobalFormatConfig, BodyTextConfig
+ from wordformat.config.models import NodeConfigRoot, GlobalFormatConfig, BodyTextConfig
config_bare = NodeConfigRoot(
global_format=GlobalFormatConfig(),
body_text=BodyTextConfig(),
)
- _fix_all_style_definitions(doc, config_bare)
+ _fix_stage._fix_all_style_definitions(doc, config_bare)
# 不应抛出异常
def test_fix_all_style_definitions_theme_color_fix(self):
"""修正主题色的完整流程:有 themeColor 的样式被修正。"""
- from wordformat.config.datamodel import NodeConfigRoot, HeadingsConfig, HeadingLevelConfig, GlobalFormatConfig
+ from wordformat.config.models import NodeConfigRoot, HeadingsConfig, HeadingLevelConfig, GlobalFormatConfig
doc = Document()
config_model = NodeConfigRoot(
@@ -772,7 +772,7 @@ def test_fix_all_style_definitions_theme_color_fix(self):
except KeyError:
pass
- _fix_all_style_definitions(doc, config_model)
+ _fix_stage._fix_all_style_definitions(doc, config_model)
# 验证主题色已清除
rPr_after = style.element.find(qn("w:rPr"))
diff --git a/tests/test_integration.py b/tests/test_integration.py
index 6cdd5df..2925290 100644
--- a/tests/test_integration.py
+++ b/tests/test_integration.py
@@ -7,7 +7,6 @@
import io
import os
import shutil
-import sys
import tempfile
import threading
from unittest import mock
@@ -21,18 +20,16 @@
from fastapi.testclient import TestClient
from wordformat.cli import validate_file, main
-from wordformat.config.config import (
+from wordformat.config.loader import (
LazyConfig,
ConfigNotLoadedError,
init_config,
get_config,
clear_config,
)
-from wordformat.config.datamodel import (
+from wordformat.config.models import (
KeywordCountRule,
- KeywordsConfig,
GlobalFormatConfig,
- FontSizeType,
NodeConfigRoot,
)
from wordformat.agent.message import MessageManager
@@ -43,14 +40,17 @@
onnx_batch_infer,
safe_batch_infer,
)
-from wordformat.set_style import apply_format_check_to_all_nodes
-from wordformat.set_tag import set_tag_main
-from wordformat.word_structure.node_factory import create_node
-from wordformat.word_structure.tree_builder import DocumentTreeBuilder
-from wordformat.word_structure.document_builder import DocumentBuilder
-from wordformat.word_structure.utils import promote_bodytext_in_subtrees_of_type
+from wordformat.pipeline.stages import FormattingExecutionStage, ParagraphAlignmentStage
+from wordformat.classify.tag import set_tag_main
+from wordformat.structure.node_factory import create_node
+from wordformat.structure.tree_builder import DocumentTreeBuilder
+from wordformat.structure.document_builder import DocumentBuilder
+from wordformat.structure.utils import promote_bodytext_in_subtrees_of_type
from wordformat.rules.node import FormatNode
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
# ==================== (a) Config 模块集成测试 ====================
@@ -109,6 +109,7 @@ def create():
assert all(r is results[0] for r in results)
+
# ==================== (b) DataModel 验证测试 ====================
@@ -153,6 +154,7 @@ def test_font_size_accepts_literal(self):
assert cfg.font_size == "小四"
+
# ==================== (c) Agent/Message 测试 ====================
@@ -225,6 +227,7 @@ def clearer():
assert len(msgs) == 0
+
# ==================== (d) Agent/ONNX 推理测试 ====================
@@ -233,9 +236,9 @@ class TestONNXInferIntegration:
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"):
+ 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]
@@ -248,9 +251,9 @@ def test_single_infer_success(self):
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"):
+ 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]
@@ -263,9 +266,9 @@ 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"):
+ 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]
@@ -284,9 +287,11 @@ 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"]):
assert _get_best_onnx_providers() == ["CPUExecutionProvider"]
@@ -309,6 +314,7 @@ def load():
assert len(errors) == 0
+
# ==================== (e) word_structure 集成测试 ====================
@@ -316,7 +322,7 @@ class TestNodeFactoryIntegration:
"""create_node 创建/拒绝 + 真实 CATEGORY_TO_CLASS"""
def test_create_known_category(self, sample_yaml_config):
- from wordformat.config.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
item = {
@@ -329,25 +335,26 @@ def test_create_known_category(self, sample_yaml_config):
assert hasattr(node, "check_format")
def test_create_unknown_category_returns_none(self, sample_yaml_config):
- from wordformat.config.config import init_config, get_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"}
- with mock.patch("wordformat.word_structure.node_factory.CATEGORY_TO_CLASS", {}):
+ with mock.patch("wordformat.structure.node_factory.CATEGORY_TO_CLASS", {}):
assert create_node(item, level=1, config=config) is None
def test_create_missing_category_raises(self, sample_yaml_config):
- from wordformat.config.config import init_config, get_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"""
- @mock.patch("wordformat.word_structure.node_factory.create_node")
+ @mock.patch("wordformat.structure.node_factory.create_node")
def test_build_flat_headings(self, mock_create):
mock_node = mock.MagicMock()
mock_node.level = 1
@@ -362,11 +369,12 @@ 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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
data = [
@@ -384,13 +392,14 @@ def test_config_not_global_state(self):
assert hasattr(builder, "_config")
+
# ==================== (f) set_tag 集成测试 ====================
class TestSetTagMainIntegration:
"""mock DocxBase.parse 验证 set_tag_main 调用链"""
- @mock.patch("wordformat.set_tag.DocxBase")
+ @mock.patch("wordformat.classify.tag.DocxBase")
def test_set_tag_main_calls_parse(self, mock_docx_cls):
mock_instance = mock_docx_cls.return_value
mock_instance.parse.return_value = [
@@ -400,13 +409,14 @@ def test_set_tag_main_calls_parse(self, mock_docx_cls):
assert len(result) == 1
mock_instance.parse.assert_called_once()
- @mock.patch("wordformat.set_tag.DocxBase")
+ @mock.patch("wordformat.classify.tag.DocxBase")
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")
+
# ==================== (g) set_style 集成测试 ====================
@@ -472,7 +482,8 @@ def load_config(self, cfg):
def test_flatten_tree_nodes_matches_doc_order(self):
"""DFS 展平的节点顺序与 document.paragraphs 一致。"""
- from wordformat.set_style import _flatten_tree_nodes
+
+ _flatten_tree_nodes = ParagraphAlignmentStage()._flatten_tree_nodes
root = FormatNode(value={"category": "top"}, level=0)
a = FormatNode(value={"category": "abstract_chinese_title"}, level=1)
@@ -488,125 +499,6 @@ def test_flatten_tree_nodes_matches_doc_order(self):
assert nodes == [a, b, c, d]
-# ==================== (h) CLI 集成测试 ====================
-
-
-class TestCLIIntegration:
- """validate_file + mock main() 各模式"""
-
- def test_validate_file_accepts_real_file(self):
- with tempfile.NamedTemporaryFile(delete=False) as f:
- path = f.name
- try:
- result = validate_file(path, "文档")
- assert result == os.path.abspath(path)
- finally:
- os.unlink(path)
-
- def test_validate_file_rejects_missing(self):
- with pytest.raises(argparse.ArgumentTypeError):
- validate_file("/nonexistent/path.txt", "文档")
-
- def test_validate_file_rejects_directory(self):
- with tempfile.TemporaryDirectory() as d:
- with pytest.raises(argparse.ArgumentTypeError):
- validate_file(d, "文档")
-
- def test_validate_file_rejects_wrong_extension(self):
- with tempfile.NamedTemporaryFile(suffix=".exe", delete=False) as f:
- path = f.name
- try:
- with pytest.raises(argparse.ArgumentTypeError):
- validate_file(path, "文档", [".docx"])
- finally:
- os.unlink(path)
-
- @mock.patch("sys.argv")
- def test_main_no_args_prints_help(self, mock_argv):
- mock_argv.__getitem__.side_effect = lambda i: ["wf"][i]
- mock_argv.__len__.return_value = 1
- main() # 不应抛异常
-
- @mock.patch("wordformat.cli.set_tag_main", return_value=[])
- @mock.patch("wordformat.cli.json")
- @mock.patch("sys.argv")
- def test_main_gj_mode(self, mock_argv, mock_json, mock_set_tag):
- with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as df:
- docx_path = df.name
- with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as cf:
- cfg_path = cf.name
- out_dir = tempfile.mkdtemp()
- try:
- mock_argv.__getitem__.side_effect = lambda i: [
- "wf", "gj", "-d", docx_path, "-c", cfg_path, "-o", out_dir
- ][i]
- mock_argv.__len__.return_value = 8
- main()
- mock_set_tag.assert_called_once()
- mock_json.dump.assert_called_once()
- finally:
- os.unlink(docx_path)
- os.unlink(cfg_path)
- shutil.rmtree(out_dir, ignore_errors=True)
-
- @mock.patch("wordformat.cli.auto_format_thesis_document")
- @mock.patch("sys.argv")
- def test_main_cf_mode(self, mock_argv, mock_auto):
- with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as df:
- docx_path = df.name
- with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as cf:
- cfg_path = cf.name
- with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as jf:
- json_path = jf.name
- 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
- ][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,
- )
- finally:
- for p in [docx_path, cfg_path, json_path]:
- os.unlink(p)
- os.rmdir(out_dir)
-
- @mock.patch("wordformat.cli.auto_format_thesis_document")
- @mock.patch("sys.argv")
- def test_main_af_mode(self, mock_argv, mock_auto):
- with tempfile.NamedTemporaryFile(suffix=".docx", delete=False) as df:
- docx_path = df.name
- with tempfile.NamedTemporaryFile(suffix=".yaml", delete=False) as cf:
- cfg_path = cf.name
- with tempfile.NamedTemporaryFile(suffix=".json", delete=False) as jf:
- json_path = jf.name
- 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
- ][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,
- )
- finally:
- for p in [docx_path, cfg_path, json_path]:
- os.unlink(p)
- os.rmdir(out_dir)
-
- @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.__len__.return_value = 4
- with mock.patch.dict("sys.modules", {"uvicorn": mock.MagicMock()}):
- with pytest.raises(SystemExit):
- main()
-
# ==================== (i) set_style.py auto_format_thesis_document 覆盖测试 ====================
@@ -614,10 +506,10 @@ def test_main_startapi_rejects_invalid_port(self, mock_argv):
class TestAutoFormatThesisDocument:
"""覆盖 set_style.py lines 53-55, 120-176: auto_format_thesis_document 主流程"""
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -625,17 +517,17 @@ def test_check_mode_returns_annotated_path(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
assert "--标注版.docx" in result
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -643,17 +535,17 @@ def test_apply_mode_returns_modified_path(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
assert "--修改版.docx" in result
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -666,7 +558,7 @@ def test_filters_body_text_nodes(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
@@ -674,11 +566,11 @@ def test_filters_body_text_nodes(
# body_text 不再被过滤,所有节点都保留
assert len(root_node.children) == 2
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
- @mock.patch("wordformat.set_style.promote_bodytext_in_subtrees_of_type")
+ @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()
@@ -686,17 +578,17 @@ def test_promote_called_for_subtrees(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
assert mock_promote.call_count == 3 # AbstractTitleCN, AbstractTitleEN, References
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -704,17 +596,17 @@ def test_exception_in_traverse_raises(
mock_builder.build_from_json.return_value = root_node
mock_apply.side_effect = RuntimeError("test error")
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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")
@@ -722,17 +614,17 @@ def test_config_load_failure_raises(
root_node.children = []
mock_builder.build_from_json.return_value = root_node
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -740,7 +632,7 @@ def test_apply_mode_lists_styles(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
@@ -748,6 +640,7 @@ def test_apply_mode_lists_styles(
# Should not raise - the styles listing is just logging
+
# ==================== (j) word_structure/utils.py promote_bodytext 覆盖测试 ====================
@@ -756,8 +649,6 @@ class TestPromoteBodyTextInSubtrees:
def test_promote_bodytext_to_target(self):
"""BodyText 子节点应被升级为目标类型 (lines 44-53)"""
- from wordformat.rules.body import BodyText
- from wordformat.rules.node import FormatNode
# Create a parent node of a specific type
class ParentType(FormatNode):
@@ -829,6 +720,7 @@ class OtherType(FormatNode):
assert isinstance(other.children[0], BodyText) # unchanged
+
# ==================== (k) log_config.py 覆盖测试 ====================
@@ -875,6 +767,7 @@ def test_setup_uvicorn_loguru(self):
assert mock_uvicorn_mod.config.LOGGING_CONFIG["version"] == 1
+
# ==================== (l) onnx_infer.py 额外覆盖测试 ====================
@@ -913,10 +806,11 @@ def test_load_model_fallback_to_cpu(self):
"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.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)
@@ -943,11 +837,11 @@ def test_load_model_cpu_core_num_zero_fallback(self):
"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"}')):
+ 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()
@@ -1066,6 +960,7 @@ def test_safe_batch_infer_empty_texts(self):
result = safe_batch_infer([])
+
# ==================== (m) keywords.py 覆盖测试 ====================
@@ -1074,7 +969,7 @@ class TestKeywordsBaseGetLangConfig:
def test_get_lang_config_unknown_lang_fallback(self, sample_yaml_config):
"""Unknown LANG falls back to chinese config (line 55)"""
- from wordformat.config.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
from wordformat.rules.keywords import BaseKeywordsNode
init_config(sample_yaml_config)
@@ -1092,6 +987,7 @@ class TestNode(BaseKeywordsNode):
assert node.pydantic_config is not None
+
class TestKeywordsENCheckKeywordLabel:
"""覆盖 keywords.py line 83: KeywordsEN._check_keyword_label matching"""
@@ -1129,6 +1025,7 @@ 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"""
@@ -1145,7 +1042,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1160,7 +1057,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1175,7 +1072,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1191,7 +1088,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1207,7 +1104,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1222,6 +1119,7 @@ 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"""
@@ -1252,7 +1150,7 @@ def test_config_none_raises(self):
def test_paragraph_style_check(self, sample_yaml_config):
"""Paragraph style is checked (line 187)"""
- from wordformat.config.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1266,7 +1164,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1280,7 +1178,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1296,7 +1194,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
@@ -1310,6 +1208,7 @@ def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config):
# Text ends with ; which should trigger trailing punctuation warning
+
# ==================== (n) heading.py 覆盖测试 ====================
@@ -1383,7 +1282,7 @@ def test_heading_load_config_invalid_type_raises(self):
def test_heading_base_with_config(self, sample_yaml_config):
"""_base method with loaded config"""
- from wordformat.config.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
from wordformat.rules.heading import HeadingLevel1Node
init_config(sample_yaml_config)
config = get_config()
@@ -1400,16 +1299,17 @@ 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.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -1417,17 +1317,17 @@ def test_exception_in_traverse_logs_and_raises(
mock_builder.build_from_json.return_value = root_node
mock_apply.side_effect = RuntimeError("traverse error")
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -1439,7 +1339,7 @@ def test_body_text_filtering(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
@@ -1447,11 +1347,11 @@ def test_body_text_filtering(
# body_text 不再被过滤
assert len(root_node.children) == 2
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
- @mock.patch("wordformat.set_style.promote_bodytext_in_subtrees_of_type")
+ @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()
@@ -1459,18 +1359,18 @@ def test_promote_called(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ 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,
)
assert mock_promote.call_count == 3
- @mock.patch("wordformat.set_style.apply_format_check_to_all_nodes")
- @mock.patch("wordformat.set_style.DocumentBuilder")
+ @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()
@@ -1478,9 +1378,9 @@ def test_numbering_processing(
mock_builder.build_from_json.return_value = root_node
mock_apply.return_value = None
- from wordformat.set_style import auto_format_thesis_document
+ from wordformat.pipeline.orchestrate import auto_format_thesis_document
# Mock config with numbering.enabled = True
- with mock.patch("wordformat.set_style.get_config") as mock_get_cfg:
+ with mock.patch("wordformat.pipeline.stages.get_config") as mock_get_cfg:
mock_cfg = mock.MagicMock()
mock_cfg.numbering.enabled = True
mock_get_cfg.return_value = mock_cfg
@@ -1491,6 +1391,7 @@ def test_numbering_processing(
mock_numbering.assert_called_once()
+
# ==================== (p) rules/body.py 额外覆盖测试 ====================
@@ -1499,7 +1400,7 @@ class TestBodyTextAdditional:
def test_body_text_apply_format(self, sample_yaml_config):
"""BodyText._base with p=False, r=False (line 27)"""
- from wordformat.config.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
from wordformat.rules.body import BodyText
init_config(sample_yaml_config)
config = get_config()
@@ -1518,7 +1419,7 @@ def test_body_text_apply_format(self, sample_yaml_config):
def test_body_text_apply_to_run(self, sample_yaml_config):
"""apply_format 通过 handler 修正字符格式。"""
- from wordformat.config.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
from wordformat.rules.body import BodyText
init_config(sample_yaml_config)
config = get_config()
@@ -1537,6 +1438,7 @@ 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 额外覆盖测试 ====================
@@ -1563,7 +1465,7 @@ def test_load_yaml_config_validation_error(self, tmp_path):
def test_load_config_unknown_type_raises(self):
"""没有 CONFIG_PATH 的节点,load_config 后 _pydantic_config 应为 None。"""
from wordformat.rules.node import FormatNode
- from wordformat.config.datamodel import BaseModel
+ from wordformat.config.models import BaseModel
class CustomConfig(BaseModel):
pass
@@ -1580,6 +1482,7 @@ class CustomNode(FormatNode[CustomConfig]):
assert node._pydantic_config is None
+
# ==================== (r) tree.py 额外覆盖测试 ====================
@@ -1604,6 +1507,7 @@ def test_print_tree_with_paragraph_value(self, capsys):
assert "test" in captured.out
+
# ==================== (s) settings.py 额外覆盖测试 ====================
@@ -1634,6 +1538,7 @@ def test_frozen_path(self):
importlib.reload(settings_mod)
+
# ==================== (t) config/config.py 额外覆盖测试 ====================
@@ -1664,221 +1569,6 @@ def test_lazy_config_config_path_property(self):
assert lc.config_path == "/some/path.yaml"
-# ==================== (u) api/__init__.py 覆盖测试 ====================
-
-
-class TestSaveUploadFile:
- """覆盖 api/__init__.py save_upload_file 函数"""
-
- 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"):
- from wordformat.api import save_upload_file, TEMP_DIR
- TEMP_DIR.mkdir(parents=True, exist_ok=True)
-
- mock_file = mock.MagicMock()
- mock_file.filename = "test.docx"
- mock_file.file.read.return_value = b"fake docx content"
-
- result = save_upload_file(mock_file, TEMP_DIR)
- assert os.path.exists(result)
- with open(result, "rb") as f:
- assert f.read() == b"fake docx content"
-
- 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"):
- from wordformat.api import save_upload_file, TEMP_DIR
- TEMP_DIR.mkdir(parents=True, exist_ok=True)
-
- # 预先创建同名文件
- existing_file = TEMP_DIR / "test.docx"
- existing_file.write_bytes(b"existing content")
-
- mock_file = mock.MagicMock()
- mock_file.filename = "test.docx"
- mock_file.file.read.return_value = b"new content"
-
- result = save_upload_file(mock_file, TEMP_DIR)
- assert os.path.exists(result)
- assert result.endswith("test_1.docx")
- with open(result, "rb") as f:
- assert f.read() == b"new content"
- # 原文件未被覆盖
- assert existing_file.read_bytes() == b"existing content"
-
- 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"):
- from wordformat.api import save_upload_file, TEMP_DIR
- TEMP_DIR.mkdir(parents=True, exist_ok=True)
-
- (TEMP_DIR / "test.docx").write_bytes(b"a")
- (TEMP_DIR / "test_1.docx").write_bytes(b"b")
- (TEMP_DIR / "test_2.docx").write_bytes(b"c")
-
- mock_file = mock.MagicMock()
- mock_file.filename = "test.docx"
- mock_file.file.read.return_value = b"d"
-
- result = save_upload_file(mock_file, TEMP_DIR)
- assert result.endswith("test_3.docx")
-
-
-class TestAPIEndpoints:
- """覆盖 api/__init__.py 所有 API 端点"""
-
- @pytest.fixture
- def api_client(self, tmp_path):
- """创建 TestClient,mock BASE_DIR 使目录创建在 tmp_path"""
- temp_dir = tmp_path / "temp"
- output_dir = tmp_path / "output"
- 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):
- from wordformat.api import app
- client = TestClient(app)
- yield client, temp_dir, output_dir
-
- 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"}]
- 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")
-
- response = client.post(
- "/generate-json",
- files={
- "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
- },
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["code"] == 200
- assert "json_data" in data["data"]
-
- def test_generate_json_non_docx_returns_400(self, api_client):
- """POST /generate-json 上传非 docx 文件返回 code 400"""
- client, temp_dir, output_dir = api_client
-
- docx_bytes = io.BytesIO(b"fake content")
- yaml_bytes = io.BytesIO(b"key: value")
-
- response = client.post(
- "/generate-json",
- files={
- "docx_file": ("test.pdf", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
- },
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["code"] == 400
- assert ".docx" in data["msg"]
-
- def test_check_format_success(self, api_client):
- """POST /check-format 成功调用 auto_format_thesis_document(check=True)"""
- 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):
- docx_bytes = io.BytesIO(b"fake docx")
- yaml_bytes = io.BytesIO(b"key: value")
- json_str = '[{"category": "body_text", "score": 0.9}]'
-
- response = client.post(
- "/check-format",
- files={
- "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
- },
- data={"json_data": json_str},
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["code"] == 200
- assert "标注版" in data["data"]["final_filename"]
- assert "download_url" in data["data"]
-
- def test_apply_format_success(self, api_client):
- """POST /apply-format 成功调用 auto_format_thesis_document(check=False)"""
- 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):
- docx_bytes = io.BytesIO(b"fake docx")
- yaml_bytes = io.BytesIO(b"key: value")
- json_str = '[{"category": "body_text", "score": 0.9}]'
-
- response = client.post(
- "/apply-format",
- files={
- "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
- },
- data={"json_data": json_str},
- )
-
- assert response.status_code == 200
- data = response.json()
- assert data["code"] == 200
- assert "修改版" in data["data"]["final_filename"]
- assert "download_url" in data["data"]
-
- def test_download_file_exists(self, api_client):
- """GET /download/{filename} 文件存在时返回文件"""
- client, temp_dir, output_dir = api_client
-
- # 在 output_dir 创建一个测试文件
- test_file = output_dir / "result.docx"
- test_file.write_bytes(b"docx content here")
-
- response = client.get(f"/download/result.docx")
- assert response.status_code == 200
- assert response.content == b"docx content here"
-
- def test_download_file_not_found(self, api_client):
- """GET /download/{filename} 文件不存在时返回 404"""
- client, temp_dir, output_dir = api_client
-
- response = client.get("/download/nonexistent.docx")
- # 已修复:重新抛出 HTTPException,正确返回 404
- assert response.status_code == 404
-
- 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")):
- docx_bytes = io.BytesIO(b"fake docx")
- yaml_bytes = io.BytesIO(b"key: value")
-
- response = client.post(
- "/generate-json",
- files={
- "docx_file": ("test.docx", docx_bytes, "application/octet-stream"),
- "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"),
- },
- )
-
- assert response.status_code == 500
-
# ==================== 补充覆盖率:小缺口 ====================
@@ -1906,12 +1596,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.config import init_config, get_config
+ from wordformat.config.loader import init_config, get_config
init_config(sample_yaml_config)
config = get_config()
builder = DocumentTreeBuilder()
@@ -1936,22 +1627,6 @@ def test_build_tree_with_body_text_category(self):
assert len(root.children) == 1
-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()}):
- with um.patch("wordformat.api.app", um.MagicMock()):
- main()
- # 验证 uvicorn.run 被调用
- import sys
- if "uvicorn" in sys.modules:
- sys.modules["uvicorn"].run.assert_called_once()
-
class TestNumberingAdditionalCoverage:
"""覆盖 numbering.py 剩余行 (49, 262)"""
@@ -1968,8 +1643,9 @@ def test_auto_strip_numbering_empty_run_text(self, doc):
def test_process_heading_numbering_disabled(self, doc):
"""numbering config enabled=False,直接返回"""
from wordformat.numbering import process_heading_numbering
- from wordformat.config.datamodel import NumberingConfig
+ from wordformat.config.models import NumberingConfig
# 创建 disabled 的 numbering config
config = NumberingConfig(enabled=False)
process_heading_numbering(None, doc, config)
+
diff --git a/tests/test_core.py b/tests/test_numbering.py
similarity index 66%
rename from tests/test_core.py
rename to tests/test_numbering.py
index 5769637..5071de2 100644
--- a/tests/test_core.py
+++ b/tests/test_numbering.py
@@ -33,7 +33,7 @@
_get_level_fmt,
_count_numbering_levels,
)
-from wordformat.style.get_some import _get_style_spacing
+from wordformat.style.reader import _get_style_spacing
from wordformat.base import DocxBase
from wordformat import settings
@@ -43,530 +43,6 @@
# ============================================================
-class TestTreeCreation:
- """Tree 创建与基本属性"""
-
- def test_create_tree_with_root_value(self):
- tree = Tree("root")
- assert tree.root.value == "root"
-
- def test_create_tree_with_dict_value(self):
- tree = Tree({"category": "top"})
- assert tree.root.value == {"category": "top"}
-
- def test_tree_repr(self):
- tree = Tree("my_root")
- assert repr(tree) == "Tree(root=my_root)"
-
- def test_is_empty(self):
- """空树(仅 root 无子节点)返回 True,有子节点返回 False"""
- tree = Tree("root")
- assert tree.is_empty() is True
- tree.root.add_child("child")
- assert tree.is_empty() is False
-
-
-class TestTreeTraversals:
- """三种遍历:前序、后序、层序"""
-
- def setup_method(self):
- tree = Tree("A")
- b = tree.root.add_child("B")
- c = tree.root.add_child("C")
- b.add_child("D")
- b.add_child("E")
- c.add_child("F")
- self.tree = tree
-
- def test_preorder(self):
- assert list(self.tree.preorder()) == ["A", "B", "D", "E", "C", "F"]
-
- def test_postorder(self):
- assert list(self.tree.postorder()) == ["D", "E", "B", "F", "C", "A"]
-
- def test_level_order(self):
- assert list(self.tree.level_order()) == ["A", "B", "C", "D", "E", "F"]
-
- def test_preorder_single_node(self):
- tree = Tree("only")
- assert list(tree.preorder()) == ["only"]
-
- def test_postorder_single_node(self):
- tree = Tree("only")
- assert list(tree.postorder()) == ["only"]
-
- def test_level_order_single_node(self):
- tree = Tree("only")
- assert list(tree.level_order()) == ["only"]
-
-
-class TestTreeFindAndMetrics:
- """find_by_condition, height, size"""
-
- def setup_method(self):
- tree = Tree("A")
- b = tree.root.add_child("B")
- c = tree.root.add_child("C")
- b.add_child("D")
- b.add_child("E")
- c.add_child("F")
- self.tree = tree
-
- def test_find_by_condition_exists(self):
- node = self.tree.find_by_condition(lambda v: v == "E")
- assert node is not None
- assert node.value == "E"
-
- def test_find_by_condition_not_exists(self):
- node = self.tree.find_by_condition(lambda v: v == "Z")
- assert node is None
-
- def test_find_by_condition_first_match(self):
- node = self.tree.find_by_condition(lambda v: isinstance(v, str) and len(v) == 1)
- assert node.value == "A" # DFS 先遇到根
-
- def test_height_single_node(self):
- assert Tree("x").height() == 0
-
- def test_height_deep_tree(self):
- tree = Tree("1")
- tree.root.add_child("2").add_child("3").add_child("4")
- assert tree.height() == 3
-
- def test_height_balanced_tree(self):
- assert self.tree.height() == 2
-
- def test_size_single_node(self):
- assert Tree("x").size() == 1
-
- def test_size(self):
- assert self.tree.size() == 6
-
-
-# ============================================================
-# tree.py — Stack
-# ============================================================
-
-
-class TestStack:
- """Stack 的全部操作"""
-
- def test_push_and_pop(self):
- s = Stack()
- s.push(10)
- s.push(20)
- assert s.pop() == 20
- assert s.pop() == 10
-
- def test_peek(self):
- s = Stack()
- s.push("hello")
- assert s.peek() == "hello"
- assert s.size() == 1 # peek 不弹出
-
- def test_peek_safe_on_empty(self):
- s = Stack()
- assert s.peek_safe() is None
-
- def test_peek_safe_returns_top(self):
- s = Stack()
- s.push(42)
- assert s.peek_safe() == 42
-
- def test_is_empty(self):
- s = Stack()
- assert s.is_empty() is True
- s.push(1)
- assert s.is_empty() is False
-
- def test_size(self):
- s = Stack()
- assert s.size() == 0
- s.push("a")
- s.push("b")
- assert s.size() == 2
-
- def test_clear(self):
- s = Stack()
- s.push(1)
- s.push(2)
- s.clear()
- assert s.is_empty() is True
- assert s.size() == 0
-
- def test_pop_empty_raises(self):
- s = Stack()
- with pytest.raises(IndexError, match="pop from empty stack"):
- s.pop()
-
- def test_peek_empty_raises(self):
- s = Stack()
- with pytest.raises(IndexError, match="peek from empty stack"):
- s.peek()
-
- def test_bool_truthy(self):
- s = Stack()
- assert bool(s) is False
- s.push(1)
- assert bool(s) is True
-
- def test_repr(self):
- s = Stack()
- s.push(1)
- s.push(2)
- assert "1" in repr(s)
- assert "2" in repr(s)
-
-
-# ============================================================
-# tree.py — print_tree
-# ============================================================
-
-
-class TestPrintTree:
- """print_tree 输出捕获"""
-
- def test_print_single_node(self):
- node = TreeNode("hello")
- buf = StringIO()
- with patch("sys.stdout", buf):
- print_tree(node)
- output = buf.getvalue()
- assert "hello" in output
- assert "└──" in output
-
- def test_print_dict_value_node(self):
- node = TreeNode({
- "category": "body_text",
- "paragraph": "这是一段正文内容",
- "fingerprint": "fp001",
- })
- buf = StringIO()
- with patch("sys.stdout", buf):
- print_tree(node)
- output = buf.getvalue()
- assert "body_text" in output
-
- def test_print_tree_with_children(self):
- root = TreeNode("root")
- root.add_child("child1")
- root.add_child("child2")
- buf = StringIO()
- with patch("sys.stdout", buf):
- print_tree(root)
- output = buf.getvalue()
- assert "root" in output
- assert "child1" in output
- assert "child2" in output
-
-
-
-
-# ============================================================
-# utils.py — get_file_name
-# ============================================================
-
-
-class TestGetFileName:
- @pytest.mark.parametrize(
- "path, expected",
- [
- ("/home/user/doc.docx", "doc"),
- ("simple.txt", "simple"),
- ("/path/to/my.file.name.pdf", "my.file.name"),
- ],
- )
- def test_extracts_name(self, path, expected):
- assert get_file_name(path) == expected
-
-
-# ============================================================
-# utils.py — ensure_is_directory
-# ============================================================
-
-
-class TestEnsureIsDirectory:
- def test_valid_directory(self, tmp_path):
- # tmp_path 本身就是目录
- ensure_is_directory(str(tmp_path))
-
- def test_nonexistent_path_raises(self):
- with pytest.raises(ValueError, match="路径不存在"):
- ensure_is_directory("/nonexistent/path/that/does/not/exist")
-
- def test_file_instead_of_directory_raises(self, tmp_path):
- file_path = tmp_path / "afile.txt"
- file_path.write_text("hello")
- with pytest.raises(ValueError, match="不是一个文件夹"):
- ensure_is_directory(str(file_path))
-
-
-# ============================================================
-# utils.py — ensure_directory_exists
-# ============================================================
-
-
-class TestEnsureDirectoryExists:
- def test_create_new_directory(self, tmp_path):
- new_dir = str(tmp_path / "sub" / "dir")
- ensure_directory_exists(new_dir)
- assert os.path.isdir(new_dir)
-
- def test_existing_directory_no_error(self, tmp_path):
- ensure_directory_exists(str(tmp_path))
-
- def test_existing_file_raises(self, tmp_path):
- file_path = tmp_path / "blocked.txt"
- file_path.write_text("data")
- with pytest.raises(ValueError, match="不是文件夹"):
- ensure_directory_exists(str(file_path))
-
-
-# ============================================================
-# utils.py — _to_roman
-# ============================================================
-
-
-class TestToRoman:
- @pytest.mark.parametrize(
- "num, expected",
- [
- (1, "i"),
- (4, "iv"),
- (5, "v"),
- (9, "ix"),
- (10, "x"),
- (40, "xl"),
- (50, "l"),
- (90, "xc"),
- (100, "c"),
- (400, "cd"),
- (500, "d"),
- (900, "cm"),
- (1000, "m"),
- (1984, "mcmlxxxiv"),
- (3999, "mmmcmxcix"),
- ],
- )
- def test_valid_numbers(self, num, expected):
- assert _to_roman(num) == expected
-
- def test_zero_returns_zero_string(self):
- """_to_roman(0) 返回 "0" 而非空字符串"""
- assert _to_roman(0) == "0"
-
- def test_negative_returns_zero_string(self):
- """_to_roman(-5) 返回 "0" 而非空字符串"""
- assert _to_roman(-5) == "0"
-
-
-# ============================================================
-# utils.py — _to_chinese_num
-# ============================================================
-
-
-class TestToChineseNum:
- @pytest.mark.parametrize(
- "num, expected",
- [
- (1, "一"),
- (5, "五"),
- (9, "九"),
- (10, "十"),
- (11, "十一"),
- (20, "二十"),
- (21, "二十一"),
- (99, "九十九"),
- ],
- )
- def test_valid_numbers(self, num, expected):
- assert _to_chinese_num(num) == expected
-
- def test_zero(self):
- assert _to_chinese_num(0) == "0"
-
- def test_negative(self):
- assert _to_chinese_num(-3) == "-3"
-
- def test_hundred_should_convert(self):
- assert _to_chinese_num(100) == "一百"
-
- def test_hundred_fallback(self):
- # _to_chinese_num(100) 返回 "一百"
- assert _to_chinese_num(100) == "一百"
-
-
-# ============================================================
-# utils.py — load_yaml_with_merge
-# ============================================================
-
-
-class TestLoadYamlWithMerge:
- def test_load_valid_yaml(self, tmp_path):
- yaml_file = tmp_path / "config.yaml"
- yaml_file.write_text("key: value\nnested:\n a: 1\n", encoding="utf-8")
- result = load_yaml_with_merge(str(yaml_file))
- assert result["key"] == "value"
- assert result["nested"]["a"] == 1
-
- def test_nonexistent_file_raises(self):
- with pytest.raises(FileNotFoundError):
- load_yaml_with_merge("/nonexistent/file.yaml")
-
-
-# ============================================================
-# rules/node.py — TreeNode
-# ============================================================
-
-
-class TestTreeNode:
- def test_init_with_simple_value(self):
- node = TreeNode("hello")
- assert node.value == "hello"
- assert node.children == []
-
- def test_init_fingerprint_is_none(self):
- """fingerprint 已废弃,始终为 None。"""
- node = TreeNode({"category": "body_text"})
- assert node.fingerprint is None
-
- node2 = TreeNode({"category": "body_text", "fingerprint": "abc123"})
- assert node2.fingerprint is None
-
- def test_config_default_empty(self):
- node = TreeNode("x")
- assert node.config == {}
-
- def test_load_config_nested_path(self):
- node = TreeNode("x")
- node.NODE_TYPE = "a.b.c"
- full = {"a": {"b": {"c": {"key": "val"}}}}
- node.load_config(full)
- assert node.config == {"key": "val"}
-
- def test_load_config_missing_path(self):
- node = TreeNode("x")
- node.NODE_TYPE = "x.y.z"
- node.load_config({"a": 1})
- assert node.config == {}
-
- def test_load_config_non_dict_input(self):
- node = TreeNode("x")
- node.load_config("not a dict")
- assert node.config == {}
-
- def test_add_child_returns_child(self):
- node = TreeNode("parent")
- child = node.add_child("child_val")
- assert child.value == "child_val"
- assert len(node.children) == 1
-
- def test_add_child_node(self):
- parent = TreeNode("parent")
- child = TreeNode("child")
- parent.add_child_node(child)
- assert parent.children[0] is child
-
- def test_repr(self):
- node = TreeNode("test_val")
- assert repr(node) == "TreeNode(test_val)"
-
- def test_fingerprint_attribute_is_none_for_non_dict(self):
- node = TreeNode("simple_string")
- assert node.fingerprint is None
-
-
-# ============================================================
-# rules/node.py — FormatNode
-# ============================================================
-
-
-class TestFormatNode:
- def test_init_defaults(self):
- node = FormatNode(value="test", level=1)
- assert node.level == 1
- assert node.paragraph is None
- assert node.expected_rule is None
- assert node._pydantic_config is None
-
- def test_pydantic_config_raises_before_load(self):
- node = FormatNode(value="test", level=1)
- with pytest.raises(ValueError, match="尚未加载Pydantic配置"):
- _ = node.pydantic_config
-
- def test_load_yaml_config_file_not_found(self):
- with pytest.raises(FileNotFoundError, match="配置文件"):
- FormatNode.load_yaml_config("/nonexistent/config.yaml")
-
- def test_load_yaml_config_invalid_yaml(self, tmp_path):
- bad_yaml = tmp_path / "bad.yaml"
- bad_yaml.write_text(":\n - [invalid", encoding="utf-8")
- with pytest.raises((ValueError, RuntimeError)):
- FormatNode.load_yaml_config(str(bad_yaml))
-
- def test_update_paragraph(self, doc):
- node = FormatNode(value="test", level=1)
- p = doc.add_paragraph("hello")
- node.update_paragraph(p)
- assert node.paragraph is p
-
- def test_base_is_noop(self, doc):
- """_base() 默认为空操作。"""
- node = FormatNode(value="test", level=1)
- node._base(doc, p=True, r=True)
- node._base(doc, p=False, r=False)
-
- def test_check_format_raises(self, doc):
- """未加载配置时 check_format 通过 handler 触发 ValueError。"""
- p = doc.add_paragraph("test")
- node = FormatNode(value="test", level=1, paragraph=p)
- with pytest.raises(ValueError, match="尚未加载"):
- node.check_format(doc)
-
- def test_apply_format_raises(self, doc):
- """未加载配置时 apply_format 通过 handler 触发 ValueError。"""
- p = doc.add_paragraph("test")
- node = FormatNode(value="test", level=1, paragraph=p)
- with pytest.raises(ValueError, match="尚未加载"):
- node.apply_format(doc)
-
- def test_add_comment_buffers(self, doc):
- """add_comment 缓冲文本,_flush_comments 合并写入。"""
- node = FormatNode(value="test", level=1)
- p = doc.add_paragraph("hello")
- node.paragraph = p
- node.add_comment(doc, p.runs[0], "格式错误")
- node.add_comment(doc, p.runs[0], "字体问题")
- with patch.object(doc, "add_comment") as mock_add:
- node._flush_comments(doc)
- mock_add.assert_called_once()
- merged = mock_add.call_args[1]["text"]
- assert "格式错误" in merged
- assert "字体问题" in merged
- assert merged.count("\n") == 1
-
- def test_add_comment_empty_text_skipped(self, doc):
- node = FormatNode(value="test", level=1)
- p = doc.add_paragraph("hello")
- run = p.runs[0]
- # 空文本不应调用 add_comment
- node.add_comment(doc, run, " ")
-
- def test_load_config_heading_level_bug(self):
- """Heading 节点没有 CONFIG_PATH,由 BaseHeadingNode 自定义 load_config 处理。
- 通过 FormatNode 基类 load_config 加载时 _pydantic_config 应为 None。"""
- from wordformat.config.datamodel import HeadingLevelConfig, NodeConfigRoot
-
- class TestFormatNode(FormatNode[HeadingLevelConfig]):
- NODE_TYPE = "headings.level_1"
- CONFIG_MODEL = HeadingLevelConfig
-
- node = TestFormatNode(value="test", level=1)
- root_config = NodeConfigRoot()
- node.load_config(root_config)
- assert node._pydantic_config is None
-
-
# ============================================================
# numbering.py — auto_strip_numbering
# ============================================================
@@ -714,30 +190,6 @@ def test_disabled_config_noop(self, doc):
# ============================================================
-# settings.py
-# ============================================================
-
-
-class TestSettings:
- def test_batch_size_is_int(self):
- assert isinstance(settings.BATCH_SIZE, int)
-
- def test_voidnodelist_contains_top(self):
- assert "top" in settings.VOIDNODELIST
-
- def test_voidnodelist_is_list(self):
- assert isinstance(settings.VOIDNODELIST, list)
-
- def test_host_is_string(self):
- assert isinstance(settings.HOST, str)
-
- def test_port_is_int(self):
- assert isinstance(settings.PORT, int)
-
- def test_server_host_format(self):
- assert settings.SERVER_HOST.startswith("http://")
- assert str(settings.PORT) in settings.SERVER_HOST
-
# ============================================================
# utils.py — get_paragraph_numbering_text
@@ -1170,151 +622,6 @@ def test_style_not_in_doc_no_error(self, doc):
remove_all_numbering(doc)
-# ============================================================
-# style/get_some.py — _get_style_spacing
-# ============================================================
-
-
-class TestGetStyleSpacing:
- """测试 _get_style_spacing 递归查找样式间距"""
-
- def test_none_style_returns_none(self):
- assert _get_style_spacing(None) is None
-
- def test_style_with_direct_spacing(self):
- """样式自身有 beforeLines 时直接返回"""
- mock_style = MagicMock()
- mock_elem = MagicMock()
- mock_pPr = MagicMock()
- mock_spacing = MagicMock()
-
- mock_style.element = mock_elem
- mock_elem.find.return_value = mock_pPr
- mock_pPr.find.return_value = mock_spacing
- mock_spacing.get.return_value = "200" # 2.0 行
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 2.0
-
- def test_style_with_zero_spacing_falls_to_base(self):
- """样式自身 spacing 为 0 时递归查基样式"""
- mock_style = MagicMock()
- mock_base = MagicMock()
- mock_elem = MagicMock()
- mock_pPr = MagicMock()
- mock_spacing = MagicMock()
- mock_base_elem = MagicMock()
- mock_base_pPr = MagicMock()
- mock_base_spacing = MagicMock()
-
- mock_style.element = mock_elem
- mock_style.base_style = mock_base
- mock_elem.find.return_value = mock_pPr
- mock_pPr.find.return_value = mock_spacing
- mock_spacing.get.return_value = "0" # 0 行
-
- mock_base.element = mock_base_elem
- mock_base_elem.find.return_value = mock_base_pPr
- mock_base_pPr.find.return_value = mock_base_spacing
- mock_base_spacing.get.return_value = "150" # 1.5 行
-
- result = _get_style_spacing(mock_style, "before")
- # 修复:显式 0 应被尊重,不回退到基样式
- assert result == 0.0
-
- def test_no_pPr_falls_to_base(self):
- """样式没有 pPr 时递归查基样式"""
- mock_style = MagicMock()
- mock_base = MagicMock()
- mock_elem = MagicMock()
- mock_base_elem = MagicMock()
- mock_base_pPr = MagicMock()
- mock_base_spacing = MagicMock()
-
- mock_style.element = mock_elem
- mock_style.base_style = mock_base
- mock_elem.find.return_value = None # pPr 为 None
-
- mock_base.element = mock_base_elem
- mock_base_elem.find.return_value = mock_base_pPr
- mock_base_pPr.find.return_value = mock_base_spacing
- mock_base_spacing.get.return_value = "100" # 1.0 行
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 1.0
-
- def test_no_spacing_falls_to_base(self):
- """样式有 pPr 但没有 spacing 时递归查基样式"""
- mock_style = MagicMock()
- mock_base = MagicMock()
- mock_elem = MagicMock()
- mock_pPr = MagicMock()
- mock_base_elem = MagicMock()
- mock_base_pPr = MagicMock()
- mock_base_spacing = MagicMock()
-
- mock_style.element = mock_elem
- mock_style.base_style = mock_base
- mock_elem.find.return_value = mock_pPr
- mock_pPr.find.return_value = None # spacing 为 None
-
- mock_base.element = mock_base_elem
- mock_base_elem.find.return_value = mock_base_pPr
- mock_base_pPr.find.return_value = mock_base_spacing
- mock_base_spacing.get.return_value = "300" # 3.0 行
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 3.0
-
- def test_after_spacing_type(self):
- """测试 after 类型的间距查找"""
- mock_style = MagicMock()
- mock_elem = MagicMock()
- mock_pPr = MagicMock()
- mock_spacing = MagicMock()
-
- mock_style.element = mock_elem
- mock_elem.find.return_value = mock_pPr
- mock_pPr.find.return_value = mock_spacing
- mock_spacing.get.return_value = "500" # 5.0 行
-
- result = _get_style_spacing(mock_style, "after")
- assert result == 5.0
-
- def test_no_base_style_returns_none(self):
- """没有基样式且自身无 spacing 时返回 None"""
- mock_style = MagicMock()
- mock_elem = MagicMock()
- mock_pPr = MagicMock()
-
- mock_style.element = mock_elem
- mock_style.base_style = None
- mock_elem.find.return_value = mock_pPr
- mock_pPr.find.return_value = None
-
- # base_style 为 None 时 AttributeError 会被捕获
- result = _get_style_spacing(mock_style, "before")
- assert result is None
-
- def test_style_element_none_falls_to_base(self):
- """style.element 为 None 时递归查基样式"""
- mock_style = MagicMock()
- mock_base = MagicMock()
- mock_base_elem = MagicMock()
- mock_base_pPr = MagicMock()
- mock_base_spacing = MagicMock()
-
- mock_style.element = None
- mock_style.base_style = mock_base
-
- mock_base.element = mock_base_elem
- mock_base_elem.find.return_value = mock_base_pPr
- mock_base_pPr.find.return_value = mock_base_spacing
- mock_base_spacing.get.return_value = "100"
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 1.0
-
# ============================================================
# numbering.py — auto_strip_numbering (empty result)
@@ -1736,7 +1043,7 @@ def test_strip_across_runs(self, doc):
class TestCreateReferenceNumberingDefinition:
def test_references_enabled_creates_definition(self, doc):
"""参考文献编号启用时创建独立 abstractNum 和 num"""
- from wordformat.config.datamodel import NumberingConfig, NumberingLevelConfig
+ from wordformat.config.models import NumberingConfig, NumberingLevelConfig
config = NumberingConfig(
enabled=True,
@@ -1761,7 +1068,7 @@ def test_references_enabled_creates_definition(self, doc):
def test_references_disabled_no_definition(self, doc):
"""参考文献编号禁用时不创建定义"""
- from wordformat.config.datamodel import NumberingConfig, NumberingLevelConfig
+ from wordformat.config.models import NumberingConfig, NumberingLevelConfig
config = NumberingConfig(
enabled=True,
@@ -1785,7 +1092,7 @@ class TestProcessReferenceNumbering:
def test_reference_entry_gets_numbering(self, doc):
"""参考文献条目节点应被应用自动编号"""
from wordformat.rules.references import ReferenceEntry
- from wordformat.config.datamodel import NumberingConfig, NumberingLevelConfig
+ from wordformat.config.models import NumberingConfig, NumberingLevelConfig
p = doc.add_paragraph("Some reference text")
node = ReferenceEntry(
@@ -1816,7 +1123,7 @@ def test_reference_entry_gets_numbering(self, doc):
def test_reference_entry_strips_manual_then_applies_numbering(self, doc):
"""参考文献条目:先清除手动编号再应用自动编号"""
from wordformat.rules.references import ReferenceEntry
- from wordformat.config.datamodel import NumberingConfig, NumberingLevelConfig
+ from wordformat.config.models import NumberingConfig, NumberingLevelConfig
p = doc.add_paragraph("[1] Some reference text")
node = ReferenceEntry(
@@ -1966,184 +1273,6 @@ def test_run_retains_superscript_in_hyperlink(self, doc):
assert rStyle.get(qn("w:val")) == "Hyperlink"
-# ============================================================
-# base.py — DocxBase
-# ============================================================
-
-
-class TestDocxBase:
- """测试 DocxBase 类的初始化和 parse 方法"""
-
- def test_init(self, temp_docx):
- """测试 DocxBase 初始化"""
- base = DocxBase(temp_docx, "/fake/config.yaml")
- assert base.docx_file == temp_docx
- assert base.document is not None
- assert base.re_dict == {}
-
- def _create_multi_para_docx(self, tmp_path, texts):
- """辅助方法:创建包含多个段落的 docx 文件"""
- doc = Document()
- for text in texts:
- doc.add_paragraph(text)
- path = str(tmp_path / "multi.docx")
- doc.save(path)
- return path
-
- def test_parse_with_mocked_batch_infer(self, tmp_path):
- """测试 parse 方法使用 mock 的批量推理"""
- path = self._create_multi_para_docx(tmp_path, ["绪论", "研究背景", "正文内容"])
- mock_batch_results = [
- {"label": "heading_level_1", "score": 0.95},
- {"label": "body_text", "score": 0.88},
- {"label": "body_text", "score": 0.75},
- ]
-
- with patch("wordformat.base.onnx_batch_infer", return_value=mock_batch_results):
- base = DocxBase(path, "/fake/config.yaml")
- result = base.parse()
-
- assert len(result) == 3
- assert result[0]["category"] == "heading_level_1"
- assert result[1]["category"] == "body_text"
- assert "paragraph" in result[0]
- assert "score" in result[0]
-
- def test_parse_low_score_forced_to_body_text(self, tmp_path):
- """测试低置信度结果被强制设为 body_text"""
- path = self._create_multi_para_docx(tmp_path, ["绪论", "研究背景"])
- mock_batch_results = [
- {"label": "heading_level_1", "score": 0.95},
- {"label": "heading_level_2", "score": 0.3},
- ]
-
- with patch("wordformat.base.onnx_batch_infer", return_value=mock_batch_results):
- base = DocxBase(path, "/fake/config.yaml")
- result = base.parse()
-
- assert result[0]["category"] == "heading_level_1"
- 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):
- base = DocxBase(temp_docx, "/fake/config.yaml")
- result = base.parse()
-
- # temp_docx 只有一个段落
- assert len(result) >= 1
- assert all(item["category"] == "body_text" for item in result)
-
- def test_parse_empty_document(self, tmp_path):
- """测试空文档的解析"""
- doc = Document()
- path = str(tmp_path / "empty.docx")
- doc.save(path)
-
- with patch("wordformat.base.onnx_batch_infer", return_value=[]) as mock_infer:
- base = DocxBase(path, "/fake/config.yaml")
- result = base.parse()
-
- assert result == []
- mock_infer.assert_not_called()
-
- def test_parse_batches_correctly(self, tmp_path):
- """测试按 BATCH_SIZE 分批推理"""
- path = self._create_multi_para_docx(tmp_path, [f"段落 {i}" for i in range(5)])
-
- call_count = 0
-
- def mock_batch(texts):
- nonlocal call_count
- call_count += 1
- return [{"label": "body_text", "score": 0.9}] * len(texts)
-
- with patch("wordformat.base.onnx_batch_infer", side_effect=mock_batch):
- with patch("wordformat.base.BATCH_SIZE", 2):
- base = DocxBase(path, "/fake/config.yaml")
- result = base.parse()
-
- assert len(result) == 5
- # BATCH_SIZE=2, 5 个段落 → 3 次调用 (2+2+1)
- assert call_count == 3
-
- def test_parse_includes_numbering_text(self, tmp_path):
- """测试解析时包含自动编号文字"""
- from docx.oxml import OxmlElement
- from docx.opc.constants import RELATIONSHIP_TYPE as RT
- from docx.opc.packuri import PackURI
- from docx.parts.numbering import NumberingPart
-
- doc = Document()
-
- # 先移除已有的 numbering 关系
- rels = doc.part.rels
- to_remove = [k for k, v in rels.items() if v.reltype == RT.NUMBERING]
- for k in to_remove:
- del rels[k]
-
- p = doc.add_paragraph("绪论")
-
- # 添加 numbering
- numbering_elm = OxmlElement("w:numbering")
- abstract_num = OxmlElement("w:abstractNum")
- abstract_num.set(qn("w:abstractNumId"), "0")
- lvl = OxmlElement("w:lvl")
- lvl.set(qn("w:ilvl"), "0")
- start = OxmlElement("w:start")
- start.set(qn("w:val"), "1")
- lvl.append(start)
- numFmt = OxmlElement("w:numFmt")
- numFmt.set(qn("w:val"), "decimal")
- lvl.append(numFmt)
- lvlText = OxmlElement("w:lvlText")
- lvlText.set(qn("w:val"), "%1.")
- lvl.append(lvlText)
- abstract_num.append(lvl)
- numbering_elm.append(abstract_num)
-
- num = OxmlElement("w:num")
- num.set(qn("w:numId"), "1")
- abstract_num_id_ref = OxmlElement("w:abstractNumId")
- abstract_num_id_ref.set(qn("w:val"), "0")
- num.append(abstract_num_id_ref)
- numbering_elm.append(num)
-
- numbering_part = NumberingPart(
- PackURI("/word/numbering.xml"),
- "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
- numbering_elm,
- doc.part.package,
- )
- doc.part.relate_to(numbering_part, RT.NUMBERING)
-
- pPr = OxmlElement("w:pPr")
- numPr = OxmlElement("w:numPr")
- numId_elem = OxmlElement("w:numId")
- numId_elem.set(qn("w:val"), "1")
- numPr.append(numId_elem)
- ilvl_elem = OxmlElement("w:ilvl")
- ilvl_elem.set(qn("w:val"), "0")
- numPr.append(ilvl_elem)
- pPr.append(numPr)
- p._element.insert(0, pPr)
-
- 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}]):
- base = DocxBase(path, "/fake/config.yaml")
- result = base.parse()
-
- assert len(result) == 1
- # 段落文本应包含编号 "1. 绪论"
- assert result[0]["paragraph"] == "1. 绪论"
-
# ============================================================
# utils.py — _format_number 额外覆盖测试
@@ -2424,3 +1553,4 @@ 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_rules.py b/tests/test_rules.py
index 1c72174..1517275 100644
--- a/tests/test_rules.py
+++ b/tests/test_rules.py
@@ -1,7 +1,6 @@
"""
rules 模块测试 —— 聚焦真实行为验证,无填充。
"""
-import re
from unittest.mock import patch
import pytest
@@ -9,7 +8,7 @@
from docx.oxml.ns import qn
from docx.shared import Pt
-from wordformat.config.datamodel import NodeConfigRoot
+from wordformat.config.models import NodeConfigRoot
from wordformat.rules import (
AbstractContentCN,
AbstractContentEN,
@@ -31,7 +30,6 @@
ReferenceEntry,
References,
)
-from wordformat.rules.keywords import BaseKeywordsNode
from wordformat.rules.node import FormatNode as FormatNodeBase
# ---------------------------------------------------------------------------
@@ -60,7 +58,7 @@ def _load_yaml(path):
@pytest.fixture
def root_config(sample_yaml_config):
"""从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。"""
- from wordformat.config.config import init_config
+ from wordformat.config.loader import init_config
init_config(sample_yaml_config)
return _load_root_config(sample_yaml_config)
@@ -101,7 +99,7 @@ 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"):
+ patch.object(node, "_run_rules"):
node.check_format(doc)
mock_base.assert_called_once_with(doc, p=True, r=True)
@@ -109,7 +107,7 @@ 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"):
+ patch.object(node, "_run_rules"):
node.apply_format(doc)
mock_base.assert_called_once_with(doc, p=False, r=False)
@@ -121,6 +119,7 @@ def test_pydantic_config_raises_before_load(self, para):
def test_unknown_config_type_raises(self, root_config, para):
"""没有 CONFIG_PATH 的节点,load_config 后 _pydantic_config 应为 None。"""
+
# 创建一个没有 CONFIG_PATH 的子类
class FakeNode(FormatNodeBase):
CONFIG_MODEL = type("TotallyUnknownConfig", (), {})
@@ -226,7 +225,7 @@ def test_table_content_config(self, root_config):
def test_table_content_config_default(self):
"""TablesConfig 的 content 有默认值。"""
- from wordformat.config.datamodel import TablesConfig
+ from wordformat.config.models import TablesConfig
cfg = TablesConfig()
assert cfg.content is not None
@@ -953,7 +952,7 @@ def test_apply_with_wrong_format(self, root_config):
node.load_config(root_config)
with patch.object(node, "add_comment") as mock_comment:
# 配置中 line_spacing 为 "0倍",现会触发 ValueError,mock 掉该步
- with patch("wordformat.style.check_format.LineSpacing.format"):
+ with patch("wordformat.style.diff.LineSpacing.format"):
node.apply_format(doc)
assert mock_comment.call_count >= 1
diff --git a/tests/test_style.py b/tests/test_style.py
deleted file mode 100644
index ad20e06..0000000
--- a/tests/test_style.py
+++ /dev/null
@@ -1,2356 +0,0 @@
-#!/usr/bin/env python
-"""
-comprehensive tests for style modules:
- check_format, get_some, set_some, style_enum, utils
-"""
-
-import pytest
-from types import SimpleNamespace
-from unittest.mock import MagicMock, PropertyMock, patch as mock_patch
-
-from docx import Document
-from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
-from docx.shared import Pt, RGBColor
-from docx.oxml.ns import qn
-
-from wordformat.style.check_format import DIFFResult, CharacterStyle, ParagraphStyle
-from wordformat.style.get_some 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, _get_style_spacing,
-)
-from wordformat.style.set_some 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,
-)
-from wordformat.style.style_enum import (
- FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
- FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
- Spacing, UnitLabelEnum,
-)
-from wordformat.style.utils import extract_unit_from_string, UnitResult
-
-
-# ---------------------------------------------------------------------------
-# fixtures & helpers
-# ---------------------------------------------------------------------------
-
-@pytest.fixture
-def doc():
- return Document()
-
-
-@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"):
- setattr(w, attr, True)
- return w
-
-
-@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"):
- setattr(w, attr, False)
- return w
-
-
-def _set_warning(w):
- import wordformat.style.check_format as m
- m.style_checks_warning = w
-
-
-def _clear_warning():
- import wordformat.style.check_format as m
- m.__dict__.pop("style_checks_warning", None)
-
-
-# ===========================================================================
-# DIFFResult
-# ===========================================================================
-
-class TestDIFFResult:
- def test_defaults_and_str(self):
- d = DIFFResult()
- assert d.diff_type is d.expected_value is d.current_value is d.comment is None
- assert d.level == 0
- d2 = DIFFResult(comment="hi")
- assert str(d2) == "hi"
-
- @pytest.mark.parametrize("lvl", [0, 1, 2, 3])
- def test_level(self, lvl):
- assert DIFFResult(level=lvl).level == lvl
-
-
-# ===========================================================================
-# CharacterStyle
-# ===========================================================================
-
-class TestCharacterStyle:
- def test_defaults(self, mock_warning):
- _set_warning(mock_warning)
- cs = CharacterStyle()
- assert cs.font_name_cn.value == "宋体"
- assert cs.font_name_en.value == "Times New Roman"
- assert cs.font_size.value == "小四"
- assert cs.bold is cs.italic is cs.underline is False
- _clear_warning()
-
- def test_diff_from_run_no_diff_when_matching(self, doc, mock_warning):
- _set_warning(mock_warning)
- cs = CharacterStyle()
- run = doc.add_paragraph().add_run("t")
- run.font.bold = run.font.italic = run.font.underline = False
- run.font.size = Pt(12)
- 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
- _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):
- _set_warning(mock_warning)
- cs = CharacterStyle(**{prop: expected_val})
- run = doc.add_paragraph().add_run("t")
- setattr(run.font, prop, current_val)
- types = [d.diff_type for d in cs.diff_from_run(run)]
- assert prop in types
- _clear_warning()
-
- def test_diff_font_size_and_name_cn(self, doc, mock_warning):
- _set_warning(mock_warning)
- cs = CharacterStyle(font_size="小四", font_name_cn="宋体")
- run = doc.add_paragraph().add_run("测试")
- run.font.size = Pt(14)
- 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 == "黑体"
- _clear_warning()
-
- def test_apply_to_run_fixes_bold(self, doc, mock_warning):
- _set_warning(mock_warning)
- run = doc.add_paragraph().add_run("t")
- run.font.bold = False
- result = CharacterStyle(bold=True).apply_to_run(run)
- assert run.font.bold is True
- assert any(d.diff_type == "bold" for d in result)
- _clear_warning()
-
- def test_to_string_filters_by_warning(self, mock_warning, mock_warning_off):
- diffs = [DIFFResult(diff_type="bold", current_value=True, expected_value=False)]
- _set_warning(mock_warning)
- assert "加粗错误" in CharacterStyle.to_string(diffs)
- _set_warning(mock_warning_off)
- assert CharacterStyle.to_string(diffs) == ""
- _clear_warning()
-
-
-# ===========================================================================
-# ParagraphStyle
-# ===========================================================================
-
-class TestParagraphStyle:
- def test_defaults(self, mock_warning):
- _set_warning(mock_warning)
- ps = ParagraphStyle()
- assert ps.alignment.value == "左对齐"
- assert ps.space_before.value == "0.5行"
- assert ps.line_spacing.value == "1.5倍"
- _clear_warning()
-
- def test_diff_none_returns_empty(self, mock_warning):
- _set_warning(mock_warning)
- assert ParagraphStyle().diff_from_paragraph(None) == []
- _clear_warning()
-
- 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)]
- _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)]
- assert "builtin_style_name" not in types # 英文"normal" = 中文"正文"
- _clear_warning()
-
- def test_to_string_line_spacing_rule_key_bug(self, doc, mock_warning):
- """BUG: diff_type='line_spacing_rule' but warning dict key='line_spacingrule'."""
- _set_warning(mock_warning)
- p = doc.add_paragraph()
- p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
- diffs = ParagraphStyle(line_spacingrule="单倍行距").diff_from_paragraph(p)
- assert "line_spacing_rule" in [d.diff_type for d in diffs]
- result = ParagraphStyle.to_string(diffs)
- assert "行距选项" not in result # filtered out due to key mismatch
- _clear_warning()
-
- def test_from_config(self, mock_warning):
- _set_warning(mock_warning)
- cfg = SimpleNamespace(alignment="居中对齐", space_before="1行")
- ps = ParagraphStyle.from_config(cfg)
- assert ps.alignment.value == "居中对齐"
- assert ps.space_after.value == "0.5行" # default
- _clear_warning()
-
-
-# ===========================================================================
-# get_some
-# ===========================================================================
-
-class TestGetSomeAlignment:
- @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
- assert paragraph_get_alignment(p) == align
-
- 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),
- ])
- def test_preset(self, doc, rule, expected):
- p = doc.add_paragraph()
- p.paragraph_format.line_spacing_rule = rule
- assert paragraph_get_line_spacing(p) == expected
-
- def test_multiple_custom(self, doc):
- p = doc.add_paragraph()
- p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
- 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])
- 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
-
- def test_font_name_after_set(self, doc):
- run = doc.add_paragraph().add_run("x")
- run_set_font_name(run, "宋体")
- assert run_get_font_name(run) == "宋体"
-
- def test_font_size_default_12(self, doc):
- assert run_get_font_size_pt(doc.add_paragraph().add_run("x")) == 12.0
-
- def test_font_size_explicit(self, doc):
- run = doc.add_paragraph().add_run("x")
- run.font.size = Pt(14)
- assert run_get_font_size_pt(run) == 14.0
-
- def test_font_color_default_black(self, doc):
- assert run_get_font_color(doc.add_paragraph().add_run("x")) == (0, 0, 0)
-
- def test_font_color_set(self, doc):
- run = doc.add_paragraph().add_run("x")
- 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"),
- ])
- def test_boolean_props(self, doc, getter, attr):
- run = doc.add_paragraph().add_run("x")
- assert getter(run) is False
- setattr(run.font, attr, True)
- assert getter(run) is True
-
-
-class TestGetSomeFirstLineIndent:
- def test_default_none(self, doc):
- assert paragraph_get_first_line_indent(doc.add_paragraph()) is None
-
- def test_with_chars(self, doc):
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_char(p, 2)
- assert paragraph_get_first_line_indent(p) == 2.0
-
- def test_ignores_firstLine_twips(self, doc):
- """Only reads firstLineChars, ignores firstLine (physical twips)."""
- p = doc.add_paragraph()
- p.paragraph_format.first_line_indent = Pt(24)
- 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()
- assert GetIndent.left_indent(p) is None
- assert GetIndent.right_indent(p) is None
-
- def test_invalid_type_raises(self, doc):
- with pytest.raises(ValueError, match="必须是"):
- GetIndent.line_indent(doc.add_paragraph(), "bad")
-
- def test_left_after_set_char(self, doc):
- p = doc.add_paragraph()
- _SetIndent.set_char(p, "R", 3)
- assert GetIndent.left_indent(p) == 3.0
-
-
-class TestGetStyleSpacing:
- def test_none_style(self):
- assert _get_style_spacing(None, "before") is None
-
- def test_no_element(self):
- s = MagicMock()
- del s.element
- assert _get_style_spacing(s, "before") is None
-
-
-# ===========================================================================
-# set_some
-# ===========================================================================
-
-class TestSetSomeFontName:
- def test_set_and_verify_xml(self, doc):
- run = doc.add_paragraph().add_run("x")
- run_set_font_name(run, "黑体")
- assert run._element.rPr.rFonts.get(qn("w:eastAsia")) == "黑体"
-
-
-class TestSetSomeSpaceByLines:
- def test_set_before_and_after(self, doc):
- p = doc.add_paragraph()
- set_paragraph_space_before_by_lines(p, 0.5)
- set_paragraph_space_after_by_lines(p, 1.0)
- assert paragraph_get_space_before(p) == 0.5
- assert paragraph_get_space_after(p) == 1.0
-
- def test_set_both(self, doc):
- p = doc.add_paragraph()
- _paragraph_space_by_lines(p, before_lines=0.3, after_lines=0.7)
- assert paragraph_get_space_before(p) == 0.3
- assert paragraph_get_space_after(p) == 0.7
-
- def test_zero_preserves_existing(self, doc):
- p = doc.add_paragraph()
- set_paragraph_space_after_by_lines(p, 1.0)
- set_paragraph_space_before_by_lines(p, 0.0)
- assert paragraph_get_space_after(p) == 1.0
-
-
-class TestSetSpacingHang:
- def test_set_and_clamp(self, doc):
- p = doc.add_paragraph()
- _SetSpacing.set_hang(p, "before", 0.5)
- assert paragraph_get_space_before(p) == 0.5
- _SetSpacing.set_hang(p, "before", 50.0)
- 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):
- p = doc.add_paragraph()
- getattr(_SetLineSpacing, method)(p, 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()
- _SetIndent.set_char(p, "R", 2.5)
- assert GetIndent.left_indent(p) == 2.5
-
- def test_set_char_returns_true(self, doc):
- assert _SetIndent.set_char(doc.add_paragraph(), "R", 2) is True
-
- 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"),
- ])
- def test_physical_units_set_indent(self, doc, method, indent_type):
- p = doc.add_paragraph()
- getattr(_SetIndent, method)(p, indent_type, 1.0)
- attr = "left_indent" if indent_type == "R" else "right_indent"
- assert getattr(p.paragraph_format, attr) is not None
-
- def test_apply_indent_invalid_raises(self, doc):
- with pytest.raises(ValueError, match="无效的缩进类型"):
- _SetIndent._apply_indent(doc.add_paragraph(), "Z", 10)
-
-
-class TestSetFirstLineIndent:
- def test_set_and_clear(self, doc):
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_char(p, 2)
- assert paragraph_get_first_line_indent(p) == 2.0
- _SetFirstLineIndent.clear(p)
- assert paragraph_get_first_line_indent(p) is None
-
- def test_clear_preserves_left_right(self, doc):
- p = doc.add_paragraph()
- _SetIndent.set_pt(p, "R", 12)
- _SetIndent.set_pt(p, "X", 12)
- _SetFirstLineIndent.set_char(p, 2)
- _SetFirstLineIndent.clear(p)
- ind = p._element.pPr.find(qn("w:ind"))
- assert ind is not None
- assert ind.get(qn("w:left")) is not None and ind.get(qn("w:right")) is not None
- assert ind.get(qn("w:firstLineChars")) is None
-
- def test_clear_no_indent_ok(self, doc):
- _SetFirstLineIndent.clear(doc.add_paragraph()) # no crash
-
- @pytest.mark.parametrize("val,expected", [(0, 0.0), (-1, -1.0)])
- def test_first_line_and_hanging_indent(self, doc, val, expected):
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_char(p, val)
- assert paragraph_get_first_line_indent(p) == expected
-
- @pytest.mark.parametrize("method", ["set_pt", "set_cm", "set_inch", "set_mm"])
- def test_physical_units_no_firstLineChars(self, doc, method):
- p = doc.add_paragraph()
- getattr(_SetFirstLineIndent, method)(p, 1.0)
- assert paragraph_get_first_line_indent(p) is None
-
-
-# ===========================================================================
-# style_enum - FontSize
-# ===========================================================================
-
-class TestFontSize:
- @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
-
- def test_bare_number_rel_value_is_string(self):
- """No unit -> extract_unit fails -> rel_value stays as raw string."""
- assert FontSize("15").rel_value == "15"
-
- def test_base_set(self, doc):
- run = doc.add_paragraph().add_run("x")
- FontSize("小四").base_set(run)
- assert run.font.size.pt == 12
-
- def test_base_set_invalid_raises(self, doc):
- with pytest.raises(ValueError, match="无效的字号"):
- 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)),
- ])
- def test_parse_color(self, spec, expected):
- assert FontColor(spec).rel_value == expected
-
- def test_eq_tuple(self):
- assert FontColor("red") == (255, 0, 0)
- assert FontColor("red") != (0, 0, 0)
-
- def test_eq_string(self):
- """字符串比较应与解析后的RGB比较"""
- assert FontColor("red") == "red"
- assert FontColor("red") != "blue"
-
- def test_base_set(self, doc):
- run = doc.add_paragraph().add_run("x")
- FontColor("red").base_set(run)
- assert run.font.color.rgb == RGBColor(255, 0, 0)
-
- def test_invalid_raises(self):
- with pytest.raises(ValueError):
- FontColor("not_a_color").rel_value
-
- 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)])
- def test_is_hex(self, h, ok):
- assert FontColor._is_hex(h) == ok
-
- def test_normalize_hex(self):
- assert FontColor._normalize_hex("#f00") == "#ff0000"
- 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),
- ])
- def test_label_map(self, label, val):
- assert Alignment(label).rel_value == val
-
- def test_base_set_and_get(self, doc):
- p = doc.add_paragraph()
- Alignment("居中对齐").base_set(p)
- assert p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
- 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
-
- 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),
- ])
- 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
-
- def test_base_set(self, doc):
- p = doc.add_paragraph()
- LineSpacing("1.5倍").base_set(p)
- assert p.paragraph_format.line_spacing == 1.5
-
- def test_leq_zero_silently_changes_to_1(self, doc):
- """<=0 现在抛出 ValueError 而非静默改为 1。"""
- p = doc.add_paragraph()
- with pytest.raises(ValueError):
- LineSpacing("0倍").base_set(p)
-
- def test_zero_should_raise(self, doc):
- with pytest.raises(ValueError):
- LineSpacing("0倍").base_set(doc.add_paragraph())
-
-
-class TestBuiltInStyleEnum:
- @pytest.mark.parametrize("label,expected", [("正文", "Normal"), ("标题", "Title"), ("Heading 1", "Heading 1")])
- def test_label_map(self, label, expected):
- assert BuiltInStyle(label).rel_value == expected
-
- def test_get_from_paragraph(self, doc):
- assert BuiltInStyle("正文").get_from_paragraph(doc.add_paragraph()) == "normal"
-
- def test_base_set_valid(self, doc):
- p = doc.add_paragraph()
- BuiltInStyle("正文").base_set(p)
- assert p.style.name == "Normal"
-
- def test_base_set_invalid_raises(self, doc):
- """NonExistent 样式会被 _ensure_style_exists 自动创建,不再抛异常"""
- p = doc.add_paragraph()
- BuiltInStyle("NonExistent").base_set(p)
- assert p.style.name == "NonExistent"
-
-
-class TestFontNameEnum:
- @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
-
- def test_base_set_chinese(self, doc):
- run = doc.add_paragraph().add_run("x")
- FontName("宋体").base_set(run)
- assert run_get_font_name(run) == "宋体"
-
- def test_base_set_english(self, doc):
- run = doc.add_paragraph().add_run("x")
- FontName("Arial").base_set(run)
- assert run.font.name == "Arial"
-
-
-class TestUnitLabelEnumEq:
- def test_same_class_same_rel(self):
- assert FontSize("小四") == FontSize("小四")
-
- def test_same_class_diff_rel(self):
- assert FontSize("小四") != FontSize("三号")
-
- def test_string_rel_eq(self):
- assert FontName("宋体") == "宋体"
-
- def test_numeric_rel_eq(self):
- assert FontSize("小四") == 12
-
- 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"),
- ])
- 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
-
- def test_invalid(self):
- r = extract_unit_from_string("no_unit")
- assert not r.is_valid and r.value is None
-
- 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)
- 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),
- ])
- def test_convert_to_emu(self, unit, val, 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
-
- 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
-
- 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", "倍"),
- ])
- def test_unit_ch(self, unit, ch):
- assert UnitResult(standard_unit=unit).unit_ch == ch
-
- def test_unit_ch_none(self):
- assert UnitResult(standard_unit=None).unit_ch == ""
-
- def test_unit_ch_invalid_raises(self):
- with pytest.raises(ValueError, match="Invalid unit"):
- UnitResult(standard_unit="xyz").unit_ch
-
-
-# ===========================================================================
-# Additional coverage tests for get_some.py
-# ===========================================================================
-
-
-class TestGetSomeAlignmentFromStyle:
- """Cover line 38: paragraph_get_alignment with style.alignment (not direct)"""
-
- def test_alignment_from_style(self, doc):
- """When paragraph has no direct alignment, falls back to style.alignment"""
- p = doc.add_paragraph()
- p.paragraph_format.alignment = None
- # Set alignment on the paragraph's style
- p.style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
- assert paragraph_get_alignment(p) == WD_ALIGN_PARAGRAPH.CENTER
-
- def test_alignment_from_base_style(self, doc):
- """Traverse _base_style chain for alignment"""
- p = doc.add_paragraph()
- p.paragraph_format.alignment = None
- # Use patch.object to mock the style property
- mock_style = MagicMock()
- mock_style.paragraph_format.alignment = None
- mock_base = MagicMock()
- mock_base.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.RIGHT
- mock_style._base_style = mock_base
- with mock_patch.object(type(p), 'style', new_callable=PropertyMock, return_value=mock_style):
- assert paragraph_get_alignment(p) == WD_ALIGN_PARAGRAPH.RIGHT
-
- def test_alignment_no_base_style_returns_none(self, doc):
- """No direct alignment, no style alignment, no base_style -> None"""
- p = doc.add_paragraph()
- p.paragraph_format.alignment = None
- p.style.paragraph_format.alignment = None
- p.style._base_style = None
- assert paragraph_get_alignment(p) is None
-
-
-class TestGetStyleSpacingExtended:
- """Cover lines 66-69, 80-124: _get_style_spacing with various paths"""
-
- def test_style_elem_none_falls_to_base(self):
- """style.element is None -> recurse to base_style (lines 66-69)"""
- mock_base = MagicMock()
- mock_base.element = None
- mock_base.base_style = None
- mock_style = MagicMock()
- mock_style.element = None
- mock_style.base_style = mock_base
- assert _get_style_spacing(mock_style, "before") is None
-
- def test_style_pPr_none_recurse_base(self):
- """style_pPr is None -> recurse to base_style (lines 76-81)"""
- from docx.oxml import OxmlElement
- mock_base = MagicMock()
- mock_base.element = None
- mock_base.base_style = None
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- mock_style.element = elem
- mock_style.base_style = mock_base
- assert _get_style_spacing(mock_style, "before") is None
-
- def test_spacing_none_recurse_base(self):
- """spacing element is None -> recurse to base_style (lines 89-94)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
- mock_base = MagicMock()
- mock_base.element = None
- mock_base.base_style = None
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- elem.append(pPr)
- mock_style.element = elem
- mock_style.base_style = mock_base
- assert _get_style_spacing(mock_style, "before") is None
-
- def test_lines_attr_valid(self):
- """Valid beforeLines attribute -> returns float (lines 97-112)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "50")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- assert _get_style_spacing(mock_style, "before") == 0.5
-
- def test_lines_attr_zero_returns_zero(self):
- """显式 Lines=0 应返回 0.0,不回退到基样式。"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
- mock_base = MagicMock()
- mock_base.element = None
- mock_base.base_style = None
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "0")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- mock_style.base_style = mock_base
- assert _get_style_spacing(mock_style, "before") == 0.0
-
- def test_mock_detection_in_lines_attr(self):
- """Lines attr is a Mock object -> detect and handle (lines 100-106)"""
- # We can't set a Mock as an XML attribute, so we mock the entire
- # _get_style_spacing function's internal behavior by constructing
- # a mock style whose spacing.get returns a Mock
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- # Set a real value first, then mock the get to return a Mock
- spacing.set(qn("w:beforeLines"), "100")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
-
- # Patch spacing.get to return a Mock with "Mock" in class name
- original_get = spacing.get
- mock_attr = MagicMock(__class__=MagicMock(__name__="Mock"))
- mock_attr.return_value = "100"
-
- def mock_get(qname, default=None):
- if "beforeLines" in qname:
- return mock_attr
- return original_get(qname, default)
-
- spacing.get = mock_get
- result = _get_style_spacing(mock_style, "before")
- assert result == 1.0
-
-
-class TestGetSomeSpaceBeforeAfterInheritance:
- """Cover lines 147-148, 157-158: space_before/after with style inheritance"""
-
- def test_space_before_invalid_attr_returns_none(self, doc):
- """beforeLines attr is non-numeric -> self_lines=None, fall to style (line 147-148)"""
- 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")
- pPr.append(spacing)
- # style has no spacing either
- assert paragraph_get_space_before(p) is None
-
- def test_space_after_invalid_attr_returns_none(self, doc):
- """afterLines attr is non-numeric -> self_lines=None, fall to style (line 180-181)"""
- 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")
- pPr.append(spacing)
- assert paragraph_get_space_after(p) is None
-
- def test_space_before_from_style(self, doc):
- """No direct beforeLines, but style has it -> returns style value"""
- p = doc.add_paragraph()
- 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):
- # _get_style_spacing returns None for None element
- assert paragraph_get_space_before(p) is None
-
- def test_space_after_from_style(self, doc):
- """No direct afterLines, but style has it -> returns style value"""
- p = doc.add_paragraph()
- 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):
- assert paragraph_get_space_after(p) is None
-
-
-class TestGetSomeLineSpacingInheritance:
- """Cover lines 233-235: paragraph_get_line_spacing with style inheritance"""
-
- def test_line_spacing_attribute_error_returns_none(self, doc):
- """paragraph with broken paragraph_format -> returns None (lines 233-235)"""
- mock_para = MagicMock()
- # paragraph_format raises AttributeError
- mock_para.paragraph_format = None
- result = paragraph_get_line_spacing(mock_para)
- assert result is None
-
- def test_line_spacing_type_error_returns_none(self):
- """paragraph with broken paragraph_format -> returns None"""
- mock_para = MagicMock()
- mock_para.paragraph_format.line_spacing_rule = "invalid"
- mock_para.paragraph_format.line_spacing = None
- result = paragraph_get_line_spacing(mock_para)
- assert result is None
-
-
-class TestGetSomeFirstLineIndentPhysicalUnit:
- """Cover lines 264-266: paragraph_get_first_line_indent with firstLine (physical unit)"""
-
- def test_first_line_indent_exception_returns_none(self, doc):
- """Exception during parsing -> returns None (lines 264-266)"""
- 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.font.size is None, falls back to style.font.size (line 317)"""
- run = doc.add_paragraph().add_run("x")
- run.font.size = None
- mock_style = MagicMock()
- mock_style.font.size = Pt(16)
- with mock_patch.object(type(run._parent), 'style', new_callable=PropertyMock, return_value=mock_style):
- assert run_get_font_size_pt(run) == 16.0
-
- def test_font_color_theme_color(self, doc):
- """run.font.color.rgb is None (theme color) -> returns (0,0,0) (line 338)"""
- run = doc.add_paragraph().add_run("x")
- run.font.color.rgb = None
- assert run_get_font_color(run) == (0, 0, 0)
-
- def test_font_color_none(self, doc):
- """run.font.color is None -> returns (0,0,0)"""
- run = doc.add_paragraph().add_run("x")
- # color is a read-only property, so we mock the entire run_get_font_color
- # by directly testing the code path with a MagicMock run
- mock_run = MagicMock()
- mock_run.font.color = None
- assert run_get_font_color(mock_run) == (0, 0, 0)
-
-
-class TestGetIndentWithRealElement:
- """Cover lines 408-423: GetIndent.line_indent with real indent element"""
-
- def test_line_indent_left_with_chars(self, doc):
- """Real w:ind element with w:leftChars -> returns float (lines 408-423)"""
- 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")
- pPr.append(ind)
- assert GetIndent.line_indent(p, "left") == 3.0
-
- def test_line_indent_right_with_chars(self, doc):
- """Real w:ind element with w:rightChars -> returns float"""
- 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")
- pPr.append(ind)
- assert GetIndent.line_indent(p, "right") == 2.0
-
- def test_line_indent_invalid_chars_value(self, doc):
- """w:leftChars has non-numeric value -> returns None (line 417-418)"""
- 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")
- pPr.append(ind)
- assert GetIndent.line_indent(p, "left") is None
-
- def test_line_indent_no_pPr(self, doc):
- """No pPr element -> returns None (line 400-401)"""
- p = doc.add_paragraph()
- # Remove pPr if it exists
- pPr = p._element.find(qn("w:pPr"))
- if pPr is not None:
- p._element.remove(pPr)
- assert GetIndent.line_indent(p, "left") is None
-
- 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:
- pPr.remove(ind)
- assert GetIndent.line_indent(p, "left") is None
-
-
-# ===========================================================================
-# Additional coverage tests for check_format.py
-# ===========================================================================
-
-
-class TestDIFFResultWarningLevel:
- """Cover line 94: DIFFResult with level='warning' (string)"""
-
- def test_level_string(self):
- d = DIFFResult(level="warning")
- assert d.level == "warning"
-
-
-class TestParagraphStyleApplyToParagraph:
- """Cover lines 315-358: ParagraphStyle.apply_to_paragraph with various indent types"""
-
- def test_apply_left_indent(self, doc, mock_warning):
- """apply_to_paragraph with left_indent diff (line 337-338)"""
- _set_warning(mock_warning)
- p = doc.add_paragraph()
- ps = ParagraphStyle(left_indent="2字符")
- diffs = ps.apply_to_paragraph(p)
- assert any(d.diff_type == "left_indent" for d in diffs)
- _clear_warning()
-
- def test_apply_right_indent(self, doc, mock_warning):
- """apply_to_paragraph with right_indent diff (line 339-341)"""
- _set_warning(mock_warning)
- p = doc.add_paragraph()
- ps = ParagraphStyle(right_indent="2字符")
- diffs = ps.apply_to_paragraph(p)
- assert any(d.diff_type == "right_indent" for d in diffs)
- _clear_warning()
-
- def test_apply_first_line_indent(self, doc, mock_warning):
- """apply_to_paragraph with first_line_indent diff (line 342-344)"""
- _set_warning(mock_warning)
- p = doc.add_paragraph()
- ps = ParagraphStyle(first_line_indent="2字符")
- diffs = ps.apply_to_paragraph(p)
- assert any(d.diff_type == "first_line_indent" for d in diffs)
- _clear_warning()
-
- def test_apply_unknown_diff_type_skipped(self, doc, mock_warning):
- """apply_to_paragraph with unknown diff_type -> logged and skipped (line 348-353)"""
- _set_warning(mock_warning)
- p = doc.add_paragraph()
- ps = ParagraphStyle()
- # Force an unknown diff_type by monkey-patching
- from wordformat.style.check_format 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
- assert len(result) == 0
- ps.diff_from_paragraph = original_diff
- _clear_warning()
-
- def test_apply_builtin_style_name(self, doc, mock_warning):
- """apply_to_paragraph with builtin_style_name diff (line 345-347)"""
- _set_warning(mock_warning)
- p = doc.add_paragraph()
- ps = ParagraphStyle(builtin_style_name="Heading 1")
- diffs = ps.apply_to_paragraph(p)
- assert any(d.diff_type == "builtin_style_name" for d in diffs)
- _clear_warning()
-
-
-class TestParagraphStyleFromConfigExtended:
- """Cover line 478: ParagraphStyle.from_config with real config"""
-
- def test_from_config_all_fields(self, mock_warning):
- """from_config with all fields specified (line 478+)"""
- _set_warning(mock_warning)
- cfg = SimpleNamespace(
- alignment="居中对齐",
- space_before="1行",
- space_after="0.5行",
- line_spacing="2倍",
- line_spacingrule="2倍行距",
- first_line_indent="2字符",
- left_indent="1字符",
- right_indent="1字符",
- builtin_style_name="正文",
- )
- ps = ParagraphStyle.from_config(cfg)
- assert ps.alignment.value == "居中对齐"
- assert ps.space_before.value == "1行"
- assert ps.space_after.value == "0.5行"
- assert ps.line_spacing == 2.0
- assert ps.line_spacingrule.value == "2倍行距"
- assert ps.first_line_indent.value == "2字符"
- assert ps.left_indent.value == "1字符"
- assert ps.right_indent.value == "1字符"
- assert ps.builtin_style_name.rel_value == "Normal"
- _clear_warning()
-
- def test_from_config_partial_fields(self, mock_warning):
- """from_config with only some fields -> defaults for rest"""
- _set_warning(mock_warning)
- cfg = SimpleNamespace(alignment="右对齐")
- ps = ParagraphStyle.from_config(cfg)
- assert ps.alignment.value == "右对齐"
- assert ps.space_before.value == "0.5行" # default
- assert ps.builtin_style_name.rel_value == "Normal" # default
- _clear_warning()
-
-
-# ===========================================================================
-# Additional coverage tests for set_some.py
-# ===========================================================================
-
-
-class TestSetSpacingUnits:
- """Cover lines 137-140, 151-154, 165-168, 179-182, 193-196: _SetSpacing unit methods"""
-
- def test_set_pt_before(self, doc):
- """_SetSpacing.set_pt with spacing_type='before' (lines 151-152)"""
- p = doc.add_paragraph()
- _SetSpacing.set_pt(p, "before", 12)
- assert p.paragraph_format.space_before is not None
-
- def test_set_pt_after(self, doc):
- """_SetSpacing.set_pt with spacing_type='after' (lines 153-154)"""
- p = doc.add_paragraph()
- _SetSpacing.set_pt(p, "after", 12)
- assert p.paragraph_format.space_after is not None
-
- def test_set_cm_before(self, doc):
- """_SetSpacing.set_cm with spacing_type='before' (lines 165-166)"""
- p = doc.add_paragraph()
- _SetSpacing.set_cm(p, "before", 1.0)
- assert p.paragraph_format.space_before is not None
-
- def test_set_cm_after(self, doc):
- """_SetSpacing.set_cm with spacing_type='after' (lines 167-168)"""
- p = doc.add_paragraph()
- _SetSpacing.set_cm(p, "after", 1.0)
- assert p.paragraph_format.space_after is not None
-
- def test_set_inch_before(self, doc):
- """_SetSpacing.set_inch with spacing_type='before' (lines 179-180)"""
- p = doc.add_paragraph()
- _SetSpacing.set_inch(p, "before", 0.5)
- assert p.paragraph_format.space_before is not None
-
- def test_set_inch_after(self, doc):
- """_SetSpacing.set_inch with spacing_type='after' (lines 181-182)"""
- p = doc.add_paragraph()
- _SetSpacing.set_inch(p, "after", 0.5)
- assert p.paragraph_format.space_after is not None
-
- def test_set_mm_before(self, doc):
- """_SetSpacing.set_mm with spacing_type='before' (lines 193-194)"""
- p = doc.add_paragraph()
- _SetSpacing.set_mm(p, "before", 5.0)
- assert p.paragraph_format.space_before is not None
-
- def test_set_mm_after(self, doc):
- """_SetSpacing.set_mm with spacing_type='after' (lines 195-196)"""
- p = doc.add_paragraph()
- _SetSpacing.set_mm(p, "after", 5.0)
- assert p.paragraph_format.space_after is not None
-
- def test_set_hang_sets_twips_to_zero(self, doc):
- """set_hang 写入 w:before="0" 覆盖样式级 pt 间距"""
- 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")
- pPr.append(spacing)
- _SetSpacing.set_hang(p, "before", 0.5)
- spacing_after = pPr.find(qn("w:spacing"))
- assert spacing_after.get(qn("w:before")) == "0"
-
-
-class TestSetLineSpacingUnits:
- """Cover lines 234-235, 245-246: _SetLineSpacing additional unit methods"""
-
- def test_set_inch(self, doc):
- """_SetLineSpacing.set_inch (lines 234-235)"""
- p = doc.add_paragraph()
- _SetLineSpacing.set_inch(p, 0.5)
- assert p.paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY
-
- def test_set_mm(self, doc):
- """_SetLineSpacing.set_mm (lines 245-246)"""
- p = doc.add_paragraph()
- _SetLineSpacing.set_mm(p, 10.0)
- 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"""
-
- def test_set_char_zero_clears_attrs(self, doc):
- """_SetIndent.set_char with value=0 clears attributes (lines 286-291)"""
- p = doc.add_paragraph()
- _SetIndent.set_char(p, "R", 2)
- _SetIndent.set_char(p, "R", 0)
- # After setting to 0, the char attr should be removed
- pPr = p._element.find(qn("w:pPr"))
- if pPr is not None:
- ind = pPr.find(qn("w:ind"))
- if ind is not None:
- assert ind.get(qn("w:leftChars")) is None
-
- def test_set_char_exception_returns_false(self, doc):
- """_SetIndent.set_char with exception -> returns False (lines 300-302)"""
- p = doc.add_paragraph()
- # Force an exception by removing _element
- original = p._element
- p._element = None
- assert _SetIndent.set_char(p, "R", 2) is False
- p._element = original
-
- def test_set_pt(self, doc):
- """_SetIndent.set_pt (line 313-314)"""
- p = doc.add_paragraph()
- _SetIndent.set_pt(p, "R", 12)
- assert p.paragraph_format.left_indent is not None
-
- def test_set_cm(self, doc):
- """_SetIndent.set_cm (line 325-326)"""
- p = doc.add_paragraph()
- _SetIndent.set_cm(p, "X", 1.0)
- assert p.paragraph_format.right_indent is not None
-
- def test_set_inch(self, doc):
- """_SetIndent.set_inch (line 337-338)"""
- p = doc.add_paragraph()
- _SetIndent.set_inch(p, "R", 0.5)
- assert p.paragraph_format.left_indent is not None
-
- def test_set_mm(self, doc):
- """_SetIndent.set_mm (line 349-350)"""
- p = doc.add_paragraph()
- _SetIndent.set_mm(p, "X", 5.0)
- assert p.paragraph_format.right_indent is not None
-
-
-class TestSetFirstLineIndentUnits:
- """Cover lines 478, 494, 510, 526: _SetFirstLineIndent physical unit methods"""
-
- def test_set_inch(self, doc):
- """_SetFirstLineIndent.set_inch (line 479)"""
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_inch(p, 0.5)
- assert p.paragraph_format.first_line_indent is not None
-
- def test_set_mm(self, doc):
- """_SetFirstLineIndent.set_mm (line 495)"""
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_mm(p, 5.0)
- assert p.paragraph_format.first_line_indent is not None
-
- def test_set_pt(self, doc):
- """_SetFirstLineIndent.set_pt (line 511)"""
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_pt(p, 24)
- assert p.paragraph_format.first_line_indent is not None
-
- def test_set_cm(self, doc):
- """_SetFirstLineIndent.set_cm (line 527)"""
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_cm(p, 0.5)
- assert p.paragraph_format.first_line_indent is not None
-
- def test_set_inch_clears_firstLineChars(self, doc):
- """set_inch clears firstLineChars to avoid priority conflict (line 478)"""
- p = doc.add_paragraph()
- _SetFirstLineIndent.set_char(p, 2)
- _SetFirstLineIndent.set_inch(p, 0.5)
- # firstLineChars should be cleared
- pPr = p._element.find(qn("w:pPr"))
- if pPr is not None:
- ind = pPr.find(qn("w:ind"))
- if ind is not None:
- assert ind.get(qn("w:firstLineChars")) is None
-
- def test_set_char_exception_returns_false(self, doc):
- """set_char with exception -> returns False (lines 461-463)"""
- p = doc.add_paragraph()
- # The try block accesses paragraph._element.get_or_add_pPr()
- # We need to make it fail inside the try, after clear() succeeds.
- # 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
-
-
-# ===========================================================================
-# Coverage: style_enum.py uncovered lines
-# ===========================================================================
-
-
-class TestUnitLabelEnumRelValueSetter:
- """Cover line 122: rel_value.setter"""
-
- def test_rel_value_setter(self):
- """Setting rel_value directly via setter"""
- e = FontSize("小四")
- assert e.rel_value == 12
- e.rel_value = 99
- 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):
- def get_from_paragraph(self, paragraph):
- return None
-
- e = MinimalEnum("test")
- # Calling base_set should just log and not crash
- e.base_set(doc.add_paragraph())
-
-
-class TestUnitLabelEnumFormatWithRun:
- """Cover lines 152-155: format() method with Run object (not Paragraph)"""
-
- def test_format_with_run(self, doc):
- """format() dispatches to fun(run=...) when docx_obj is a Run"""
- run = doc.add_paragraph().add_run("x")
- FontSize("小四").format(run)
- assert run.font.size.pt == 12
-
- def test_format_with_paragraph(self, doc):
- """format() dispatches to fun(paragraph=...) when docx_obj is a Paragraph"""
- p = doc.add_paragraph()
- Alignment("居中对齐").format(p)
- assert p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
-
- def test_format_no_fun_falls_to_base_set(self, doc):
- """format() with no matching function_map falls back to base_set"""
- run = doc.add_paragraph().add_run("x")
- FontName("宋体").format(run)
- 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
-
- e = MinimalEnum("test")
- with pytest.raises(NotImplementedError):
- e.get_from_paragraph(doc.add_paragraph())
-
-
-class TestFontColorParseColorValueError:
- """Cover lines 304-305: FontColor._parse_color ValueError for invalid hex"""
-
- def test_parse_color_invalid_hex_raises(self):
- """Hex that passes _is_hex but fails webcolors.hex_to_rgb"""
- # _is_hex checks regex, but webcolors might still fail
- # A valid hex format that webcolors can't parse is unlikely since all valid hex are valid colors
- # But we can test the ValueError path by using a hex that causes webcolors to raise
- # 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:
- with pytest.raises(ValueError, match="非法十六进制色值"):
- FontColor("#FF0000").rel_value
- finally:
- webcolors.hex_to_rgb = original
-
-
-class TestFontColorBaseSetNonString:
- """Cover line 330: FontColor.base_set with non-string value raises TypeError"""
-
- def test_base_set_non_string_raises(self, doc):
- """base_set with non-string value raises TypeError"""
- run = doc.add_paragraph().add_run("x")
- fc = FontColor.__new__(FontColor)
- fc.value = 123 # non-string
- fc._rel_value = None
- fc.original_unit = None
- fc.unit_ch = None
- fc._rel_unit = None
- fc.extract_unit_result = None
- with pytest.raises(TypeError, match="颜色标识仅支持字符串"):
- 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
-
- def test_eq_wrong_length_tuple_returns_false(self):
- """__eq__ with tuple of wrong length returns False (line 339-340)"""
- fc = FontColor("red")
- assert fc != (255, 0)
- assert fc != (255, 0, 0, 0)
-
- def test_eq_parse_error_returns_false(self):
- """__eq__ when rel_value raises -> returns False (lines 344-345)"""
- fc = FontColor.__new__(FontColor)
- fc.value = 12345 # will cause _parse_color to raise TypeError
- fc._rel_value = None
- fc.original_unit = None
- fc.unit_ch = None
- fc._rel_unit = None
- fc.extract_unit_result = None
- # __eq__ catches TypeError/ValueError from rel_value
- 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"):
- 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"),
- ])
- 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()
- p.paragraph_format.space_before = Pt(val) if unit == "pt" else None
- 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":
- p.paragraph_format.space_before = Emu(360000 * val)
- elif unit == "inch":
- p.paragraph_format.space_before = Emu(914400 * val)
-
- sb = SpaceBefore(f"{val}{unit}")
- result = sb.get_from_paragraph(p)
- assert result is not None
- # Result should be close to val in the specified unit
- assert abs(result - val) < 0.01
-
- def test_get_from_paragraph_hang_unit(self, doc):
- """SpaceBefore.get_from_paragraph with 'hang' unit uses paragraph_get_space_before"""
- p = doc.add_paragraph()
- set_paragraph_space_before_by_lines(p, 0.5)
- sb = SpaceBefore("0.5行")
- result = sb.get_from_paragraph(p)
- assert result == 0.5
-
- def test_get_from_paragraph_none(self, doc):
- """SpaceBefore.get_from_paragraph returns None when no spacing set"""
- sb = SpaceBefore("12pt")
- result = sb.get_from_paragraph(doc.add_paragraph())
- 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),
- ])
- 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":
- p.paragraph_format.space_after = Emu(36000 * val)
- elif unit == "cm":
- p.paragraph_format.space_after = Emu(360000 * val)
- elif unit == "inch":
- p.paragraph_format.space_after = Emu(914400 * val)
-
- sa = SpaceAfter(f"{val}{unit}")
- result = sa.get_from_paragraph(p)
- assert result is not None
- assert abs(result - val) < 0.01
-
- def test_get_from_paragraph_hang_unit(self, doc):
- """SpaceAfter.get_from_paragraph with 'hang' unit uses paragraph_get_space_after"""
- p = doc.add_paragraph()
- set_paragraph_space_after_by_lines(p, 1.0)
- sa = SpaceAfter("1行")
- result = sa.get_from_paragraph(p)
- assert result == 1.0
-
- def test_get_from_paragraph_none(self, doc):
- """SpaceAfter.get_from_paragraph returns None when no spacing set"""
- sa = SpaceAfter("12pt")
- result = sa.get_from_paragraph(doc.add_paragraph())
- assert result is None
-
-
-class TestLineSpacingRuleBaseSetInvalid:
- """Cover lines 447-451: LineSpacingRule.base_set with invalid value raises ValueError"""
-
- def test_base_set_invalid_raises(self, doc):
- """base_set with value not in _LABEL_MAP raises ValueError"""
- p = doc.add_paragraph()
- lsr = LineSpacingRule.__new__(LineSpacingRule)
- lsr.value = "无效行距"
- lsr._rel_value = None
- lsr.original_unit = None
- lsr.unit_ch = None
- lsr._rel_unit = None
- lsr.extract_unit_result = None
- with pytest.raises(ValueError, match="无效的行距选项"):
- lsr.base_set(p)
-
-
-class TestLineSpacingBaseSetInvalidValue:
- """Cover line 489: LineSpacing.base_set with invalid value raises ValueError"""
-
- def test_base_set_non_numeric_raises(self, doc):
- """base_set with non-numeric rel_value raises ValueError"""
- p = doc.add_paragraph()
- ls = LineSpacing.__new__(LineSpacing)
- ls.value = "abc"
- ls._rel_value = "abc" # not int or float
- ls.original_unit = None
- ls.unit_ch = None
- ls._rel_unit = None
- ls.extract_unit_result = None
- with pytest.raises(ValueError, match="无效的行距"):
- ls.base_set(p)
-
- def test_base_set_none_raises(self, doc):
- """base_set with None rel_value raises ValueError"""
- p = doc.add_paragraph()
- ls = LineSpacing.__new__(LineSpacing)
- ls.value = None
- ls._rel_value = None
- ls.original_unit = None
- ls.unit_ch = None
- ls._rel_unit = None
- ls.extract_unit_result = None
- with pytest.raises(ValueError, match="无效的行距"):
- 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),
- ])
- 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":
- p.paragraph_format.left_indent = Emu(36000 * val)
- elif unit == "cm":
- p.paragraph_format.left_indent = Emu(360000 * val)
- elif unit == "inch":
- p.paragraph_format.left_indent = Emu(914400 * val)
-
- li = LeftIndent(f"{val}{unit}")
- result = li.get_from_paragraph(p)
- assert result is not None
- assert abs(result - val) < 0.01
-
- def test_get_from_paragraph_none(self, doc):
- """LeftIndent.get_from_paragraph returns None when no indent set"""
- li = LeftIndent("12pt")
- result = li.get_from_paragraph(doc.add_paragraph())
- 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),
- ])
- 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":
- p.paragraph_format.right_indent = Emu(36000 * val)
- elif unit == "cm":
- p.paragraph_format.right_indent = Emu(360000 * val)
- elif unit == "inch":
- p.paragraph_format.right_indent = Emu(914400 * val)
-
- ri = RightIndent(f"{val}{unit}")
- result = ri.get_from_paragraph(p)
- assert result is not None
- assert abs(result - val) < 0.01
-
- def test_get_from_paragraph_none(self, doc):
- """RightIndent.get_from_paragraph returns None when no indent set"""
- ri = RightIndent("12pt")
- result = ri.get_from_paragraph(doc.add_paragraph())
- 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),
- ])
- 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":
- p.paragraph_format.first_line_indent = Emu(36000 * val)
- elif unit == "cm":
- p.paragraph_format.first_line_indent = Emu(360000 * val)
- elif unit == "inch":
- p.paragraph_format.first_line_indent = Emu(914400 * val)
-
- fli = FirstLineIndent(f"{val}{unit}")
- result = fli.get_from_paragraph(p)
- assert result is not None
- assert abs(result - val) < 0.01
-
- def test_get_from_paragraph_none(self, doc):
- """FirstLineIndent.get_from_paragraph returns None when no indent set"""
- fli = FirstLineIndent("12pt")
- result = fli.get_from_paragraph(doc.add_paragraph())
- 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"""
-
- 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)"""
- p = doc.add_paragraph()
- # "Heading 1" is a valid style name but also in _LABEL_MAP, so use a valid
- # style name that is NOT in _LABEL_MAP. "Normal" is in _LABEL_MAP via "正文".
- # We need a style name that exists in docx but not in _LABEL_MAP.
- # Let's use a direct style name like "Heading 5" which exists in docx but not in map.
- bis = BuiltInStyle.__new__(BuiltInStyle)
- bis.value = "Heading 1" # This IS in _LABEL_MAP, so it hits the if branch
- bis._rel_value = None
- bis.original_unit = None
- bis.unit_ch = None
- bis._rel_unit = None
- bis.extract_unit_result = None
- # This hits the if branch (line 591-595)
- bis.base_set(p)
- assert p.style.name == "Heading 1"
-
- def test_base_set_else_branch_not_in_map(self, doc):
- """base_set with value NOT in _LABEL_MAP hits else branch (lines 596-600)"""
- p = doc.add_paragraph()
- bis = BuiltInStyle.__new__(BuiltInStyle)
- bis.value = "Normal" # "Normal" is NOT a key in _LABEL_MAP (only "正文" is)
- bis._rel_value = None
- bis.original_unit = None
- bis.unit_ch = None
- bis._rel_unit = None
- bis.extract_unit_result = None
- # "Normal" is not in _LABEL_MAP keys, so it hits else branch (line 596)
- # But "Normal" is a valid style, so docx_obj.style = self.value succeeds
- bis.base_set(p)
- assert p.style.name == "Normal"
-
- def test_base_set_else_branch_invalid_style_raises(self, doc):
- """base_set with value NOT in _LABEL_MAP: _ensure_style_exists 会自动创建样式"""
- p = doc.add_paragraph()
- bis = BuiltInStyle.__new__(BuiltInStyle)
- bis.value = "CompletelyInvalidStyleName"
- bis._rel_value = None
- bis.original_unit = None
- bis.unit_ch = None
- bis._rel_unit = None
- bis.extract_unit_result = None
- # _ensure_style_exists 会自动创建样式,不再抛异常
- bis.base_set(p)
- assert p.style.name == "CompletelyInvalidStyleName"
-
- def test_base_set_if_branch_invalid_style_raises(self, doc):
- """base_set with value in _LABEL_MAP but invalid style raises ValueError (lines 594-595)"""
- p = doc.add_paragraph()
- bis = BuiltInStyle.__new__(BuiltInStyle)
- # Use a value that IS in _LABEL_MAP but maps to an invalid style
- bis.value = "正文" # maps to "Normal" which IS valid, so this won't raise
- bis._rel_value = None
- bis.original_unit = None
- bis.unit_ch = None
- bis._rel_unit = None
- bis.extract_unit_result = None
- # This should succeed since Normal is a valid style
- bis.base_set(p)
- assert p.style.name == "Normal"
-
-
-# ===========================================================================
-# Coverage: get_some.py uncovered lines
-# ===========================================================================
-
-
-class TestGetStyleSpacingAttributeErrorPaths:
- """Cover lines 68-69, 73-74, 80-81, 86-87, 93-94:
- _get_style_spacing when various attributes raise AttributeError"""
-
- def test_style_elem_none_base_style_attr_error(self):
- """style_elem is None, style.base_style raises AttributeError (lines 68-69)"""
- mock_style = MagicMock()
- mock_style.element = None
- # Make base_style raise AttributeError
- del mock_style.base_style
- assert _get_style_spacing(mock_style, "before") is None
-
- def test_style_pPr_find_attr_error(self):
- """style_pPr find raises AttributeError (lines 73-74)"""
- from docx.oxml import OxmlElement
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- mock_style.element = elem
- # Make elem.find raise AttributeError
- original_find = elem.find
- elem.find = MagicMock(side_effect=AttributeError("test"))
- try:
- assert _get_style_spacing(mock_style, "before") is None
- finally:
- elem.find = original_find
-
- def test_style_pPr_none_base_style_attr_error(self):
- """style_pPr is None, style.base_style raises AttributeError (lines 80-81)"""
- from docx.oxml import OxmlElement
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- mock_style.element = elem
- del mock_style.base_style
- # pPr will be None since no w:pPr child
- assert _get_style_spacing(mock_style, "before") is None
-
- def test_spacing_find_attr_error(self):
- """spacing find raises AttributeError (lines 86-87)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- elem.append(pPr)
- mock_style.element = elem
- # Make pPr.find raise AttributeError for w:spacing
- original_find = pPr.find
- def selective_find(qname):
- if "spacing" in str(qname):
- raise AttributeError("test")
- return original_find(qname)
- pPr.find = selective_find
- try:
- assert _get_style_spacing(mock_style, "before") is None
- finally:
- pPr.find = original_find
-
- def test_spacing_none_base_style_attr_error(self):
- """spacing is None, style.base_style raises AttributeError (lines 93-94)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- elem.append(pPr)
- mock_style.element = elem
- del mock_style.base_style
- # No w:spacing child -> spacing is None
- assert _get_style_spacing(mock_style, "before") is None
-
-
-class TestGetStyleSpacingMockDetectionAndValueError:
- """Cover lines 106, 108-109: _get_style_spacing Mock detection and ValueError paths"""
-
- def test_lines_attr_mock_with_return_value(self):
- """lines_attr is a Mock with return_value -> use return_value (line 106)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
-
- # Create a Mock that has return_value and "Mock" in class name
- lines_attr = MagicMock(__class__=MagicMock(__name__="Mock"))
- lines_attr.return_value = "200" # 2.0 lines
-
- original_get = spacing.get
- def mock_get(qname, default=None):
- if "beforeLines" in str(qname):
- return lines_attr
- return original_get(qname, default)
- spacing.get = mock_get
- try:
- result = _get_style_spacing(mock_style, "before")
- assert result == 2.0
- finally:
- spacing.get = original_get
-
- def test_lines_attr_mock_no_return_value_value_error(self):
- """lines_attr is a Mock, return_value exists but int(return_value) raises ValueError (lines 108-109)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- mock_style.base_style = None # terminate recursion
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
-
- # Create a Mock detected as Mock, with return_value that causes int() to raise
- return_value_mock = MagicMock()
- return_value_mock.__int__ = MagicMock(side_effect=ValueError("not a number"))
- lines_attr = MagicMock(__class__=MagicMock(__name__="Mock"))
- lines_attr.return_value = return_value_mock
-
- original_get = spacing.get
- def mock_get(qname, default=None):
- if "beforeLines" in str(qname):
- return lines_attr
- return original_get(qname, default)
- spacing.get = mock_get
- try:
- result = _get_style_spacing(mock_style, "before")
- # Mock detected -> lines_attr = return_value_mock
- # int(return_value_mock) raises ValueError -> lines_val = None
- # base_style is None -> returns None
- assert result is None
- finally:
- spacing.get = original_get
-
- def test_lines_attr_not_mock_value_error(self):
- """lines_attr is not a Mock, int() raises ValueError (lines 108-109)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- mock_style.base_style = None # terminate recursion
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
-
- original_get = spacing.get
- def mock_get(qname, default=None):
- if "beforeLines" in str(qname):
- return "not_a_number"
- return original_get(qname, default)
- spacing.get = mock_get
- try:
- result = _get_style_spacing(mock_style, "before")
- # int("not_a_number") raises ValueError -> lines_val = None
- # Then falls to base_style which is None
- assert result is None
- finally:
- spacing.get = original_get
-
-
-class TestGetStyleSpacingRecursionAndBaseLines:
- """Cover lines 117-118, 122: _get_style_spacing recursion and base_lines paths"""
-
- def test_recursion_returns_base_value(self):
- """当前样式 Lines 有效(非0)时返回自身值,不回退基样式。"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_base = MagicMock()
- base_elem = OxmlElement("w:style")
- base_pPr = OxmlElement("w:pPr")
- base_spacing = OxmlElement("w:spacing")
- base_spacing.set(qn("w:beforeLines"), "100")
- base_pPr.append(base_spacing)
- base_elem.append(base_pPr)
- mock_base.element = base_elem
- mock_base.base_style = None
-
- # 子样式显式设置 beforeLines=200,应使用自身值
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "200")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- mock_style.base_style = mock_base
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 2.0 # 自身值,不回退基样式
-
- def test_explicit_zero_not_falls_to_base(self):
- """显式 Lines=0 返回 0.0,不回退基样式。"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_base = MagicMock()
- base_elem = OxmlElement("w:style")
- base_pPr = OxmlElement("w:pPr")
- base_spacing = OxmlElement("w:spacing")
- base_spacing.set(qn("w:beforeLines"), "100")
- base_pPr.append(base_spacing)
- base_elem.append(base_pPr)
- mock_base.element = base_elem
- mock_base.base_style = None
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "0")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- mock_style.base_style = mock_base
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 0.0 # 显式 0,不回退
-
- def test_recursion_base_style_attr_error(self):
- """Recursion: base_style raises AttributeError (line 117-118)"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "0")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- del mock_style.base_style
-
- result = _get_style_spacing(mock_style, "before")
- # 修复:显式 0 不再回退,返回 0.0
- assert result == 0.0
-
- def test_returns_base_lines_when_zero(self):
- """显式 Lines=0 返回 0.0,不回退基样式。"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_base = MagicMock()
- base_elem = OxmlElement("w:style")
- base_pPr = OxmlElement("w:pPr")
- base_spacing = OxmlElement("w:spacing")
- base_spacing.set(qn("w:beforeLines"), "50")
- base_pPr.append(base_spacing)
- base_elem.append(base_pPr)
- mock_base.element = base_elem
- mock_base.base_style = None
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "0")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- mock_style.base_style = mock_base
-
- result = _get_style_spacing(mock_style, "before")
- assert result == 0.0 # 显式 0
-
- def test_returns_base_lines_when_negative(self):
- """显式负值返回自身,不回退基样式。"""
- from docx.oxml import OxmlElement
- from docx.oxml.ns import qn
-
- mock_base = MagicMock()
- base_elem = OxmlElement("w:style")
- base_pPr = OxmlElement("w:pPr")
- base_spacing = OxmlElement("w:spacing")
- base_spacing.set(qn("w:beforeLines"), "200")
- base_pPr.append(base_spacing)
- base_elem.append(base_pPr)
- mock_base.element = base_elem
- mock_base.base_style = None
-
- mock_style = MagicMock()
- elem = OxmlElement("w:style")
- pPr = OxmlElement("w:pPr")
- spacing = OxmlElement("w:spacing")
- spacing.set(qn("w:beforeLines"), "-50")
- pPr.append(spacing)
- elem.append(pPr)
- mock_style.element = elem
- mock_style.base_style = mock_base
-
- result = _get_style_spacing(mock_style, "before")
- assert result == -0.5 # 显式负值
-
-
-class TestParagraphGetSpaceBeforeWithValidLines:
- """Cover lines 157-158: paragraph_get_space_before with valid beforeLines in XML"""
-
- def test_space_before_valid_lines(self, doc):
- """Valid beforeLines in XML returns correct value (lines 157-158)"""
- 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")
- pPr.append(spacing)
- assert paragraph_get_space_before(p) == 1.0
-
- def test_space_before_valid_lines_rounded(self, doc):
- """Valid beforeLines value is rounded (line 156)"""
- 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")
- pPr.append(spacing)
- 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"""
-
- def test_space_after_valid_lines(self, doc):
- """Valid afterLines in XML returns correct value (lines 190-191)"""
- 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")
- pPr.append(spacing)
- assert paragraph_get_space_after(p) == 1.5
-
- def test_space_after_valid_lines_rounded(self, doc):
- """Valid afterLines value is rounded (line 189)"""
- 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")
- pPr.append(spacing)
- 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"""
-
- def test_font_color_valid_rgb_tuple_access(self, doc):
- """color.rgb returns RGBColor, access via indexing (line 280)"""
- run = doc.add_paragraph().add_run("x")
- run.font.color.rgb = RGBColor(0x12, 0x34, 0x56)
- # RGBColor supports indexing: rgb[0] = 0x12, etc.
- result = run_get_font_color(run)
- assert result == (0x12, 0x34, 0x56)
-
-
-class TestRunGetFontColorFalsyRGB:
- """Cover line 338: run_get_font_color when color.rgb is falsy (empty string)"""
-
- def test_font_color_falsy_rgb(self):
- """color.rgb is a falsy value -> returns (0,0,0) (line 338)"""
- mock_run = MagicMock()
- # Create a mock color where rgb is truthy but then becomes falsy
- mock_color = MagicMock()
- mock_color.rgb = "" # empty string is falsy
- mock_run.font.color = mock_color
- result = run_get_font_color(mock_run)
- assert result == (0, 0, 0)
-
- def test_font_color_zero_rgb(self):
- """color.rgb is 0 (falsy int) -> returns (0,0,0) (line 338)"""
- mock_run = MagicMock()
- mock_color = MagicMock()
- mock_color.rgb = 0 # 0 is falsy
- mock_run.font.color = mock_color
- result = run_get_font_color(mock_run)
- assert result == (0, 0, 0)
-
-
-class TestGetIndentLineIndentWithCharsValues:
- """Cover lines 419-423: GetIndent.line_indent with valid leftChars/rightChars values"""
-
- def test_line_indent_left_chars_valid(self, doc):
- """Valid w:leftChars value returns correct float (lines 419-423)"""
- 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")
- pPr.append(ind)
- assert GetIndent.line_indent(p, "left") == 1.5
-
- def test_line_indent_right_chars_valid(self, doc):
- """Valid w:rightChars value returns correct float"""
- 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")
- pPr.append(ind)
- assert GetIndent.line_indent(p, "right") == 2.5
-
- def test_line_indent_left_chars_zero(self, doc):
- """w:leftChars = 0 -> returns max(0.0, 0.0) = 0.0"""
- 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")
- pPr.append(ind)
- assert GetIndent.line_indent(p, "left") == 0.0
-
- def test_line_indent_exception_returns_none(self, doc):
- """Exception during line_indent -> returns None (lines 421-423)"""
- mock_para = MagicMock()
- mock_para._element.pPr = None
- assert GetIndent.line_indent(mock_para, "left") is None
-
-
-# ===========================================================================
-# Additional coverage tests for check_format.py (CharacterStyle / ParagraphStyle)
-# ===========================================================================
-
-
-class TestCharacterStyleInitFromConfig:
- """Cover line 94: CharacterStyle.__init__ with style_checks_warning from get_config()"""
-
- def test_init_loads_warning_from_config(self, config_path):
- """When style_checks_warning is None, __init__ calls get_config() (line 94)"""
- from wordformat.config.config import init_config, clear_config
- import wordformat.style.check_format as m
-
- # Ensure style_checks_warning is None so __init__ triggers get_config()
- m.style_checks_warning = None
- init_config(config_path)
- try:
- cs = CharacterStyle()
- # After init, style_checks_warning should have been loaded
- assert m.style_checks_warning is not None
- finally:
- clear_config()
- m.style_checks_warning = None
-
-
-class TestCharacterStyleDiffFontColor:
- """Cover line 157: CharacterStyle.diff_from_run font_color diff"""
-
- def test_diff_font_color_mismatch(self, doc, mock_warning):
- """font_color != current_color triggers diff (line 157)"""
- _set_warning(mock_warning)
- cs = CharacterStyle(font_color="红色")
- run = doc.add_paragraph().add_run("t")
- # Default color is black (0,0,0), red is (255,0,0)
- types = [d.diff_type for d in cs.diff_from_run(run)]
- assert "font_color" in types
- _clear_warning()
-
-
-class TestCharacterStyleApplyToRunItalic:
- """Cover lines 207-208: CharacterStyle.apply_to_run 'italic' case"""
-
- def test_apply_to_run_fixes_italic(self, doc, mock_warning):
- """italic mismatch: run.italic=False, expected=True (lines 207-208)"""
- _set_warning(mock_warning)
- cs = CharacterStyle(italic=True)
- p = doc.add_paragraph()
- run = p.add_run("test")
- run.italic = False # Wrong - should be True
- result = cs.apply_to_run(run)
- assert len(result) > 0
- assert run.italic is True
- assert any(d.diff_type == "italic" for d in result)
- _clear_warning()
-
-
-class TestCharacterStyleApplyToRunUnderline:
- """Cover lines 212-213: CharacterStyle.apply_to_run 'underline' case"""
-
- def test_apply_to_run_fixes_underline(self, doc, mock_warning):
- """underline mismatch: run.underline=False, expected=True (lines 212-213)"""
- _set_warning(mock_warning)
- cs = CharacterStyle(underline=True)
- p = doc.add_paragraph()
- run = p.add_run("test")
- run.underline = False # Wrong
- result = cs.apply_to_run(run)
- assert len(result) > 0
- assert run.underline is True
- assert any(d.diff_type == "underline" for d in result)
- _clear_warning()
-
-
-class TestCharacterStyleApplyToRunFontColor:
- """Cover lines 218-219: CharacterStyle.apply_to_run 'font_color' case"""
-
- def test_apply_to_run_fixes_font_color(self, doc, mock_warning):
- """font_color mismatch triggers format (lines 218-219)"""
- _set_warning(mock_warning)
- cs = CharacterStyle(font_color="红色")
- p = doc.add_paragraph()
- run = p.add_run("test")
- # Default is black, so red should trigger a fix
- result = cs.apply_to_run(run)
- assert len(result) > 0
- assert any(d.diff_type == "font_color" for d in result)
- _clear_warning()
-
-
-class TestCharacterStyleApplyToRunFontNameCn:
- """Cover lines 226-227: CharacterStyle.apply_to_run 'font_name_cn' case"""
-
- def test_apply_to_run_fixes_font_name_cn(self, doc, mock_warning):
- """font_name_cn mismatch triggers format (lines 226-227)"""
- _set_warning(mock_warning)
- cs = CharacterStyle(font_name_cn="黑体")
- p = doc.add_paragraph()
- run = p.add_run("测试")
- # Default CN font is 宋体, so 黑体 should trigger a fix
- run_set_font_name(run, "宋体")
- result = cs.apply_to_run(run)
- assert len(result) > 0
- assert any(d.diff_type == "font_name_cn" for d in result)
- _clear_warning()
-
-
-class TestCharacterStyleToStringNone:
- """Cover line 244: CharacterStyle.to_string with style_checks_warning is None"""
-
- def test_to_string_warning_none(self):
- """style_checks_warning=None 时返回所有 diff 的标准格式文本。"""
- import wordformat.style.check_format as m
- m.style_checks_warning = None
- diffs = [
- DIFFResult(diff_type="bold", current_value=True, expected_value=False),
- DIFFResult(diff_type="italic", current_value=True, expected_value=False),
- ]
- result = CharacterStyle.to_string(diffs, target="测试")
- assert "加粗错误" in result
- assert "斜体错误" in result
- assert "测试" in result
-
-
-class TestCharacterStyleToStringBoldFilter:
- """Cover line 250: CharacterStyle.to_string with style_checks_warning.bold = True"""
-
- def test_to_string_bold_filtered_in(self, mock_warning):
- """warning.bold=True 时 bold diff 被包含。"""
- _set_warning(mock_warning)
- diffs = [DIFFResult(diff_type="bold", current_value=True, expected_value=False)]
- result = CharacterStyle.to_string(diffs, target="测试")
- assert "加粗错误" in result
- _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)]
- result = CharacterStyle.to_string(diffs)
- assert "斜体错误" in result
- _clear_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)]
- result = CharacterStyle.to_string(diffs)
- assert "字号错误" in result
- _clear_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="黑色")]
- result = CharacterStyle.to_string(diffs)
- assert "字体颜色错误" in result
- _clear_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="黑体")]
- result = CharacterStyle.to_string(diffs)
- assert "中文字体错误" in result
- _clear_warning()
-
-
-class TestParagraphStyleToStringNone:
- """Cover line 478: ParagraphStyle.to_string with style_checks_warning is None"""
-
- def test_to_string_warning_none(self):
- """style_checks_warning=None 时返回所有 diff 的标准格式文本。"""
- import wordformat.style.check_format as m
- m.style_checks_warning = None
- diffs = [
- 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/test_style_defs.py b/tests/test_style_defs.py
new file mode 100644
index 0000000..1ebc886
--- /dev/null
+++ b/tests/test_style_defs.py
@@ -0,0 +1,773 @@
+#!/usr/bin/env python
+"""
+comprehensive tests for style modules:
+ check_format, get_some, set_some, style_enum, utils
+"""
+
+import pytest
+from types import SimpleNamespace
+from unittest.mock import MagicMock, PropertyMock, patch as mock_patch
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.shared import Pt, RGBColor
+from docx.oxml.ns import qn
+
+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, _get_style_spacing,
+)
+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,
+)
+from wordformat.style.defs import (
+ FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
+ FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
+ Spacing, UnitLabelEnum,
+)
+from wordformat.style.units import extract_unit_from_string, UnitResult
+
+
+# ---------------------------------------------------------------------------
+# fixtures & helpers
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def doc():
+ return Document()
+
+
+@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"):
+ setattr(w, attr, True)
+ return w
+
+
+@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"):
+ 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)
+
+
+# ===========================================================================
+# style_enum - FontSize
+# ===========================================================================
+
+class TestFontSize:
+ @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
+
+ def test_bare_number_rel_value_is_string(self):
+ """No unit -> extract_unit fails -> rel_value stays as raw string."""
+ assert FontSize("15").rel_value == "15"
+
+ def test_base_set(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ FontSize("小四").base_set(run)
+ assert run.font.size.pt == 12
+
+ def test_base_set_invalid_raises(self, doc):
+ with pytest.raises(ValueError, match="无效的字号"):
+ 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)),
+ ])
+ def test_parse_color(self, spec, expected):
+ assert FontColor(spec).rel_value == expected
+
+ def test_eq_tuple(self):
+ assert FontColor("red") == (255, 0, 0)
+ assert FontColor("red") != (0, 0, 0)
+
+ def test_eq_string(self):
+ """字符串比较应与解析后的RGB比较"""
+ assert FontColor("red") == "red"
+ assert FontColor("red") != "blue"
+
+ def test_base_set(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ FontColor("red").base_set(run)
+ assert run.font.color.rgb == RGBColor(255, 0, 0)
+
+ def test_invalid_raises(self):
+ with pytest.raises(ValueError):
+ FontColor("not_a_color").rel_value
+
+ 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)])
+ def test_is_hex(self, h, ok):
+ assert FontColor._is_hex(h) == ok
+
+ def test_normalize_hex(self):
+ assert FontColor._normalize_hex("#f00") == "#ff0000"
+ 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),
+ ])
+ def test_label_map(self, label, val):
+ assert Alignment(label).rel_value == val
+
+ def test_base_set_and_get(self, doc):
+ p = doc.add_paragraph()
+ Alignment("居中对齐").base_set(p)
+ assert p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
+ 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
+
+ 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),
+ ])
+ 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
+
+ def test_base_set(self, doc):
+ p = doc.add_paragraph()
+ LineSpacing("1.5倍").base_set(p)
+ assert p.paragraph_format.line_spacing == 1.5
+
+ def test_leq_zero_silently_changes_to_1(self, doc):
+ """<=0 现在抛出 ValueError 而非静默改为 1。"""
+ p = doc.add_paragraph()
+ with pytest.raises(ValueError):
+ LineSpacing("0倍").base_set(p)
+
+ def test_zero_should_raise(self, doc):
+ with pytest.raises(ValueError):
+ LineSpacing("0倍").base_set(doc.add_paragraph())
+
+
+
+class TestBuiltInStyleEnum:
+ @pytest.mark.parametrize("label,expected", [("正文", "Normal"), ("标题", "Title"), ("Heading 1", "Heading 1")])
+ def test_label_map(self, label, expected):
+ assert BuiltInStyle(label).rel_value == expected
+
+ def test_get_from_paragraph(self, doc):
+ assert BuiltInStyle("正文").get_from_paragraph(doc.add_paragraph()) == "normal"
+
+ def test_base_set_valid(self, doc):
+ p = doc.add_paragraph()
+ BuiltInStyle("正文").base_set(p)
+ assert p.style.name == "Normal"
+
+ def test_base_set_invalid_raises(self, doc):
+ """NonExistent 样式会被 _ensure_style_exists 自动创建,不再抛异常"""
+ p = doc.add_paragraph()
+ BuiltInStyle("NonExistent").base_set(p)
+ assert p.style.name == "NonExistent"
+
+
+
+class TestFontNameEnum:
+ @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
+
+ def test_base_set_chinese(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ FontName("宋体").base_set(run)
+ assert run_get_font_name(run) == "宋体"
+
+ def test_base_set_english(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ FontName("Arial").base_set(run)
+ assert run.font.name == "Arial"
+
+
+
+class TestUnitLabelEnumEq:
+ def test_same_class_same_rel(self):
+ assert FontSize("小四") == FontSize("小四")
+
+ def test_same_class_diff_rel(self):
+ assert FontSize("小四") != FontSize("三号")
+
+ def test_string_rel_eq(self):
+ assert FontName("宋体") == "宋体"
+
+ def test_numeric_rel_eq(self):
+ assert FontSize("小四") == 12
+
+ 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"),
+ ])
+ 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
+
+ def test_invalid(self):
+ r = extract_unit_from_string("no_unit")
+ assert not r.is_valid and r.value is None
+
+ 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)
+ 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),
+ ])
+ def test_convert_to_emu(self, unit, val, 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
+
+ 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
+
+ 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", "倍"),
+ ])
+ def test_unit_ch(self, unit, ch):
+ assert UnitResult(standard_unit=unit).unit_ch == ch
+
+ def test_unit_ch_none(self):
+ assert UnitResult(standard_unit=None).unit_ch == ""
+
+ def test_unit_ch_invalid_raises(self):
+ with pytest.raises(ValueError, match="Invalid unit"):
+ UnitResult(standard_unit="xyz").unit_ch
+
+
+
+# ===========================================================================
+# Coverage: style_enum.py uncovered lines
+# ===========================================================================
+
+
+class TestUnitLabelEnumRelValueSetter:
+ """Cover line 122: rel_value.setter"""
+
+ def test_rel_value_setter(self):
+ """Setting rel_value directly via setter"""
+ e = FontSize("小四")
+ assert e.rel_value == 12
+ e.rel_value = 99
+ 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):
+ def get_from_paragraph(self, paragraph):
+ return None
+
+ e = MinimalEnum("test")
+ # Calling base_set should just log and not crash
+ e.base_set(doc.add_paragraph())
+
+
+
+class TestUnitLabelEnumFormatWithRun:
+ """Cover lines 152-155: format() method with Run object (not Paragraph)"""
+
+ def test_format_with_run(self, doc):
+ """format() dispatches to fun(run=...) when docx_obj is a Run"""
+ run = doc.add_paragraph().add_run("x")
+ FontSize("小四").format(run)
+ assert run.font.size.pt == 12
+
+ def test_format_with_paragraph(self, doc):
+ """format() dispatches to fun(paragraph=...) when docx_obj is a Paragraph"""
+ p = doc.add_paragraph()
+ Alignment("居中对齐").format(p)
+ assert p.paragraph_format.alignment == WD_ALIGN_PARAGRAPH.CENTER
+
+ def test_format_no_fun_falls_to_base_set(self, doc):
+ """format() with no matching function_map falls back to base_set"""
+ run = doc.add_paragraph().add_run("x")
+ FontName("宋体").format(run)
+ 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
+
+ e = MinimalEnum("test")
+ with pytest.raises(NotImplementedError):
+ e.get_from_paragraph(doc.add_paragraph())
+
+
+
+class TestFontColorParseColorValueError:
+ """Cover lines 304-305: FontColor._parse_color ValueError for invalid hex"""
+
+ def test_parse_color_invalid_hex_raises(self):
+ """Hex that passes _is_hex but fails webcolors.hex_to_rgb"""
+ # _is_hex checks regex, but webcolors might still fail
+ # A valid hex format that webcolors can't parse is unlikely since all valid hex are valid colors
+ # But we can test the ValueError path by using a hex that causes webcolors to raise
+ # 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:
+ with pytest.raises(ValueError, match="非法十六进制色值"):
+ FontColor("#FF0000").rel_value
+ finally:
+ webcolors.hex_to_rgb = original
+
+
+
+class TestFontColorBaseSetNonString:
+ """Cover line 330: FontColor.base_set with non-string value raises TypeError"""
+
+ def test_base_set_non_string_raises(self, doc):
+ """base_set with non-string value raises TypeError"""
+ run = doc.add_paragraph().add_run("x")
+ fc = FontColor.__new__(FontColor)
+ fc.value = 123 # non-string
+ fc._rel_value = None
+ fc.original_unit = None
+ fc.unit_ch = None
+ fc._rel_unit = None
+ fc.extract_unit_result = None
+ with pytest.raises(TypeError, match="颜色标识仅支持字符串"):
+ 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
+
+ def test_eq_wrong_length_tuple_returns_false(self):
+ """__eq__ with tuple of wrong length returns False (line 339-340)"""
+ fc = FontColor("red")
+ assert fc != (255, 0)
+ assert fc != (255, 0, 0, 0)
+
+ def test_eq_parse_error_returns_false(self):
+ """__eq__ when rel_value raises -> returns False (lines 344-345)"""
+ fc = FontColor.__new__(FontColor)
+ fc.value = 12345 # will cause _parse_color to raise TypeError
+ fc._rel_value = None
+ fc.original_unit = None
+ fc.unit_ch = None
+ fc._rel_unit = None
+ fc.extract_unit_result = None
+ # __eq__ catches TypeError/ValueError from rel_value
+ 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"):
+ 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"),
+ ])
+ 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()
+ p.paragraph_format.space_before = Pt(val) if unit == "pt" else None
+ 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":
+ p.paragraph_format.space_before = Emu(360000 * val)
+ elif unit == "inch":
+ p.paragraph_format.space_before = Emu(914400 * val)
+
+ sb = SpaceBefore(f"{val}{unit}")
+ result = sb.get_from_paragraph(p)
+ assert result is not None
+ # Result should be close to val in the specified unit
+ assert abs(result - val) < 0.01
+
+ def test_get_from_paragraph_hang_unit(self, doc):
+ """SpaceBefore.get_from_paragraph with 'hang' unit uses paragraph_get_space_before"""
+ p = doc.add_paragraph()
+ set_paragraph_space_before_by_lines(p, 0.5)
+ sb = SpaceBefore("0.5行")
+ result = sb.get_from_paragraph(p)
+ assert result == 0.5
+
+ def test_get_from_paragraph_none(self, doc):
+ """SpaceBefore.get_from_paragraph returns None when no spacing set"""
+ sb = SpaceBefore("12pt")
+ result = sb.get_from_paragraph(doc.add_paragraph())
+ 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),
+ ])
+ 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":
+ p.paragraph_format.space_after = Emu(36000 * val)
+ elif unit == "cm":
+ p.paragraph_format.space_after = Emu(360000 * val)
+ elif unit == "inch":
+ p.paragraph_format.space_after = Emu(914400 * val)
+
+ sa = SpaceAfter(f"{val}{unit}")
+ result = sa.get_from_paragraph(p)
+ assert result is not None
+ assert abs(result - val) < 0.01
+
+ def test_get_from_paragraph_hang_unit(self, doc):
+ """SpaceAfter.get_from_paragraph with 'hang' unit uses paragraph_get_space_after"""
+ p = doc.add_paragraph()
+ set_paragraph_space_after_by_lines(p, 1.0)
+ sa = SpaceAfter("1行")
+ result = sa.get_from_paragraph(p)
+ assert result == 1.0
+
+ def test_get_from_paragraph_none(self, doc):
+ """SpaceAfter.get_from_paragraph returns None when no spacing set"""
+ sa = SpaceAfter("12pt")
+ result = sa.get_from_paragraph(doc.add_paragraph())
+ assert result is None
+
+
+
+class TestLineSpacingRuleBaseSetInvalid:
+ """Cover lines 447-451: LineSpacingRule.base_set with invalid value raises ValueError"""
+
+ def test_base_set_invalid_raises(self, doc):
+ """base_set with value not in _LABEL_MAP raises ValueError"""
+ p = doc.add_paragraph()
+ lsr = LineSpacingRule.__new__(LineSpacingRule)
+ lsr.value = "无效行距"
+ lsr._rel_value = None
+ lsr.original_unit = None
+ lsr.unit_ch = None
+ lsr._rel_unit = None
+ lsr.extract_unit_result = None
+ with pytest.raises(ValueError, match="无效的行距选项"):
+ lsr.base_set(p)
+
+
+
+class TestLineSpacingBaseSetInvalidValue:
+ """Cover line 489: LineSpacing.base_set with invalid value raises ValueError"""
+
+ def test_base_set_non_numeric_raises(self, doc):
+ """base_set with non-numeric rel_value raises ValueError"""
+ p = doc.add_paragraph()
+ ls = LineSpacing.__new__(LineSpacing)
+ ls.value = "abc"
+ ls._rel_value = "abc" # not int or float
+ ls.original_unit = None
+ ls.unit_ch = None
+ ls._rel_unit = None
+ ls.extract_unit_result = None
+ with pytest.raises(ValueError, match="无效的行距"):
+ ls.base_set(p)
+
+ def test_base_set_none_raises(self, doc):
+ """base_set with None rel_value raises ValueError"""
+ p = doc.add_paragraph()
+ ls = LineSpacing.__new__(LineSpacing)
+ ls.value = None
+ ls._rel_value = None
+ ls.original_unit = None
+ ls.unit_ch = None
+ ls._rel_unit = None
+ ls.extract_unit_result = None
+ with pytest.raises(ValueError, match="无效的行距"):
+ 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),
+ ])
+ 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":
+ p.paragraph_format.left_indent = Emu(36000 * val)
+ elif unit == "cm":
+ p.paragraph_format.left_indent = Emu(360000 * val)
+ elif unit == "inch":
+ p.paragraph_format.left_indent = Emu(914400 * val)
+
+ li = LeftIndent(f"{val}{unit}")
+ result = li.get_from_paragraph(p)
+ assert result is not None
+ assert abs(result - val) < 0.01
+
+ def test_get_from_paragraph_none(self, doc):
+ """LeftIndent.get_from_paragraph returns None when no indent set"""
+ li = LeftIndent("12pt")
+ result = li.get_from_paragraph(doc.add_paragraph())
+ 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),
+ ])
+ 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":
+ p.paragraph_format.right_indent = Emu(36000 * val)
+ elif unit == "cm":
+ p.paragraph_format.right_indent = Emu(360000 * val)
+ elif unit == "inch":
+ p.paragraph_format.right_indent = Emu(914400 * val)
+
+ ri = RightIndent(f"{val}{unit}")
+ result = ri.get_from_paragraph(p)
+ assert result is not None
+ assert abs(result - val) < 0.01
+
+ def test_get_from_paragraph_none(self, doc):
+ """RightIndent.get_from_paragraph returns None when no indent set"""
+ ri = RightIndent("12pt")
+ result = ri.get_from_paragraph(doc.add_paragraph())
+ 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),
+ ])
+ 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":
+ p.paragraph_format.first_line_indent = Emu(36000 * val)
+ elif unit == "cm":
+ p.paragraph_format.first_line_indent = Emu(360000 * val)
+ elif unit == "inch":
+ p.paragraph_format.first_line_indent = Emu(914400 * val)
+
+ fli = FirstLineIndent(f"{val}{unit}")
+ result = fli.get_from_paragraph(p)
+ assert result is not None
+ assert abs(result - val) < 0.01
+
+ def test_get_from_paragraph_none(self, doc):
+ """FirstLineIndent.get_from_paragraph returns None when no indent set"""
+ fli = FirstLineIndent("12pt")
+ result = fli.get_from_paragraph(doc.add_paragraph())
+ 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"""
+
+ 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)"""
+ p = doc.add_paragraph()
+ # "Heading 1" is a valid style name but also in _LABEL_MAP, so use a valid
+ # style name that is NOT in _LABEL_MAP. "Normal" is in _LABEL_MAP via "正文".
+ # We need a style name that exists in docx but not in _LABEL_MAP.
+ # Let's use a direct style name like "Heading 5" which exists in docx but not in map.
+ bis = BuiltInStyle.__new__(BuiltInStyle)
+ bis.value = "Heading 1" # This IS in _LABEL_MAP, so it hits the if branch
+ bis._rel_value = None
+ bis.original_unit = None
+ bis.unit_ch = None
+ bis._rel_unit = None
+ bis.extract_unit_result = None
+ # This hits the if branch (line 591-595)
+ bis.base_set(p)
+ assert p.style.name == "Heading 1"
+
+ def test_base_set_else_branch_not_in_map(self, doc):
+ """base_set with value NOT in _LABEL_MAP hits else branch (lines 596-600)"""
+ p = doc.add_paragraph()
+ bis = BuiltInStyle.__new__(BuiltInStyle)
+ bis.value = "Normal" # "Normal" is NOT a key in _LABEL_MAP (only "正文" is)
+ bis._rel_value = None
+ bis.original_unit = None
+ bis.unit_ch = None
+ bis._rel_unit = None
+ bis.extract_unit_result = None
+ # "Normal" is not in _LABEL_MAP keys, so it hits else branch (line 596)
+ # But "Normal" is a valid style, so docx_obj.style = self.value succeeds
+ bis.base_set(p)
+ assert p.style.name == "Normal"
+
+ def test_base_set_else_branch_invalid_style_raises(self, doc):
+ """base_set with value NOT in _LABEL_MAP: _ensure_style_exists 会自动创建样式"""
+ p = doc.add_paragraph()
+ bis = BuiltInStyle.__new__(BuiltInStyle)
+ bis.value = "CompletelyInvalidStyleName"
+ bis._rel_value = None
+ bis.original_unit = None
+ bis.unit_ch = None
+ bis._rel_unit = None
+ bis.extract_unit_result = None
+ # _ensure_style_exists 会自动创建样式,不再抛异常
+ bis.base_set(p)
+ assert p.style.name == "CompletelyInvalidStyleName"
+
+ def test_base_set_if_branch_invalid_style_raises(self, doc):
+ """base_set with value in _LABEL_MAP but invalid style raises ValueError (lines 594-595)"""
+ p = doc.add_paragraph()
+ bis = BuiltInStyle.__new__(BuiltInStyle)
+ # Use a value that IS in _LABEL_MAP but maps to an invalid style
+ bis.value = "正文" # maps to "Normal" which IS valid, so this won't raise
+ bis._rel_value = None
+ bis.original_unit = None
+ bis.unit_ch = None
+ bis._rel_unit = None
+ bis.extract_unit_result = None
+ # This should succeed since Normal is a valid style
+ bis.base_set(p)
+ assert p.style.name == "Normal"
+
diff --git a/tests/test_style_diff.py b/tests/test_style_diff.py
new file mode 100644
index 0000000..7884967
--- /dev/null
+++ b/tests/test_style_diff.py
@@ -0,0 +1,525 @@
+#!/usr/bin/env python
+"""
+comprehensive tests for style modules:
+ check_format, get_some, set_some, style_enum, utils
+"""
+
+import pytest
+from types import SimpleNamespace
+from unittest.mock import MagicMock, PropertyMock, patch as mock_patch
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.shared import Pt, RGBColor
+from docx.oxml.ns import qn
+
+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, _get_style_spacing,
+)
+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,
+)
+from wordformat.style.defs import (
+ FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
+ FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
+ Spacing, UnitLabelEnum,
+)
+from wordformat.style.units import extract_unit_from_string, UnitResult
+
+
+# ---------------------------------------------------------------------------
+# fixtures & helpers
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def doc():
+ return Document()
+
+
+@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"):
+ setattr(w, attr, True)
+ return w
+
+
+@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"):
+ 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)
+
+
+# ===========================================================================
+# DIFFResult
+# ===========================================================================
+
+class TestDIFFResult:
+ def test_defaults_and_str(self):
+ d = DIFFResult()
+ assert d.diff_type is d.expected_value is d.current_value is d.comment is None
+ assert d.level == 0
+ d2 = DIFFResult(comment="hi")
+ assert str(d2) == "hi"
+
+ @pytest.mark.parametrize("lvl", [0, 1, 2, 3])
+ def test_level(self, lvl):
+ assert DIFFResult(level=lvl).level == lvl
+
+
+
+# ===========================================================================
+# CharacterStyle
+# ===========================================================================
+
+class TestCharacterStyle:
+ def test_defaults(self, mock_warning):
+ _set_warning(mock_warning)
+ cs = CharacterStyle()
+ assert cs.font_name_cn.value == "宋体"
+ assert cs.font_name_en.value == "Times New Roman"
+ assert cs.font_size.value == "小四"
+ assert cs.bold is cs.italic is cs.underline is False
+ _clear_warning()
+
+ def test_diff_from_run_no_diff_when_matching(self, doc, mock_warning):
+ _set_warning(mock_warning)
+ cs = CharacterStyle()
+ run = doc.add_paragraph().add_run("t")
+ run.font.bold = run.font.italic = run.font.underline = False
+ run.font.size = Pt(12)
+ 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
+ _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):
+ _set_warning(mock_warning)
+ cs = CharacterStyle(**{prop: expected_val})
+ run = doc.add_paragraph().add_run("t")
+ setattr(run.font, prop, current_val)
+ types = [d.diff_type for d in cs.diff_from_run(run)]
+ assert prop in types
+ _clear_warning()
+
+ def test_diff_font_size_and_name_cn(self, doc, mock_warning):
+ _set_warning(mock_warning)
+ cs = CharacterStyle(font_size="小四", font_name_cn="宋体")
+ run = doc.add_paragraph().add_run("测试")
+ run.font.size = Pt(14)
+ 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 == "黑体"
+ _clear_warning()
+
+ def test_apply_to_run_fixes_bold(self, doc, mock_warning):
+ _set_warning(mock_warning)
+ run = doc.add_paragraph().add_run("t")
+ run.font.bold = False
+ result = CharacterStyle(bold=True).apply_to_run(run)
+ assert run.font.bold is True
+ assert any(d.diff_type == "bold" for d in result)
+ _clear_warning()
+
+ def test_to_string_filters_by_warning(self, mock_warning, mock_warning_off):
+ diffs = [DIFFResult(diff_type="bold", current_value=True, expected_value=False)]
+ _set_warning(mock_warning)
+ assert "加粗错误" in CharacterStyle.to_string(diffs)
+ _set_warning(mock_warning_off)
+ assert CharacterStyle.to_string(diffs) == ""
+ _clear_warning()
+
+
+
+# ===========================================================================
+# ParagraphStyle
+# ===========================================================================
+
+class TestParagraphStyle:
+ def test_defaults(self, mock_warning):
+ _set_warning(mock_warning)
+ ps = ParagraphStyle()
+ assert ps.alignment.value == "左对齐"
+ assert ps.space_before.value == "0.5行"
+ assert ps.line_spacing.value == "1.5倍"
+ _clear_warning()
+
+ def test_diff_none_returns_empty(self, mock_warning):
+ _set_warning(mock_warning)
+ assert ParagraphStyle().diff_from_paragraph(None) == []
+ _clear_warning()
+
+ 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)]
+ _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)]
+ assert "builtin_style_name" not in types # 英文"normal" = 中文"正文"
+ _clear_warning()
+
+ def test_to_string_line_spacing_rule_key_bug(self, doc, mock_warning):
+ """BUG: diff_type='line_spacing_rule' but warning dict key='line_spacingrule'."""
+ _set_warning(mock_warning)
+ p = doc.add_paragraph()
+ p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+ diffs = ParagraphStyle(line_spacingrule="单倍行距").diff_from_paragraph(p)
+ assert "line_spacing_rule" in [d.diff_type for d in diffs]
+ result = ParagraphStyle.to_string(diffs)
+ assert "行距选项" not in result # filtered out due to key mismatch
+ _clear_warning()
+
+ def test_from_config(self, mock_warning):
+ _set_warning(mock_warning)
+ cfg = SimpleNamespace(alignment="居中对齐", space_before="1行")
+ ps = ParagraphStyle.from_config(cfg)
+ assert ps.alignment.value == "居中对齐"
+ assert ps.space_after.value == "0.5行" # default
+ _clear_warning()
+
+
+
+# ===========================================================================
+# Additional coverage tests for check_format.py
+# ===========================================================================
+
+
+class TestDIFFResultWarningLevel:
+ """Cover line 94: DIFFResult with level='warning' (string)"""
+
+ def test_level_string(self):
+ d = DIFFResult(level="warning")
+ assert d.level == "warning"
+
+
+
+class TestParagraphStyleApplyToParagraph:
+ """Cover lines 315-358: ParagraphStyle.apply_to_paragraph with various indent types"""
+
+ def test_apply_left_indent(self, doc, mock_warning):
+ """apply_to_paragraph with left_indent diff (line 337-338)"""
+ _set_warning(mock_warning)
+ p = doc.add_paragraph()
+ ps = ParagraphStyle(left_indent="2字符")
+ diffs = ps.apply_to_paragraph(p)
+ assert any(d.diff_type == "left_indent" for d in diffs)
+ _clear_warning()
+
+ def test_apply_right_indent(self, doc, mock_warning):
+ """apply_to_paragraph with right_indent diff (line 339-341)"""
+ _set_warning(mock_warning)
+ p = doc.add_paragraph()
+ ps = ParagraphStyle(right_indent="2字符")
+ diffs = ps.apply_to_paragraph(p)
+ assert any(d.diff_type == "right_indent" for d in diffs)
+ _clear_warning()
+
+ def test_apply_first_line_indent(self, doc, mock_warning):
+ """apply_to_paragraph with first_line_indent diff (line 342-344)"""
+ _set_warning(mock_warning)
+ p = doc.add_paragraph()
+ ps = ParagraphStyle(first_line_indent="2字符")
+ diffs = ps.apply_to_paragraph(p)
+ assert any(d.diff_type == "first_line_indent" for d in diffs)
+ _clear_warning()
+
+ def test_apply_unknown_diff_type_skipped(self, doc, mock_warning):
+ """apply_to_paragraph with unknown diff_type -> logged and skipped (line 348-353)"""
+ _set_warning(mock_warning)
+ p = doc.add_paragraph()
+ 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
+ assert len(result) == 0
+ ps.diff_from_paragraph = original_diff
+ _clear_warning()
+
+ def test_apply_builtin_style_name(self, doc, mock_warning):
+ """apply_to_paragraph with builtin_style_name diff (line 345-347)"""
+ _set_warning(mock_warning)
+ p = doc.add_paragraph()
+ ps = ParagraphStyle(builtin_style_name="Heading 1")
+ diffs = ps.apply_to_paragraph(p)
+ assert any(d.diff_type == "builtin_style_name" for d in diffs)
+ _clear_warning()
+
+
+
+class TestParagraphStyleFromConfigExtended:
+ """Cover line 478: ParagraphStyle.from_config with real config"""
+
+ def test_from_config_all_fields(self, mock_warning):
+ """from_config with all fields specified (line 478+)"""
+ _set_warning(mock_warning)
+ cfg = SimpleNamespace(
+ alignment="居中对齐",
+ space_before="1行",
+ space_after="0.5行",
+ line_spacing="2倍",
+ line_spacingrule="2倍行距",
+ first_line_indent="2字符",
+ left_indent="1字符",
+ right_indent="1字符",
+ builtin_style_name="正文",
+ )
+ ps = ParagraphStyle.from_config(cfg)
+ assert ps.alignment.value == "居中对齐"
+ assert ps.space_before.value == "1行"
+ assert ps.space_after.value == "0.5行"
+ assert ps.line_spacing == 2.0
+ assert ps.line_spacingrule.value == "2倍行距"
+ assert ps.first_line_indent.value == "2字符"
+ assert ps.left_indent.value == "1字符"
+ assert ps.right_indent.value == "1字符"
+ assert ps.builtin_style_name.rel_value == "Normal"
+ _clear_warning()
+
+ def test_from_config_partial_fields(self, mock_warning):
+ """from_config with only some fields -> defaults for rest"""
+ _set_warning(mock_warning)
+ cfg = SimpleNamespace(alignment="右对齐")
+ ps = ParagraphStyle.from_config(cfg)
+ assert ps.alignment.value == "右对齐"
+ assert ps.space_before.value == "0.5行" # default
+ assert ps.builtin_style_name.rel_value == "Normal" # default
+ _clear_warning()
+
+
+
+# ===========================================================================
+# Additional coverage tests for check_format.py (CharacterStyle / ParagraphStyle)
+# ===========================================================================
+
+
+class TestCharacterStyleInitFromConfig:
+ """Cover line 94: CharacterStyle.__init__ with style_checks_warning from get_config()"""
+
+ def test_init_loads_warning_from_config(self, config_path):
+ """When style_checks_warning is None, __init__ calls get_config() (line 94)"""
+ from wordformat.config.loader import init_config, clear_config
+ import wordformat.style.diff as m
+
+ # Ensure style_checks_warning is None so __init__ triggers get_config()
+ m.style_checks_warning = None
+ init_config(config_path)
+ try:
+ cs = CharacterStyle()
+ # After init, style_checks_warning should have been loaded
+ assert m.style_checks_warning is not None
+ finally:
+ clear_config()
+ m.style_checks_warning = None
+
+
+
+class TestCharacterStyleDiffFontColor:
+ """Cover line 157: CharacterStyle.diff_from_run font_color diff"""
+
+ def test_diff_font_color_mismatch(self, doc, mock_warning):
+ """font_color != current_color triggers diff (line 157)"""
+ _set_warning(mock_warning)
+ cs = CharacterStyle(font_color="红色")
+ run = doc.add_paragraph().add_run("t")
+ # Default color is black (0,0,0), red is (255,0,0)
+ types = [d.diff_type for d in cs.diff_from_run(run)]
+ assert "font_color" in types
+ _clear_warning()
+
+
+
+class TestCharacterStyleApplyToRunItalic:
+ """Cover lines 207-208: CharacterStyle.apply_to_run 'italic' case"""
+
+ def test_apply_to_run_fixes_italic(self, doc, mock_warning):
+ """italic mismatch: run.italic=False, expected=True (lines 207-208)"""
+ _set_warning(mock_warning)
+ cs = CharacterStyle(italic=True)
+ p = doc.add_paragraph()
+ run = p.add_run("test")
+ run.italic = False # Wrong - should be True
+ result = cs.apply_to_run(run)
+ assert len(result) > 0
+ assert run.italic is True
+ assert any(d.diff_type == "italic" for d in result)
+ _clear_warning()
+
+
+
+class TestCharacterStyleApplyToRunUnderline:
+ """Cover lines 212-213: CharacterStyle.apply_to_run 'underline' case"""
+
+ def test_apply_to_run_fixes_underline(self, doc, mock_warning):
+ """underline mismatch: run.underline=False, expected=True (lines 212-213)"""
+ _set_warning(mock_warning)
+ cs = CharacterStyle(underline=True)
+ p = doc.add_paragraph()
+ run = p.add_run("test")
+ run.underline = False # Wrong
+ result = cs.apply_to_run(run)
+ assert len(result) > 0
+ assert run.underline is True
+ assert any(d.diff_type == "underline" for d in result)
+ _clear_warning()
+
+
+
+class TestCharacterStyleApplyToRunFontColor:
+ """Cover lines 218-219: CharacterStyle.apply_to_run 'font_color' case"""
+
+ def test_apply_to_run_fixes_font_color(self, doc, mock_warning):
+ """font_color mismatch triggers format (lines 218-219)"""
+ _set_warning(mock_warning)
+ cs = CharacterStyle(font_color="红色")
+ p = doc.add_paragraph()
+ run = p.add_run("test")
+ # Default is black, so red should trigger a fix
+ result = cs.apply_to_run(run)
+ assert len(result) > 0
+ assert any(d.diff_type == "font_color" for d in result)
+ _clear_warning()
+
+
+
+class TestCharacterStyleApplyToRunFontNameCn:
+ """Cover lines 226-227: CharacterStyle.apply_to_run 'font_name_cn' case"""
+
+ def test_apply_to_run_fixes_font_name_cn(self, doc, mock_warning):
+ """font_name_cn mismatch triggers format (lines 226-227)"""
+ _set_warning(mock_warning)
+ cs = CharacterStyle(font_name_cn="黑体")
+ p = doc.add_paragraph()
+ run = p.add_run("测试")
+ # Default CN font is 宋体, so 黑体 should trigger a fix
+ run_set_font_name(run, "宋体")
+ result = cs.apply_to_run(run)
+ assert len(result) > 0
+ assert any(d.diff_type == "font_name_cn" for d in result)
+ _clear_warning()
+
+
+
+class TestCharacterStyleToStringNone:
+ """Cover line 244: CharacterStyle.to_string with style_checks_warning is None"""
+
+ def test_to_string_warning_none(self):
+ """style_checks_warning=None 时返回所有 diff 的标准格式文本。"""
+ import wordformat.style.diff as m
+ m.style_checks_warning = None
+ diffs = [
+ DIFFResult(diff_type="bold", current_value=True, expected_value=False),
+ DIFFResult(diff_type="italic", current_value=True, expected_value=False),
+ ]
+ result = CharacterStyle.to_string(diffs, target="测试")
+ assert "加粗错误" in result
+ assert "斜体错误" in result
+ assert "测试" in result
+
+
+
+class TestCharacterStyleToStringBoldFilter:
+ """Cover line 250: CharacterStyle.to_string with style_checks_warning.bold = True"""
+
+ def test_to_string_bold_filtered_in(self, mock_warning):
+ """warning.bold=True 时 bold diff 被包含。"""
+ _set_warning(mock_warning)
+ diffs = [DIFFResult(diff_type="bold", current_value=True, expected_value=False)]
+ result = CharacterStyle.to_string(diffs, target="测试")
+ assert "加粗错误" in result
+ _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)]
+ result = CharacterStyle.to_string(diffs)
+ assert "斜体错误" in result
+ _clear_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)]
+ result = CharacterStyle.to_string(diffs)
+ assert "字号错误" in result
+ _clear_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="黑色")]
+ result = CharacterStyle.to_string(diffs)
+ assert "字体颜色错误" in result
+ _clear_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="黑体")]
+ result = CharacterStyle.to_string(diffs)
+ assert "中文字体错误" in result
+ _clear_warning()
+
+
+
+class TestParagraphStyleToStringNone:
+ """Cover line 478: ParagraphStyle.to_string with style_checks_warning is None"""
+
+ def test_to_string_warning_none(self):
+ """style_checks_warning=None 时返回所有 diff 的标准格式文本。"""
+ import wordformat.style.diff as m
+ m.style_checks_warning = None
+ diffs = [
+ 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/test_style_reader.py b/tests/test_style_reader.py
new file mode 100644
index 0000000..256177c
--- /dev/null
+++ b/tests/test_style_reader.py
@@ -0,0 +1,969 @@
+#!/usr/bin/env python
+"""
+comprehensive tests for style modules:
+ check_format, get_some, set_some, style_enum, utils
+"""
+
+import pytest
+from types import SimpleNamespace
+from unittest.mock import MagicMock, PropertyMock, patch as mock_patch
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.shared import Pt, RGBColor
+from docx.oxml.ns import qn
+
+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, _get_style_spacing,
+)
+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,
+)
+from wordformat.style.defs import (
+ FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
+ FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
+ Spacing, UnitLabelEnum,
+)
+from wordformat.style.units import extract_unit_from_string, UnitResult
+
+
+# ---------------------------------------------------------------------------
+# fixtures & helpers
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def doc():
+ return Document()
+
+
+@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"):
+ setattr(w, attr, True)
+ return w
+
+
+@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"):
+ 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)
+
+
+# ===========================================================================
+# get_some
+# ===========================================================================
+
+class TestGetSomeAlignment:
+ @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
+ assert paragraph_get_alignment(p) == align
+
+ 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),
+ ])
+ def test_preset(self, doc, rule, expected):
+ p = doc.add_paragraph()
+ p.paragraph_format.line_spacing_rule = rule
+ assert paragraph_get_line_spacing(p) == expected
+
+ def test_multiple_custom(self, doc):
+ p = doc.add_paragraph()
+ p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
+ 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])
+ 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
+
+ def test_font_name_after_set(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ run_set_font_name(run, "宋体")
+ assert run_get_font_name(run) == "宋体"
+
+ def test_font_size_default_12(self, doc):
+ assert run_get_font_size_pt(doc.add_paragraph().add_run("x")) == 12.0
+
+ def test_font_size_explicit(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ run.font.size = Pt(14)
+ assert run_get_font_size_pt(run) == 14.0
+
+ def test_font_color_default_black(self, doc):
+ assert run_get_font_color(doc.add_paragraph().add_run("x")) == (0, 0, 0)
+
+ def test_font_color_set(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ 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"),
+ ])
+ def test_boolean_props(self, doc, getter, attr):
+ run = doc.add_paragraph().add_run("x")
+ assert getter(run) is False
+ setattr(run.font, attr, True)
+ assert getter(run) is True
+
+
+
+class TestGetSomeFirstLineIndent:
+ def test_default_none(self, doc):
+ assert paragraph_get_first_line_indent(doc.add_paragraph()) is None
+
+ def test_with_chars(self, doc):
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_char(p, 2)
+ assert paragraph_get_first_line_indent(p) == 2.0
+
+ def test_ignores_firstLine_twips(self, doc):
+ """Only reads firstLineChars, ignores firstLine (physical twips)."""
+ p = doc.add_paragraph()
+ p.paragraph_format.first_line_indent = Pt(24)
+ 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()
+ assert GetIndent.left_indent(p) is None
+ assert GetIndent.right_indent(p) is None
+
+ def test_invalid_type_raises(self, doc):
+ with pytest.raises(ValueError, match="必须是"):
+ GetIndent.line_indent(doc.add_paragraph(), "bad")
+
+ def test_left_after_set_char(self, doc):
+ p = doc.add_paragraph()
+ SetIndent.set_char(p, "R", 3)
+ assert GetIndent.left_indent(p) == 3.0
+
+
+
+class TestGetStyleSpacing:
+ def test_none_style(self):
+ assert _get_style_spacing(None, "before") is None
+
+ def test_no_element(self):
+ s = MagicMock()
+ del s.element
+ assert _get_style_spacing(s, "before") is None
+
+
+
+# ===========================================================================
+# Additional coverage tests for get_some.py
+# ===========================================================================
+
+
+class TestGetSomeAlignmentFromStyle:
+ """Cover line 38: paragraph_get_alignment with style.alignment (not direct)"""
+
+ def test_alignment_from_style(self, doc):
+ """When paragraph has no direct alignment, falls back to style.alignment"""
+ p = doc.add_paragraph()
+ p.paragraph_format.alignment = None
+ # Set alignment on the paragraph's style
+ p.style.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
+ assert paragraph_get_alignment(p) == WD_ALIGN_PARAGRAPH.CENTER
+
+ def test_alignment_from_base_style(self, doc):
+ """Traverse _base_style chain for alignment"""
+ p = doc.add_paragraph()
+ p.paragraph_format.alignment = None
+ # Use patch.object to mock the style property
+ mock_style = MagicMock()
+ mock_style.paragraph_format.alignment = None
+ mock_base = MagicMock()
+ mock_base.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.RIGHT
+ mock_style._base_style = mock_base
+ with mock_patch.object(type(p), 'style', new_callable=PropertyMock, return_value=mock_style):
+ assert paragraph_get_alignment(p) == WD_ALIGN_PARAGRAPH.RIGHT
+
+ def test_alignment_no_base_style_returns_none(self, doc):
+ """No direct alignment, no style alignment, no base_style -> None"""
+ p = doc.add_paragraph()
+ p.paragraph_format.alignment = None
+ p.style.paragraph_format.alignment = None
+ p.style._base_style = None
+ assert paragraph_get_alignment(p) is None
+
+
+
+class TestGetStyleSpacingExtended:
+ """Cover lines 66-69, 80-124: _get_style_spacing with various paths"""
+
+ def test_style_elem_none_falls_to_base(self):
+ """style.element is None -> recurse to base_style (lines 66-69)"""
+ mock_base = MagicMock()
+ mock_base.element = None
+ mock_base.base_style = None
+ mock_style = MagicMock()
+ mock_style.element = None
+ mock_style.base_style = mock_base
+ assert _get_style_spacing(mock_style, "before") is None
+
+ def test_style_pPr_none_recurse_base(self):
+ """style_pPr is None -> recurse to base_style (lines 76-81)"""
+ from docx.oxml import OxmlElement
+ mock_base = MagicMock()
+ mock_base.element = None
+ mock_base.base_style = None
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+ assert _get_style_spacing(mock_style, "before") is None
+
+ def test_spacing_none_recurse_base(self):
+ """spacing element is None -> recurse to base_style (lines 89-94)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+ mock_base = MagicMock()
+ mock_base.element = None
+ mock_base.base_style = None
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ elem.append(pPr)
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+ assert _get_style_spacing(mock_style, "before") is None
+
+ def test_lines_attr_valid(self):
+ """Valid beforeLines attribute -> returns float (lines 97-112)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "50")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ assert _get_style_spacing(mock_style, "before") == 0.5
+
+ def test_lines_attr_zero_returns_zero(self):
+ """显式 Lines=0 应返回 0.0,不回退到基样式。"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+ mock_base = MagicMock()
+ mock_base.element = None
+ mock_base.base_style = None
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "0")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+ assert _get_style_spacing(mock_style, "before") == 0.0
+
+ def test_mock_detection_in_lines_attr(self):
+ """Lines attr is a Mock object -> detect and handle (lines 100-106)"""
+ # We can't set a Mock as an XML attribute, so we mock the entire
+ # _get_style_spacing function's internal behavior by constructing
+ # a mock style whose spacing.get returns a Mock
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ # Set a real value first, then mock the get to return a Mock
+ spacing.set(qn("w:beforeLines"), "100")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+
+ # Patch spacing.get to return a Mock with "Mock" in class name
+ original_get = spacing.get
+ mock_attr = MagicMock(__class__=MagicMock(__name__="Mock"))
+ mock_attr.return_value = "100"
+
+ def mock_get(qname, default=None):
+ if "beforeLines" in qname:
+ return mock_attr
+ return original_get(qname, default)
+
+ spacing.get = mock_get
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 1.0
+
+
+
+class TestGetSomeSpaceBeforeAfterInheritance:
+ """Cover lines 147-148, 157-158: space_before/after with style inheritance"""
+
+ def test_space_before_invalid_attr_returns_none(self, doc):
+ """beforeLines attr is non-numeric -> self_lines=None, fall to style (line 147-148)"""
+ 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")
+ pPr.append(spacing)
+ # style has no spacing either
+ assert paragraph_get_space_before(p) is None
+
+ def test_space_after_invalid_attr_returns_none(self, doc):
+ """afterLines attr is non-numeric -> self_lines=None, fall to style (line 180-181)"""
+ 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")
+ pPr.append(spacing)
+ assert paragraph_get_space_after(p) is None
+
+ def test_space_before_from_style(self, doc):
+ """No direct beforeLines, but style has it -> returns style value"""
+ p = doc.add_paragraph()
+ 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):
+ # _get_style_spacing returns None for None element
+ assert paragraph_get_space_before(p) is None
+
+ def test_space_after_from_style(self, doc):
+ """No direct afterLines, but style has it -> returns style value"""
+ p = doc.add_paragraph()
+ 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):
+ assert paragraph_get_space_after(p) is None
+
+
+
+class TestGetSomeLineSpacingInheritance:
+ """Cover lines 233-235: paragraph_get_line_spacing with style inheritance"""
+
+ def test_line_spacing_attribute_error_returns_none(self, doc):
+ """paragraph with broken paragraph_format -> returns None (lines 233-235)"""
+ mock_para = MagicMock()
+ # paragraph_format raises AttributeError
+ mock_para.paragraph_format = None
+ result = paragraph_get_line_spacing(mock_para)
+ assert result is None
+
+ def test_line_spacing_type_error_returns_none(self):
+ """paragraph with broken paragraph_format -> returns None"""
+ mock_para = MagicMock()
+ mock_para.paragraph_format.line_spacing_rule = "invalid"
+ mock_para.paragraph_format.line_spacing = None
+ result = paragraph_get_line_spacing(mock_para)
+ assert result is None
+
+
+
+class TestGetSomeFirstLineIndentPhysicalUnit:
+ """Cover lines 264-266: paragraph_get_first_line_indent with firstLine (physical unit)"""
+
+ def test_first_line_indent_exception_returns_none(self, doc):
+ """Exception during parsing -> returns None (lines 264-266)"""
+ 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.font.size is None, falls back to style.font.size (line 317)"""
+ run = doc.add_paragraph().add_run("x")
+ run.font.size = None
+ mock_style = MagicMock()
+ mock_style.font.size = Pt(16)
+ with mock_patch.object(type(run._parent), 'style', new_callable=PropertyMock, return_value=mock_style):
+ assert run_get_font_size_pt(run) == 16.0
+
+ def test_font_color_theme_color(self, doc):
+ """run.font.color.rgb is None (theme color) -> returns (0,0,0) (line 338)"""
+ run = doc.add_paragraph().add_run("x")
+ run.font.color.rgb = None
+ assert run_get_font_color(run) == (0, 0, 0)
+
+ def test_font_color_none(self, doc):
+ """run.font.color is None -> returns (0,0,0)"""
+ run = doc.add_paragraph().add_run("x")
+ # color is a read-only property, so we mock the entire run_get_font_color
+ # by directly testing the code path with a MagicMock run
+ mock_run = MagicMock()
+ mock_run.font.color = None
+ assert run_get_font_color(mock_run) == (0, 0, 0)
+
+
+
+class TestGetIndentWithRealElement:
+ """Cover lines 408-423: GetIndent.line_indent with real indent element"""
+
+ def test_line_indent_left_with_chars(self, doc):
+ """Real w:ind element with w:leftChars -> returns float (lines 408-423)"""
+ 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")
+ pPr.append(ind)
+ assert GetIndent.line_indent(p, "left") == 3.0
+
+ def test_line_indent_right_with_chars(self, doc):
+ """Real w:ind element with w:rightChars -> returns float"""
+ 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")
+ pPr.append(ind)
+ assert GetIndent.line_indent(p, "right") == 2.0
+
+ def test_line_indent_invalid_chars_value(self, doc):
+ """w:leftChars has non-numeric value -> returns None (line 417-418)"""
+ 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")
+ pPr.append(ind)
+ assert GetIndent.line_indent(p, "left") is None
+
+ def test_line_indent_no_pPr(self, doc):
+ """No pPr element -> returns None (line 400-401)"""
+ p = doc.add_paragraph()
+ # Remove pPr if it exists
+ pPr = p._element.find(qn("w:pPr"))
+ if pPr is not None:
+ p._element.remove(pPr)
+ assert GetIndent.line_indent(p, "left") is None
+
+ 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:
+ pPr.remove(ind)
+ assert GetIndent.line_indent(p, "left") is None
+
+
+
+# ===========================================================================
+# Coverage: get_some.py uncovered lines
+# ===========================================================================
+
+
+class TestGetStyleSpacingAttributeErrorPaths:
+ """Cover lines 68-69, 73-74, 80-81, 86-87, 93-94:
+ _get_style_spacing when various attributes raise AttributeError"""
+
+ def test_style_elem_none_base_style_attr_error(self):
+ """style_elem is None, style.base_style raises AttributeError (lines 68-69)"""
+ mock_style = MagicMock()
+ mock_style.element = None
+ # Make base_style raise AttributeError
+ del mock_style.base_style
+ assert _get_style_spacing(mock_style, "before") is None
+
+ def test_style_pPr_find_attr_error(self):
+ """style_pPr find raises AttributeError (lines 73-74)"""
+ from docx.oxml import OxmlElement
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ mock_style.element = elem
+ # Make elem.find raise AttributeError
+ original_find = elem.find
+ elem.find = MagicMock(side_effect=AttributeError("test"))
+ try:
+ assert _get_style_spacing(mock_style, "before") is None
+ finally:
+ elem.find = original_find
+
+ def test_style_pPr_none_base_style_attr_error(self):
+ """style_pPr is None, style.base_style raises AttributeError (lines 80-81)"""
+ from docx.oxml import OxmlElement
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ mock_style.element = elem
+ del mock_style.base_style
+ # pPr will be None since no w:pPr child
+ assert _get_style_spacing(mock_style, "before") is None
+
+ def test_spacing_find_attr_error(self):
+ """spacing find raises AttributeError (lines 86-87)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ elem.append(pPr)
+ mock_style.element = elem
+ # Make pPr.find raise AttributeError for w:spacing
+ original_find = pPr.find
+ def selective_find(qname):
+ if "spacing" in str(qname):
+ raise AttributeError("test")
+ return original_find(qname)
+ pPr.find = selective_find
+ try:
+ assert _get_style_spacing(mock_style, "before") is None
+ finally:
+ pPr.find = original_find
+
+ def test_spacing_none_base_style_attr_error(self):
+ """spacing is None, style.base_style raises AttributeError (lines 93-94)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ elem.append(pPr)
+ mock_style.element = elem
+ del mock_style.base_style
+ # No w:spacing child -> spacing is None
+ assert _get_style_spacing(mock_style, "before") is None
+
+
+
+class TestGetStyleSpacingMockDetectionAndValueError:
+ """Cover lines 106, 108-109: _get_style_spacing Mock detection and ValueError paths"""
+
+ def test_lines_attr_mock_with_return_value(self):
+ """lines_attr is a Mock with return_value -> use return_value (line 106)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+
+ # Create a Mock that has return_value and "Mock" in class name
+ lines_attr = MagicMock(__class__=MagicMock(__name__="Mock"))
+ lines_attr.return_value = "200" # 2.0 lines
+
+ original_get = spacing.get
+ def mock_get(qname, default=None):
+ if "beforeLines" in str(qname):
+ return lines_attr
+ return original_get(qname, default)
+ spacing.get = mock_get
+ try:
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 2.0
+ finally:
+ spacing.get = original_get
+
+ def test_lines_attr_mock_no_return_value_value_error(self):
+ """lines_attr is a Mock, return_value exists but int(return_value) raises ValueError (lines 108-109)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ mock_style.base_style = None # terminate recursion
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+
+ # Create a Mock detected as Mock, with return_value that causes int() to raise
+ return_value_mock = MagicMock()
+ return_value_mock.__int__ = MagicMock(side_effect=ValueError("not a number"))
+ lines_attr = MagicMock(__class__=MagicMock(__name__="Mock"))
+ lines_attr.return_value = return_value_mock
+
+ original_get = spacing.get
+ def mock_get(qname, default=None):
+ if "beforeLines" in str(qname):
+ return lines_attr
+ return original_get(qname, default)
+ spacing.get = mock_get
+ try:
+ result = _get_style_spacing(mock_style, "before")
+ # Mock detected -> lines_attr = return_value_mock
+ # int(return_value_mock) raises ValueError -> lines_val = None
+ # base_style is None -> returns None
+ assert result is None
+ finally:
+ spacing.get = original_get
+
+ def test_lines_attr_not_mock_value_error(self):
+ """lines_attr is not a Mock, int() raises ValueError (lines 108-109)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ mock_style.base_style = None # terminate recursion
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+
+ original_get = spacing.get
+ def mock_get(qname, default=None):
+ if "beforeLines" in str(qname):
+ return "not_a_number"
+ return original_get(qname, default)
+ spacing.get = mock_get
+ try:
+ result = _get_style_spacing(mock_style, "before")
+ # int("not_a_number") raises ValueError -> lines_val = None
+ # Then falls to base_style which is None
+ assert result is None
+ finally:
+ spacing.get = original_get
+
+
+
+class TestGetStyleSpacingRecursionAndBaseLines:
+ """Cover lines 117-118, 122: _get_style_spacing recursion and base_lines paths"""
+
+ def test_recursion_returns_base_value(self):
+ """当前样式 Lines 有效(非0)时返回自身值,不回退基样式。"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_base = MagicMock()
+ base_elem = OxmlElement("w:style")
+ base_pPr = OxmlElement("w:pPr")
+ base_spacing = OxmlElement("w:spacing")
+ base_spacing.set(qn("w:beforeLines"), "100")
+ base_pPr.append(base_spacing)
+ base_elem.append(base_pPr)
+ mock_base.element = base_elem
+ mock_base.base_style = None
+
+ # 子样式显式设置 beforeLines=200,应使用自身值
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "200")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 2.0 # 自身值,不回退基样式
+
+ def test_explicit_zero_not_falls_to_base(self):
+ """显式 Lines=0 返回 0.0,不回退基样式。"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_base = MagicMock()
+ base_elem = OxmlElement("w:style")
+ base_pPr = OxmlElement("w:pPr")
+ base_spacing = OxmlElement("w:spacing")
+ base_spacing.set(qn("w:beforeLines"), "100")
+ base_pPr.append(base_spacing)
+ base_elem.append(base_pPr)
+ mock_base.element = base_elem
+ mock_base.base_style = None
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "0")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 0.0 # 显式 0,不回退
+
+ def test_recursion_base_style_attr_error(self):
+ """Recursion: base_style raises AttributeError (line 117-118)"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "0")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ del mock_style.base_style
+
+ result = _get_style_spacing(mock_style, "before")
+ # 修复:显式 0 不再回退,返回 0.0
+ assert result == 0.0
+
+ def test_returns_base_lines_when_zero(self):
+ """显式 Lines=0 返回 0.0,不回退基样式。"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_base = MagicMock()
+ base_elem = OxmlElement("w:style")
+ base_pPr = OxmlElement("w:pPr")
+ base_spacing = OxmlElement("w:spacing")
+ base_spacing.set(qn("w:beforeLines"), "50")
+ base_pPr.append(base_spacing)
+ base_elem.append(base_pPr)
+ mock_base.element = base_elem
+ mock_base.base_style = None
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "0")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 0.0 # 显式 0
+
+ def test_returns_base_lines_when_negative(self):
+ """显式负值返回自身,不回退基样式。"""
+ from docx.oxml import OxmlElement
+ from docx.oxml.ns import qn
+
+ mock_base = MagicMock()
+ base_elem = OxmlElement("w:style")
+ base_pPr = OxmlElement("w:pPr")
+ base_spacing = OxmlElement("w:spacing")
+ base_spacing.set(qn("w:beforeLines"), "200")
+ base_pPr.append(base_spacing)
+ base_elem.append(base_pPr)
+ mock_base.element = base_elem
+ mock_base.base_style = None
+
+ mock_style = MagicMock()
+ elem = OxmlElement("w:style")
+ pPr = OxmlElement("w:pPr")
+ spacing = OxmlElement("w:spacing")
+ spacing.set(qn("w:beforeLines"), "-50")
+ pPr.append(spacing)
+ elem.append(pPr)
+ mock_style.element = elem
+ mock_style.base_style = mock_base
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == -0.5 # 显式负值
+
+
+
+class TestParagraphGetSpaceBeforeWithValidLines:
+ """Cover lines 157-158: paragraph_get_space_before with valid beforeLines in XML"""
+
+ def test_space_before_valid_lines(self, doc):
+ """Valid beforeLines in XML returns correct value (lines 157-158)"""
+ 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")
+ pPr.append(spacing)
+ assert paragraph_get_space_before(p) == 1.0
+
+ def test_space_before_valid_lines_rounded(self, doc):
+ """Valid beforeLines value is rounded (line 156)"""
+ 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")
+ pPr.append(spacing)
+ 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"""
+
+ def test_space_after_valid_lines(self, doc):
+ """Valid afterLines in XML returns correct value (lines 190-191)"""
+ 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")
+ pPr.append(spacing)
+ assert paragraph_get_space_after(p) == 1.5
+
+ def test_space_after_valid_lines_rounded(self, doc):
+ """Valid afterLines value is rounded (line 189)"""
+ 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")
+ pPr.append(spacing)
+ 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"""
+
+ def test_font_color_valid_rgb_tuple_access(self, doc):
+ """color.rgb returns RGBColor, access via indexing (line 280)"""
+ run = doc.add_paragraph().add_run("x")
+ run.font.color.rgb = RGBColor(0x12, 0x34, 0x56)
+ # RGBColor supports indexing: rgb[0] = 0x12, etc.
+ result = run_get_font_color(run)
+ assert result == (0x12, 0x34, 0x56)
+
+
+
+class TestRunGetFontColorFalsyRGB:
+ """Cover line 338: run_get_font_color when color.rgb is falsy (empty string)"""
+
+ def test_font_color_falsy_rgb(self):
+ """color.rgb is a falsy value -> returns (0,0,0) (line 338)"""
+ mock_run = MagicMock()
+ # Create a mock color where rgb is truthy but then becomes falsy
+ mock_color = MagicMock()
+ mock_color.rgb = "" # empty string is falsy
+ mock_run.font.color = mock_color
+ result = run_get_font_color(mock_run)
+ assert result == (0, 0, 0)
+
+ def test_font_color_zero_rgb(self):
+ """color.rgb is 0 (falsy int) -> returns (0,0,0) (line 338)"""
+ mock_run = MagicMock()
+ mock_color = MagicMock()
+ mock_color.rgb = 0 # 0 is falsy
+ mock_run.font.color = mock_color
+ result = run_get_font_color(mock_run)
+ assert result == (0, 0, 0)
+
+
+
+class TestGetIndentLineIndentWithCharsValues:
+ """Cover lines 419-423: GetIndent.line_indent with valid leftChars/rightChars values"""
+
+ def test_line_indent_left_chars_valid(self, doc):
+ """Valid w:leftChars value returns correct float (lines 419-423)"""
+ 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")
+ pPr.append(ind)
+ assert GetIndent.line_indent(p, "left") == 1.5
+
+ def test_line_indent_right_chars_valid(self, doc):
+ """Valid w:rightChars value returns correct float"""
+ 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")
+ pPr.append(ind)
+ assert GetIndent.line_indent(p, "right") == 2.5
+
+ def test_line_indent_left_chars_zero(self, doc):
+ """w:leftChars = 0 -> returns max(0.0, 0.0) = 0.0"""
+ 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")
+ pPr.append(ind)
+ assert GetIndent.line_indent(p, "left") == 0.0
+
+ def test_line_indent_exception_returns_none(self, doc):
+ """Exception during line_indent -> returns None (lines 421-423)"""
+ mock_para = MagicMock()
+ mock_para._element.pPr = None
+ assert GetIndent.line_indent(mock_para, "left") is None
+
diff --git a/tests/test_style_writer.py b/tests/test_style_writer.py
new file mode 100644
index 0000000..cc29a92
--- /dev/null
+++ b/tests/test_style_writer.py
@@ -0,0 +1,388 @@
+#!/usr/bin/env python
+"""
+comprehensive tests for style modules:
+ check_format, get_some, set_some, style_enum, utils
+"""
+
+import pytest
+from types import SimpleNamespace
+from unittest.mock import MagicMock, PropertyMock, patch as mock_patch
+
+from docx import Document
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
+from docx.shared import Pt, RGBColor
+from docx.oxml.ns import qn
+
+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, _get_style_spacing,
+)
+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,
+)
+from wordformat.style.defs import (
+ FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing,
+ FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter,
+ Spacing, UnitLabelEnum,
+)
+from wordformat.style.units import extract_unit_from_string, UnitResult
+
+
+# ---------------------------------------------------------------------------
+# fixtures & helpers
+# ---------------------------------------------------------------------------
+
+@pytest.fixture
+def doc():
+ return Document()
+
+
+@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"):
+ setattr(w, attr, True)
+ return w
+
+
+@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"):
+ 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)
+
+
+# ===========================================================================
+# set_some
+# ===========================================================================
+
+class TestSetSomeFontName:
+ def test_set_and_verify_xml(self, doc):
+ run = doc.add_paragraph().add_run("x")
+ run_set_font_name(run, "黑体")
+ assert run._element.rPr.rFonts.get(qn("w:eastAsia")) == "黑体"
+
+
+
+class TestSetSomeSpaceByLines:
+ def test_set_before_and_after(self, doc):
+ p = doc.add_paragraph()
+ set_paragraph_space_before_by_lines(p, 0.5)
+ set_paragraph_space_after_by_lines(p, 1.0)
+ assert paragraph_get_space_before(p) == 0.5
+ assert paragraph_get_space_after(p) == 1.0
+
+ def test_set_both(self, doc):
+ p = doc.add_paragraph()
+ _paragraph_space_by_lines(p, before_lines=0.3, after_lines=0.7)
+ assert paragraph_get_space_before(p) == 0.3
+ assert paragraph_get_space_after(p) == 0.7
+
+ def test_zero_preserves_existing(self, doc):
+ p = doc.add_paragraph()
+ set_paragraph_space_after_by_lines(p, 1.0)
+ set_paragraph_space_before_by_lines(p, 0.0)
+ assert paragraph_get_space_after(p) == 1.0
+
+
+
+class TestSetSpacingHang:
+ def test_set_and_clamp(self, doc):
+ p = doc.add_paragraph()
+ SetSpacing.set_hang(p, "before", 0.5)
+ assert paragraph_get_space_before(p) == 0.5
+ SetSpacing.set_hang(p, "before", 50.0)
+ 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):
+ p = doc.add_paragraph()
+ getattr(SetLineSpacing, method)(p, 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()
+ SetIndent.set_char(p, "R", 2.5)
+ assert GetIndent.left_indent(p) == 2.5
+
+ def test_set_char_returns_true(self, doc):
+ assert SetIndent.set_char(doc.add_paragraph(), "R", 2) is True
+
+ 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"),
+ ])
+ def test_physical_units_set_indent(self, doc, method, indent_type):
+ p = doc.add_paragraph()
+ getattr(SetIndent, method)(p, indent_type, 1.0)
+ attr = "left_indent" if indent_type == "R" else "right_indent"
+ assert getattr(p.paragraph_format, attr) is not None
+
+ def test_apply_indent_invalid_raises(self, doc):
+ with pytest.raises(ValueError, match="无效的缩进类型"):
+ SetIndent._apply_indent(doc.add_paragraph(), "Z", 10)
+
+
+
+class TestSetFirstLineIndent:
+ def test_set_and_clear(self, doc):
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_char(p, 2)
+ assert paragraph_get_first_line_indent(p) == 2.0
+ SetFirstLineIndent.clear(p)
+ assert paragraph_get_first_line_indent(p) is None
+
+ def test_clear_preserves_left_right(self, doc):
+ p = doc.add_paragraph()
+ SetIndent.set_pt(p, "R", 12)
+ SetIndent.set_pt(p, "X", 12)
+ SetFirstLineIndent.set_char(p, 2)
+ SetFirstLineIndent.clear(p)
+ ind = p._element.pPr.find(qn("w:ind"))
+ assert ind is not None
+ assert ind.get(qn("w:left")) is not None and ind.get(qn("w:right")) is not None
+ assert ind.get(qn("w:firstLineChars")) is None
+
+ def test_clear_no_indent_ok(self, doc):
+ SetFirstLineIndent.clear(doc.add_paragraph()) # no crash
+
+ @pytest.mark.parametrize("val,expected", [(0, 0.0), (-1, -1.0)])
+ def test_first_line_and_hanging_indent(self, doc, val, expected):
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_char(p, val)
+ assert paragraph_get_first_line_indent(p) == expected
+
+ @pytest.mark.parametrize("method", ["set_pt", "set_cm", "set_inch", "set_mm"])
+ def test_physical_units_no_firstLineChars(self, doc, method):
+ p = doc.add_paragraph()
+ getattr(SetFirstLineIndent, method)(p, 1.0)
+ assert paragraph_get_first_line_indent(p) is None
+
+
+
+# ===========================================================================
+# Additional coverage tests for set_some.py
+# ===========================================================================
+
+
+class TestSetSpacingUnits:
+ """Cover lines 137-140, 151-154, 165-168, 179-182, 193-196: _SetSpacing unit methods"""
+
+ def test_set_pt_before(self, doc):
+ """_SetSpacing.set_pt with spacing_type='before' (lines 151-152)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_pt(p, "before", 12)
+ assert p.paragraph_format.space_before is not None
+
+ def test_set_pt_after(self, doc):
+ """_SetSpacing.set_pt with spacing_type='after' (lines 153-154)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_pt(p, "after", 12)
+ assert p.paragraph_format.space_after is not None
+
+ def test_set_cm_before(self, doc):
+ """_SetSpacing.set_cm with spacing_type='before' (lines 165-166)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_cm(p, "before", 1.0)
+ assert p.paragraph_format.space_before is not None
+
+ def test_set_cm_after(self, doc):
+ """_SetSpacing.set_cm with spacing_type='after' (lines 167-168)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_cm(p, "after", 1.0)
+ assert p.paragraph_format.space_after is not None
+
+ def test_set_inch_before(self, doc):
+ """_SetSpacing.set_inch with spacing_type='before' (lines 179-180)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_inch(p, "before", 0.5)
+ assert p.paragraph_format.space_before is not None
+
+ def test_set_inch_after(self, doc):
+ """_SetSpacing.set_inch with spacing_type='after' (lines 181-182)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_inch(p, "after", 0.5)
+ assert p.paragraph_format.space_after is not None
+
+ def test_set_mm_before(self, doc):
+ """_SetSpacing.set_mm with spacing_type='before' (lines 193-194)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_mm(p, "before", 5.0)
+ assert p.paragraph_format.space_before is not None
+
+ def test_set_mm_after(self, doc):
+ """_SetSpacing.set_mm with spacing_type='after' (lines 195-196)"""
+ p = doc.add_paragraph()
+ SetSpacing.set_mm(p, "after", 5.0)
+ assert p.paragraph_format.space_after is not None
+
+ def test_set_hang_sets_twips_to_zero(self, doc):
+ """set_hang 写入 w:before="0" 覆盖样式级 pt 间距"""
+ 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")
+ pPr.append(spacing)
+ SetSpacing.set_hang(p, "before", 0.5)
+ spacing_after = pPr.find(qn("w:spacing"))
+ assert spacing_after.get(qn("w:before")) == "0"
+
+
+
+class TestSetLineSpacingUnits:
+ """Cover lines 234-235, 245-246: _SetLineSpacing additional unit methods"""
+
+ def test_set_inch(self, doc):
+ """_SetLineSpacing.set_inch (lines 234-235)"""
+ p = doc.add_paragraph()
+ SetLineSpacing.set_inch(p, 0.5)
+ assert p.paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY
+
+ def test_set_mm(self, doc):
+ """_SetLineSpacing.set_mm (lines 245-246)"""
+ p = doc.add_paragraph()
+ SetLineSpacing.set_mm(p, 10.0)
+ 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"""
+
+ def test_set_char_zero_clears_attrs(self, doc):
+ """_SetIndent.set_char with value=0 clears attributes (lines 286-291)"""
+ p = doc.add_paragraph()
+ SetIndent.set_char(p, "R", 2)
+ SetIndent.set_char(p, "R", 0)
+ # After setting to 0, the char attr should be removed
+ pPr = p._element.find(qn("w:pPr"))
+ if pPr is not None:
+ ind = pPr.find(qn("w:ind"))
+ if ind is not None:
+ assert ind.get(qn("w:leftChars")) is None
+
+ def test_set_char_exception_returns_false(self, doc):
+ """_SetIndent.set_char with exception -> returns False (lines 300-302)"""
+ p = doc.add_paragraph()
+ # Force an exception by removing _element
+ original = p._element
+ p._element = None
+ assert SetIndent.set_char(p, "R", 2) is False
+ p._element = original
+
+ def test_set_pt(self, doc):
+ """_SetIndent.set_pt (line 313-314)"""
+ p = doc.add_paragraph()
+ SetIndent.set_pt(p, "R", 12)
+ assert p.paragraph_format.left_indent is not None
+
+ def test_set_cm(self, doc):
+ """_SetIndent.set_cm (line 325-326)"""
+ p = doc.add_paragraph()
+ SetIndent.set_cm(p, "X", 1.0)
+ assert p.paragraph_format.right_indent is not None
+
+ def test_set_inch(self, doc):
+ """_SetIndent.set_inch (line 337-338)"""
+ p = doc.add_paragraph()
+ SetIndent.set_inch(p, "R", 0.5)
+ assert p.paragraph_format.left_indent is not None
+
+ def test_set_mm(self, doc):
+ """_SetIndent.set_mm (line 349-350)"""
+ p = doc.add_paragraph()
+ SetIndent.set_mm(p, "X", 5.0)
+ assert p.paragraph_format.right_indent is not None
+
+
+
+class TestSetFirstLineIndentUnits:
+ """Cover lines 478, 494, 510, 526: _SetFirstLineIndent physical unit methods"""
+
+ def test_set_inch(self, doc):
+ """_SetFirstLineIndent.set_inch (line 479)"""
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_inch(p, 0.5)
+ assert p.paragraph_format.first_line_indent is not None
+
+ def test_set_mm(self, doc):
+ """_SetFirstLineIndent.set_mm (line 495)"""
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_mm(p, 5.0)
+ assert p.paragraph_format.first_line_indent is not None
+
+ def test_set_pt(self, doc):
+ """_SetFirstLineIndent.set_pt (line 511)"""
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_pt(p, 24)
+ assert p.paragraph_format.first_line_indent is not None
+
+ def test_set_cm(self, doc):
+ """_SetFirstLineIndent.set_cm (line 527)"""
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_cm(p, 0.5)
+ assert p.paragraph_format.first_line_indent is not None
+
+ def test_set_inch_clears_firstLineChars(self, doc):
+ """set_inch clears firstLineChars to avoid priority conflict (line 478)"""
+ p = doc.add_paragraph()
+ SetFirstLineIndent.set_char(p, 2)
+ SetFirstLineIndent.set_inch(p, 0.5)
+ # firstLineChars should be cleared
+ pPr = p._element.find(qn("w:pPr"))
+ if pPr is not None:
+ ind = pPr.find(qn("w:ind"))
+ if ind is not None:
+ assert ind.get(qn("w:firstLineChars")) is None
+
+ def test_set_char_exception_returns_false(self, doc):
+ """set_char with exception -> returns False (lines 461-463)"""
+ p = doc.add_paragraph()
+ # The try block accesses paragraph._element.get_or_add_pPr()
+ # We need to make it fail inside the try, after clear() succeeds.
+ # 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_tree.py b/tests/test_tree.py
new file mode 100644
index 0000000..d166a25
--- /dev/null
+++ b/tests/test_tree.py
@@ -0,0 +1,424 @@
+"""
+Core 模块综合测试
+
+覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py
+"""
+import os
+import pytest
+from io import StringIO
+from unittest.mock import MagicMock, patch
+
+from docx import Document
+from docx.oxml.ns import qn
+
+from wordformat.tree import Tree, Stack, print_tree
+from wordformat.rules.node import TreeNode, FormatNode
+from wordformat.numbering import (
+ _auto_strip_numbering,
+ _strip_reference_numbering,
+ apply_auto_numbering,
+ create_numbering_definition,
+ process_heading_numbering,
+)
+from wordformat.utils import (
+ get_file_name,
+ ensure_is_directory,
+ ensure_directory_exists,
+ _to_roman,
+ _to_chinese_num,
+ load_yaml_with_merge,
+ get_paragraph_numbering_text,
+ remove_all_numbering,
+ _format_number,
+ _get_level_fmt,
+ _count_numbering_levels,
+)
+from wordformat.style.reader import _get_style_spacing
+from wordformat.base import DocxBase
+from wordformat import settings
+
+
+# ============================================================
+# tree.py — Tree
+# ============================================================
+
+
+# ============================================================
+# tree.py — Tree
+# ============================================================
+
+
+class TestTreeCreation:
+ """Tree 创建与基本属性"""
+
+ def test_create_tree_with_root_value(self):
+ tree = Tree("root")
+ assert tree.root.value == "root"
+
+ def test_create_tree_with_dict_value(self):
+ tree = Tree({"category": "top"})
+ assert tree.root.value == {"category": "top"}
+
+ def test_tree_repr(self):
+ tree = Tree("my_root")
+ assert repr(tree) == "Tree(root=my_root)"
+
+ def test_is_empty(self):
+ """空树(仅 root 无子节点)返回 True,有子节点返回 False"""
+ tree = Tree("root")
+ assert tree.is_empty() is True
+ tree.root.add_child("child")
+ assert tree.is_empty() is False
+
+
+class TestTreeTraversals:
+ """三种遍历:前序、后序、层序"""
+
+ def setup_method(self):
+ tree = Tree("A")
+ b = tree.root.add_child("B")
+ c = tree.root.add_child("C")
+ b.add_child("D")
+ b.add_child("E")
+ c.add_child("F")
+ self.tree = tree
+
+ def test_preorder(self):
+ assert list(self.tree.preorder()) == ["A", "B", "D", "E", "C", "F"]
+
+ def test_postorder(self):
+ assert list(self.tree.postorder()) == ["D", "E", "B", "F", "C", "A"]
+
+ def test_level_order(self):
+ assert list(self.tree.level_order()) == ["A", "B", "C", "D", "E", "F"]
+
+ def test_preorder_single_node(self):
+ tree = Tree("only")
+ assert list(tree.preorder()) == ["only"]
+
+ def test_postorder_single_node(self):
+ tree = Tree("only")
+ assert list(tree.postorder()) == ["only"]
+
+ def test_level_order_single_node(self):
+ tree = Tree("only")
+ assert list(tree.level_order()) == ["only"]
+
+
+class TestTreeFindAndMetrics:
+ """find_by_condition, height, size"""
+
+ def setup_method(self):
+ tree = Tree("A")
+ b = tree.root.add_child("B")
+ c = tree.root.add_child("C")
+ b.add_child("D")
+ b.add_child("E")
+ c.add_child("F")
+ self.tree = tree
+
+ def test_find_by_condition_exists(self):
+ node = self.tree.find_by_condition(lambda v: v == "E")
+ assert node is not None
+ assert node.value == "E"
+
+ def test_find_by_condition_not_exists(self):
+ node = self.tree.find_by_condition(lambda v: v == "Z")
+ assert node is None
+
+ def test_find_by_condition_first_match(self):
+ node = self.tree.find_by_condition(lambda v: isinstance(v, str) and len(v) == 1)
+ assert node.value == "A" # DFS 先遇到根
+
+ def test_height_single_node(self):
+ assert Tree("x").height() == 0
+
+ def test_height_deep_tree(self):
+ tree = Tree("1")
+ tree.root.add_child("2").add_child("3").add_child("4")
+ assert tree.height() == 3
+
+ def test_height_balanced_tree(self):
+ assert self.tree.height() == 2
+
+ def test_size_single_node(self):
+ assert Tree("x").size() == 1
+
+ def test_size(self):
+ assert self.tree.size() == 6
+
+
+# ============================================================
+# tree.py — Stack
+# ============================================================
+
+
+class TestStack:
+ """Stack 的全部操作"""
+
+ def test_push_and_pop(self):
+ s = Stack()
+ s.push(10)
+ s.push(20)
+ assert s.pop() == 20
+ assert s.pop() == 10
+
+ def test_peek(self):
+ s = Stack()
+ s.push("hello")
+ assert s.peek() == "hello"
+ assert s.size() == 1 # peek 不弹出
+
+ def test_peek_safe_on_empty(self):
+ s = Stack()
+ assert s.peek_safe() is None
+
+ def test_peek_safe_returns_top(self):
+ s = Stack()
+ s.push(42)
+ assert s.peek_safe() == 42
+
+ def test_is_empty(self):
+ s = Stack()
+ assert s.is_empty() is True
+ s.push(1)
+ assert s.is_empty() is False
+
+ def test_size(self):
+ s = Stack()
+ assert s.size() == 0
+ s.push("a")
+ s.push("b")
+ assert s.size() == 2
+
+ def test_clear(self):
+ s = Stack()
+ s.push(1)
+ s.push(2)
+ s.clear()
+ assert s.is_empty() is True
+ assert s.size() == 0
+
+ def test_pop_empty_raises(self):
+ s = Stack()
+ with pytest.raises(IndexError, match="pop from empty stack"):
+ s.pop()
+
+ def test_peek_empty_raises(self):
+ s = Stack()
+ with pytest.raises(IndexError, match="peek from empty stack"):
+ s.peek()
+
+ def test_bool_truthy(self):
+ s = Stack()
+ assert bool(s) is False
+ s.push(1)
+ assert bool(s) is True
+
+ def test_repr(self):
+ s = Stack()
+ s.push(1)
+ s.push(2)
+ assert "1" in repr(s)
+ assert "2" in repr(s)
+
+
+# ============================================================
+# tree.py — print_tree
+# ============================================================
+
+
+class TestPrintTree:
+ """print_tree 输出捕获"""
+
+ def test_print_single_node(self):
+ node = TreeNode("hello")
+ buf = StringIO()
+ with patch("sys.stdout", buf):
+ print_tree(node)
+ output = buf.getvalue()
+ assert "hello" in output
+ # rich 渲染单根节点无线条前缀
+
+ def test_print_dict_value_node(self):
+ node = TreeNode({
+ "category": "body_text",
+ "paragraph": "这是一段正文内容",
+ "fingerprint": "fp001",
+ })
+ buf = StringIO()
+ with patch("sys.stdout", buf):
+ print_tree(node)
+ output = buf.getvalue()
+ assert "body_text" in output
+
+ def test_print_tree_with_children(self):
+ root = TreeNode("root")
+ root.add_child("child1")
+ root.add_child("child2")
+ buf = StringIO()
+ with patch("sys.stdout", buf):
+ print_tree(root)
+ output = buf.getvalue()
+ assert "root" in output
+ assert "child1" in output
+ assert "child2" in output
+
+
+
+
+
+# ============================================================
+# rules/node.py — TreeNode
+# ============================================================
+
+
+class TestTreeNode:
+ def test_init_with_simple_value(self):
+ node = TreeNode("hello")
+ assert node.value == "hello"
+ assert node.children == []
+
+ def test_init_fingerprint_is_none(self):
+ """fingerprint 已废弃,始终为 None。"""
+ node = TreeNode({"category": "body_text"})
+ assert node.fingerprint is None
+
+ node2 = TreeNode({"category": "body_text", "fingerprint": "abc123"})
+ assert node2.fingerprint is None
+
+ def test_config_default_empty(self):
+ node = TreeNode("x")
+ assert node.config == {}
+
+ def test_load_config_nested_path(self):
+ node = TreeNode("x")
+ node.NODE_TYPE = "a.b.c"
+ full = {"a": {"b": {"c": {"key": "val"}}}}
+ node.load_config(full)
+ assert node.config == {"key": "val"}
+
+ def test_load_config_missing_path(self):
+ node = TreeNode("x")
+ node.NODE_TYPE = "x.y.z"
+ node.load_config({"a": 1})
+ assert node.config == {}
+
+ def test_load_config_non_dict_input(self):
+ node = TreeNode("x")
+ node.load_config("not a dict")
+ assert node.config == {}
+
+ def test_add_child_returns_child(self):
+ node = TreeNode("parent")
+ child = node.add_child("child_val")
+ assert child.value == "child_val"
+ assert len(node.children) == 1
+
+ def test_add_child_node(self):
+ parent = TreeNode("parent")
+ child = TreeNode("child")
+ parent.add_child_node(child)
+ assert parent.children[0] is child
+
+ def test_repr(self):
+ node = TreeNode("test_val")
+ assert repr(node) == "TreeNode(test_val)"
+
+ def test_fingerprint_attribute_is_none_for_non_dict(self):
+ node = TreeNode("simple_string")
+ assert node.fingerprint is None
+
+
+# ============================================================
+# rules/node.py — FormatNode
+# ============================================================
+
+
+class TestFormatNode:
+ def test_init_defaults(self):
+ node = FormatNode(value="test", level=1)
+ assert node.level == 1
+ assert node.paragraph is None
+ assert node.expected_rule is None
+ assert node._pydantic_config is None
+
+ def test_pydantic_config_raises_before_load(self):
+ node = FormatNode(value="test", level=1)
+ with pytest.raises(ValueError, match="尚未加载Pydantic配置"):
+ _ = node.pydantic_config
+
+ def test_load_yaml_config_file_not_found(self):
+ with pytest.raises(FileNotFoundError, match="配置文件"):
+ FormatNode.load_yaml_config("/nonexistent/config.yaml")
+
+ def test_load_yaml_config_invalid_yaml(self, tmp_path):
+ bad_yaml = tmp_path / "bad.yaml"
+ bad_yaml.write_text(":\n - [invalid", encoding="utf-8")
+ with pytest.raises((ValueError, RuntimeError)):
+ FormatNode.load_yaml_config(str(bad_yaml))
+
+ def test_update_paragraph(self, doc):
+ node = FormatNode(value="test", level=1)
+ p = doc.add_paragraph("hello")
+ node.update_paragraph(p)
+ assert node.paragraph is p
+
+ def test_base_is_noop(self, doc):
+ """_base() 默认为空操作。"""
+ node = FormatNode(value="test", level=1)
+ node._base(doc, p=True, r=True)
+ node._base(doc, p=False, r=False)
+
+ def test_check_format_raises(self, doc):
+ """未加载配置时 check_format 通过 handler 触发 ValueError。"""
+ p = doc.add_paragraph("test")
+ node = FormatNode(value="test", level=1, paragraph=p)
+ with pytest.raises(ValueError, match="尚未加载"):
+ node.check_format(doc)
+
+ def test_apply_format_raises(self, doc):
+ """未加载配置时 apply_format 通过 handler 触发 ValueError。"""
+ p = doc.add_paragraph("test")
+ node = FormatNode(value="test", level=1, paragraph=p)
+ with pytest.raises(ValueError, match="尚未加载"):
+ node.apply_format(doc)
+
+ def test_add_comment_buffers(self, doc):
+ """add_comment 缓冲文本,_flush_comments 合并写入。"""
+ node = FormatNode(value="test", level=1)
+ p = doc.add_paragraph("hello")
+ node.paragraph = p
+ node.add_comment(doc, p.runs[0], "格式错误")
+ node.add_comment(doc, p.runs[0], "字体问题")
+ with patch.object(doc, "add_comment") as mock_add:
+ node._flush_comments(doc)
+ mock_add.assert_called_once()
+ merged = mock_add.call_args[1]["text"]
+ assert "格式错误" in merged
+ assert "字体问题" in merged
+ assert merged.count("\n") == 1
+
+ def test_add_comment_empty_text_skipped(self, doc):
+ node = FormatNode(value="test", level=1)
+ p = doc.add_paragraph("hello")
+ run = p.runs[0]
+ # 空文本不应调用 add_comment
+ node.add_comment(doc, run, " ")
+
+ def test_load_config_heading_level_bug(self):
+ """Heading 节点没有 CONFIG_PATH,由 BaseHeadingNode 自定义 load_config 处理。
+ 通过 FormatNode 基类 load_config 加载时 _pydantic_config 应为 None。"""
+ from wordformat.config.models import HeadingLevelConfig, NodeConfigRoot
+
+ class TestFormatNode(FormatNode[HeadingLevelConfig]):
+ NODE_TYPE = "headings.level_1"
+ CONFIG_MODEL = HeadingLevelConfig
+
+ node = TestFormatNode(value="test", level=1)
+ root_config = NodeConfigRoot()
+ node.load_config(root_config)
+ assert node._pydantic_config is None
+
+
+
diff --git a/tests/test_utils.py b/tests/test_utils.py
new file mode 100644
index 0000000..8b565ee
--- /dev/null
+++ b/tests/test_utils.py
@@ -0,0 +1,464 @@
+"""
+Core 模块综合测试
+
+覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py
+"""
+import os
+import pytest
+from io import StringIO
+from unittest.mock import MagicMock, patch
+
+from docx import Document
+from docx.oxml.ns import qn
+
+from wordformat.tree import Tree, Stack, print_tree
+from wordformat.rules.node import TreeNode, FormatNode
+from wordformat.numbering import (
+ _auto_strip_numbering,
+ _strip_reference_numbering,
+ apply_auto_numbering,
+ create_numbering_definition,
+ process_heading_numbering,
+)
+from wordformat.utils import (
+ get_file_name,
+ ensure_is_directory,
+ ensure_directory_exists,
+ _to_roman,
+ _to_chinese_num,
+ load_yaml_with_merge,
+ get_paragraph_numbering_text,
+ remove_all_numbering,
+ _format_number,
+ _get_level_fmt,
+ _count_numbering_levels,
+)
+from wordformat.style.reader import _get_style_spacing
+from wordformat.base import DocxBase
+from wordformat import settings
+
+
+# ============================================================
+# tree.py — Tree
+# ============================================================
+
+
+# ============================================================
+# utils.py — get_file_name
+# ============================================================
+
+
+class TestGetFileName:
+ @pytest.mark.parametrize(
+ "path, expected",
+ [
+ ("/home/user/doc.docx", "doc"),
+ ("simple.txt", "simple"),
+ ("/path/to/my.file.name.pdf", "my.file.name"),
+ ],
+ )
+ def test_extracts_name(self, path, expected):
+ assert get_file_name(path) == expected
+
+
+# ============================================================
+# utils.py — ensure_is_directory
+# ============================================================
+
+
+class TestEnsureIsDirectory:
+ def test_valid_directory(self, tmp_path):
+ # tmp_path 本身就是目录
+ ensure_is_directory(str(tmp_path))
+
+ def test_nonexistent_path_raises(self):
+ with pytest.raises(ValueError, match="路径不存在"):
+ ensure_is_directory("/nonexistent/path/that/does/not/exist")
+
+ def test_file_instead_of_directory_raises(self, tmp_path):
+ file_path = tmp_path / "afile.txt"
+ file_path.write_text("hello")
+ with pytest.raises(ValueError, match="不是一个文件夹"):
+ ensure_is_directory(str(file_path))
+
+
+# ============================================================
+# utils.py — ensure_directory_exists
+# ============================================================
+
+
+class TestEnsureDirectoryExists:
+ def test_create_new_directory(self, tmp_path):
+ new_dir = str(tmp_path / "sub" / "dir")
+ ensure_directory_exists(new_dir)
+ assert os.path.isdir(new_dir)
+
+ def test_existing_directory_no_error(self, tmp_path):
+ ensure_directory_exists(str(tmp_path))
+
+ def test_existing_file_raises(self, tmp_path):
+ file_path = tmp_path / "blocked.txt"
+ file_path.write_text("data")
+ with pytest.raises(ValueError, match="不是文件夹"):
+ ensure_directory_exists(str(file_path))
+
+
+# ============================================================
+# utils.py — _to_roman
+# ============================================================
+
+
+class TestToRoman:
+ @pytest.mark.parametrize(
+ "num, expected",
+ [
+ (1, "i"),
+ (4, "iv"),
+ (5, "v"),
+ (9, "ix"),
+ (10, "x"),
+ (40, "xl"),
+ (50, "l"),
+ (90, "xc"),
+ (100, "c"),
+ (400, "cd"),
+ (500, "d"),
+ (900, "cm"),
+ (1000, "m"),
+ (1984, "mcmlxxxiv"),
+ (3999, "mmmcmxcix"),
+ ],
+ )
+ def test_valid_numbers(self, num, expected):
+ assert _to_roman(num) == expected
+
+ def test_zero_returns_zero_string(self):
+ """_to_roman(0) 返回 "0" 而非空字符串"""
+ assert _to_roman(0) == "0"
+
+ def test_negative_returns_zero_string(self):
+ """_to_roman(-5) 返回 "0" 而非空字符串"""
+ assert _to_roman(-5) == "0"
+
+
+# ============================================================
+# utils.py — _to_chinese_num
+# ============================================================
+
+
+class TestToChineseNum:
+ @pytest.mark.parametrize(
+ "num, expected",
+ [
+ (1, "一"),
+ (5, "五"),
+ (9, "九"),
+ (10, "十"),
+ (11, "十一"),
+ (20, "二十"),
+ (21, "二十一"),
+ (99, "九十九"),
+ ],
+ )
+ def test_valid_numbers(self, num, expected):
+ assert _to_chinese_num(num) == expected
+
+ def test_zero(self):
+ assert _to_chinese_num(0) == "0"
+
+ def test_negative(self):
+ assert _to_chinese_num(-3) == "-3"
+
+ def test_hundred_should_convert(self):
+ assert _to_chinese_num(100) == "一百"
+
+ def test_hundred_fallback(self):
+ # _to_chinese_num(100) 返回 "一百"
+ assert _to_chinese_num(100) == "一百"
+
+
+# ============================================================
+# utils.py — load_yaml_with_merge
+# ============================================================
+
+
+class TestLoadYamlWithMerge:
+ def test_load_valid_yaml(self, tmp_path):
+ yaml_file = tmp_path / "config.yaml"
+ yaml_file.write_text("key: value\nnested:\n a: 1\n", encoding="utf-8")
+ result = load_yaml_with_merge(str(yaml_file))
+ assert result["key"] == "value"
+ assert result["nested"]["a"] == 1
+
+ def test_nonexistent_file_raises(self):
+ with pytest.raises(FileNotFoundError):
+ load_yaml_with_merge("/nonexistent/file.yaml")
+
+
+# ============================================================
+# rules/node.py — TreeNode
+# ============================================================
+
+
+
+# ============================================================
+# settings.py
+# ============================================================
+
+
+class TestSettings:
+ def test_batch_size_is_int(self):
+ assert isinstance(settings.BATCH_SIZE, int)
+
+ def test_voidnodelist_contains_top(self):
+ assert "top" in settings.VOIDNODELIST
+
+ def test_voidnodelist_is_list(self):
+ assert isinstance(settings.VOIDNODELIST, list)
+
+ def test_host_is_string(self):
+ assert isinstance(settings.HOST, str)
+
+ def test_port_is_int(self):
+ assert isinstance(settings.PORT, int)
+
+ def test_server_host_format(self):
+ assert settings.SERVER_HOST.startswith("http://")
+ assert str(settings.PORT) in settings.SERVER_HOST
+
+
+# ============================================================
+# utils.py — get_paragraph_numbering_text
+# ============================================================
+
+
+
+# ============================================================
+# utils.py — remove_all_numbering
+# ============================================================
+
+
+class TestRemoveAllNumbering:
+ """测试 remove_all_numbering 移除标题样式的编号绑定"""
+
+ def test_removes_numPr_from_heading_styles(self, doc):
+ """从 Heading 1/2/3 样式中移除 numPr"""
+ from docx.oxml import OxmlElement
+
+ for style_name in ["Heading 1", "Heading 2", "Heading 3"]:
+ style = doc.styles[style_name]
+ style_element = style._element
+
+ # 确保 pPr 存在
+ pPr = style_element.find(qn("w:pPr"))
+ if pPr is None:
+ pPr = OxmlElement("w:pPr")
+ style_element.insert(0, pPr)
+
+ # 添加 numPr
+ numPr = OxmlElement("w:numPr")
+ pPr.append(numPr)
+
+ remove_all_numbering(doc)
+
+ for style_name in ["Heading 1", "Heading 2", "Heading 3"]:
+ style = doc.styles[style_name]
+ pPr = style._element.find(qn("w:pPr"))
+ if pPr is not None:
+ numPr = pPr.find(qn("w:numPr"))
+ assert numPr is None, f"{style_name} 的 numPr 未被移除"
+
+ def test_removes_outlineLvl_from_heading_styles(self, doc):
+ """从 Heading 1/2/3 样式中移除 outlineLvl"""
+ from docx.oxml import OxmlElement
+
+ for style_name in ["Heading 1", "Heading 2", "Heading 3"]:
+ style = doc.styles[style_name]
+ style_element = style._element
+
+ pPr = style_element.find(qn("w:pPr"))
+ if pPr is None:
+ pPr = OxmlElement("w:pPr")
+ style_element.insert(0, pPr)
+
+ # 先移除已有的 outlineLvl
+ existing = pPr.find(qn("w:outlineLvl"))
+ if existing is not None:
+ pPr.remove(existing)
+
+ outlineLvl = OxmlElement("w:outlineLvl")
+ outlineLvl.set(qn("w:val"), "0")
+ pPr.append(outlineLvl)
+
+ remove_all_numbering(doc)
+
+ for style_name in ["Heading 1", "Heading 2", "Heading 3"]:
+ style = doc.styles[style_name]
+ pPr = style._element.find(qn("w:pPr"))
+ if pPr is not None:
+ outlineLvl = pPr.find(qn("w:outlineLvl"))
+ assert outlineLvl is None, f"{style_name} 的 outlineLvl 未被移除"
+
+ def test_no_pPr_in_style_no_error(self, doc):
+ """样式中没有 pPr 时不报错"""
+ # Heading 1 的 pPr 可能存在也可能不存在,直接调用不应报错
+ remove_all_numbering(doc)
+
+ def test_style_not_in_doc_no_error(self, doc):
+ """如果样式不存在(不太可能),不报错"""
+ # Document() 默认包含 Heading 1/2/3,所以这个测试主要验证不会抛异常
+ remove_all_numbering(doc)
+
+
+# ============================================================
+# style/get_some.py — _get_style_spacing
+# ============================================================
+
+
+class TestGetStyleSpacing:
+ """测试 _get_style_spacing 递归查找样式间距"""
+
+ def test_none_style_returns_none(self):
+ assert _get_style_spacing(None) is None
+
+ def test_style_with_direct_spacing(self):
+ """样式自身有 beforeLines 时直接返回"""
+ mock_style = MagicMock()
+ mock_elem = MagicMock()
+ mock_pPr = MagicMock()
+ mock_spacing = MagicMock()
+
+ mock_style.element = mock_elem
+ mock_elem.find.return_value = mock_pPr
+ mock_pPr.find.return_value = mock_spacing
+ mock_spacing.get.return_value = "200" # 2.0 行
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 2.0
+
+ def test_style_with_zero_spacing_falls_to_base(self):
+ """样式自身 spacing 为 0 时递归查基样式"""
+ mock_style = MagicMock()
+ mock_base = MagicMock()
+ mock_elem = MagicMock()
+ mock_pPr = MagicMock()
+ mock_spacing = MagicMock()
+ mock_base_elem = MagicMock()
+ mock_base_pPr = MagicMock()
+ mock_base_spacing = MagicMock()
+
+ mock_style.element = mock_elem
+ mock_style.base_style = mock_base
+ mock_elem.find.return_value = mock_pPr
+ mock_pPr.find.return_value = mock_spacing
+ mock_spacing.get.return_value = "0" # 0 行
+
+ mock_base.element = mock_base_elem
+ mock_base_elem.find.return_value = mock_base_pPr
+ mock_base_pPr.find.return_value = mock_base_spacing
+ mock_base_spacing.get.return_value = "150" # 1.5 行
+
+ result = _get_style_spacing(mock_style, "before")
+ # 修复:显式 0 应被尊重,不回退到基样式
+ assert result == 0.0
+
+ def test_no_pPr_falls_to_base(self):
+ """样式没有 pPr 时递归查基样式"""
+ mock_style = MagicMock()
+ mock_base = MagicMock()
+ mock_elem = MagicMock()
+ mock_base_elem = MagicMock()
+ mock_base_pPr = MagicMock()
+ mock_base_spacing = MagicMock()
+
+ mock_style.element = mock_elem
+ mock_style.base_style = mock_base
+ mock_elem.find.return_value = None # pPr 为 None
+
+ mock_base.element = mock_base_elem
+ mock_base_elem.find.return_value = mock_base_pPr
+ mock_base_pPr.find.return_value = mock_base_spacing
+ mock_base_spacing.get.return_value = "100" # 1.0 行
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 1.0
+
+ def test_no_spacing_falls_to_base(self):
+ """样式有 pPr 但没有 spacing 时递归查基样式"""
+ mock_style = MagicMock()
+ mock_base = MagicMock()
+ mock_elem = MagicMock()
+ mock_pPr = MagicMock()
+ mock_base_elem = MagicMock()
+ mock_base_pPr = MagicMock()
+ mock_base_spacing = MagicMock()
+
+ mock_style.element = mock_elem
+ mock_style.base_style = mock_base
+ mock_elem.find.return_value = mock_pPr
+ mock_pPr.find.return_value = None # spacing 为 None
+
+ mock_base.element = mock_base_elem
+ mock_base_elem.find.return_value = mock_base_pPr
+ mock_base_pPr.find.return_value = mock_base_spacing
+ mock_base_spacing.get.return_value = "300" # 3.0 行
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 3.0
+
+ def test_after_spacing_type(self):
+ """测试 after 类型的间距查找"""
+ mock_style = MagicMock()
+ mock_elem = MagicMock()
+ mock_pPr = MagicMock()
+ mock_spacing = MagicMock()
+
+ mock_style.element = mock_elem
+ mock_elem.find.return_value = mock_pPr
+ mock_pPr.find.return_value = mock_spacing
+ mock_spacing.get.return_value = "500" # 5.0 行
+
+ result = _get_style_spacing(mock_style, "after")
+ assert result == 5.0
+
+ def test_no_base_style_returns_none(self):
+ """没有基样式且自身无 spacing 时返回 None"""
+ mock_style = MagicMock()
+ mock_elem = MagicMock()
+ mock_pPr = MagicMock()
+
+ mock_style.element = mock_elem
+ mock_style.base_style = None
+ mock_elem.find.return_value = mock_pPr
+ mock_pPr.find.return_value = None
+
+ # base_style 为 None 时 AttributeError 会被捕获
+ result = _get_style_spacing(mock_style, "before")
+ assert result is None
+
+ def test_style_element_none_falls_to_base(self):
+ """style.element 为 None 时递归查基样式"""
+ mock_style = MagicMock()
+ mock_base = MagicMock()
+ mock_base_elem = MagicMock()
+ mock_base_pPr = MagicMock()
+ mock_base_spacing = MagicMock()
+
+ mock_style.element = None
+ mock_style.base_style = mock_base
+
+ mock_base.element = mock_base_elem
+ mock_base_elem.find.return_value = mock_base_pPr
+ mock_base_pPr.find.return_value = mock_base_spacing
+ mock_base_spacing.get.return_value = "100"
+
+ result = _get_style_spacing(mock_style, "before")
+ assert result == 1.0
+
+
+# ============================================================
+# numbering.py — auto_strip_numbering (empty result)
+# ============================================================
+
+
+
diff --git a/uv.lock b/uv.lock
index 166a158..bf20988 100644
--- a/uv.lock
+++ b/uv.lock
@@ -651,6 +651,27 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/c7/d1/a9f36f8ecdf0fb7c9b1e78c8d7af12b8c8754e74851ac7b94a8305540fc7/macholib-1.16.4-py2.py3-none-any.whl", hash = "sha256:da1a3fa8266e30f0ce7e97c6a54eefaae8edd1e5f86f3eb8b95457cae90265ea", size = 38117, upload-time = "2025-11-22T08:28:36.939Z" },
]
+[[package]]
+name = "markdown-it-py"
+version = "4.2.0"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+dependencies = [
+ { name = "mdurl" },
+]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" }
+wheels = [
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" },
+]
+
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" }
+wheels = [
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" },
+]
+
[[package]]
name = "mpmath"
version = "1.3.0"
@@ -1271,6 +1292,19 @@ wheels = [
{ url = "https://pypi.tuna.tsinghua.edu.cn/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
]
+[[package]]
+name = "rich"
+version = "15.0.0"
+source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }
+dependencies = [
+ { name = "markdown-it-py" },
+ { name = "pygments" },
+]
+sdist = { url = "https://pypi.tuna.tsinghua.edu.cn/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" }
+wheels = [
+ { url = "https://pypi.tuna.tsinghua.edu.cn/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" },
+]
+
[[package]]
name = "ruff"
version = "0.14.14"
@@ -1540,6 +1574,7 @@ name = "wordformat"
version = "1.4.0"
source = { editable = "." }
dependencies = [
+ { name = "fastapi" },
{ name = "loguru" },
{ name = "numpy", version = "2.2.6", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version < '3.11'" },
{ name = "numpy", version = "2.4.2", source = { registry = "https://pypi.tuna.tsinghua.edu.cn/simple" }, marker = "python_full_version >= '3.11'" },
@@ -1547,29 +1582,24 @@ dependencies = [
{ name = "pydantic" },
{ name = "python-docx" },
{ name = "python-dotenv" },
+ { name = "python-multipart" },
{ name = "pyyaml" },
{ name = "requests" },
+ { name = "rich" },
{ name = "tenacity" },
{ name = "tokenizers" },
+ { name = "uvicorn" },
{ name = "webcolors" },
]
[package.optional-dependencies]
-api = [
- { name = "fastapi" },
- { name = "python-multipart" },
- { name = "uvicorn" },
-]
dev = [
- { name = "fastapi" },
{ name = "pre-commit" },
{ name = "pyinstaller" },
{ name = "pytest" },
{ name = "pytest-asyncio" },
{ name = "pytest-cov" },
- { name = "python-multipart" },
{ name = "ruff" },
- { name = "uvicorn" },
]
test = [
{ name = "pytest" },
@@ -1579,7 +1609,7 @@ test = [
[package.metadata]
requires-dist = [
- { name = "fastapi", marker = "extra == 'api'", specifier = ">=0.128.1" },
+ { name = "fastapi", specifier = ">=0.128.1" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "numpy", specifier = ">=1.24.0" },
{ name = "onnxruntime", specifier = ">=1.17.0" },
@@ -1591,14 +1621,15 @@ requires-dist = [
{ name = "pytest-cov", marker = "extra == 'test'", specifier = ">=7.0.0" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "python-dotenv", specifier = ">=1.2.1" },
- { name = "python-multipart", marker = "extra == 'api'", specifier = ">=0.0.22" },
+ { name = "python-multipart", specifier = ">=0.0.22" },
{ name = "pyyaml", specifier = ">=6.0.3" },
{ name = "requests", specifier = ">=2.32.5" },
+ { name = "rich", specifier = ">=13.0.0" },
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.14.14" },
{ name = "tenacity", specifier = ">=9.1.2" },
{ name = "tokenizers", specifier = ">=0.22.2" },
- { name = "uvicorn", marker = "extra == 'api'", specifier = ">=0.40.0" },
+ { name = "uvicorn", specifier = ">=0.40.0" },
{ name = "webcolors", specifier = ">=25.10.0" },
- { name = "wordformat", extras = ["test", "api"], marker = "extra == 'dev'" },
+ { name = "wordformat", extras = ["test"], marker = "extra == 'dev'" },
]
-provides-extras = ["api", "test", "dev"]
+provides-extras = ["test", "dev"]
From 433cbc2f68acb36e56a94d9e07ff7f9e6dde5975 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Thu, 9 Jul 2026 14:12:26 +0800
Subject: [PATCH 2/4] =?UTF-8?q?chore:=20=E6=B7=BB=E5=8A=A0=20*.bak=20/=20a?=
=?UTF-8?q?pi.log=20/=20temp/=20=E5=88=B0=20.gitignore=EF=BC=8C=E5=88=A0?=
=?UTF-8?q?=E9=99=A4=E5=A4=87=E4=BB=BD=E6=96=87=E4=BB=B6?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitignore | 1 +
pyproject.toml.bak | 147 ---------------------------------------------
2 files changed, 1 insertion(+), 147 deletions(-)
delete mode 100644 pyproject.toml.bak
diff --git a/.gitignore b/.gitignore
index 4f17a49..2f1a670 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ tmp/*
.trae
output/*
temp/
+*.bak
api.log
dist
build
diff --git a/pyproject.toml.bak b/pyproject.toml.bak
deleted file mode 100644
index 8dfea52..0000000
--- a/pyproject.toml.bak
+++ /dev/null
@@ -1,147 +0,0 @@
-[project]
-name = "wordformat"
-version = "1.4.0"
-description = "论文格式自动化处理工具"
-readme = "README.md"
-license = "Apache-2.0"
-requires-python = ">=3.10"
-authors = [
- {name = "afish", email = "1593699665@qq.com"},
-]
-keywords = ["word", "docx", "thesis", "format", "论文", "格式", "校验", "自动化"]
-classifiers = [
- "Development Status :: 3 - Alpha",
- "Intended Audience :: Developers",
- "Intended Audience :: Education",
- "Intended Audience :: Science/Research",
- "Programming Language :: Python :: 3",
- "Programming Language :: Python :: 3.10",
- "Programming Language :: Python :: 3.11",
- "Programming Language :: Python :: 3.12",
- "Programming Language :: Python :: 3.13",
- "Topic :: Scientific/Engineering",
- "Topic :: Text Processing",
- "Operating System :: OS Independent",
-]
-dependencies = [
- "loguru>=0.7.3",
- "numpy>=1.24.0",
- "onnxruntime>=1.17.0",
- "pydantic>=2.12.5",
- "python-docx>=1.2.0",
- "python-dotenv>=1.2.1",
- "pyyaml>=6.0.3",
- "requests>=2.32.5",
- "tenacity>=9.1.2",
- "tokenizers>=0.22.2",
- "webcolors>=25.10.0",
-]
-
-[project.urls]
-Homepage = "https://github.com/AfishInLake/WordFormat"
-Repository = "https://github.com/AfishInLake/WordFormat"
-"Bug Tracker" = "https://github.com/AfishInLake/WordFormat/issues"
-
-[tool.ruff.lint]
-select = [
- "E", # pycodestyle errors
- "W", # pycodestyle warnings
- "F", # pyflakes
- "I", # isort
- "C", # flake8-comprehensions
- "B", # flake8-bugbear
- "T20", # flake8-print
- "TC", # flake8-type-checking
- "ASYNC", # flake8-asyncio
-]
-ignore = [
- "ASYNC210",
- "ASYNC251",
- "ASYNC230",
- "TC003",
- "TC002"
-]
-fixable = ["A", "B", "C", "D", "E", "F", "G", "I", "N", "Q", "S", "T", "W", "ANN", "ARG", "BLE", "COM", "DJ", "DTZ", "EM", "ERA", "EXE", "FBT", "ICN", "INP", "ISC", "NPY", "PD", "PGH", "PIE", "PL", "PT", "PTH", "PYI", "RET", "RSE", "RUF", "SIM", "SLF", "TCH", "TID", "TRY", "UP", "YTT"]
-unfixable = []
-exclude = [
- ".bzr",
- ".direnv",
- ".eggs",
- ".git",
- ".hg",
- ".mypy_cache",
- ".nox",
- ".pants.d",
- ".pytype",
- ".ruff_cache",
- ".svn",
- ".tox",
- ".venv",
- "wordformat-skill/",
- "__pypackages__",
- "_build",
- "buck-out",
- "build",
- "dist",
- "node_modules",
- "venv",
-]
-dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$"
-
-[tool.ruff.lint.mccabe]
-max-complexity = 10
-
-[tool.ruff.lint.per-file-ignores]
-"__init__.py" = ["F401", "F403", "E501"]
-"tree.py" = ["T201", "C901"]
-"cli.py" = ["T201", "C901"]
-"**/body.py" = ["C901"]
-"**/heading.py" = ["C901"]
-"numbering.py" = ["C901"]
-"**/pipeline/stages.py" = ["C901"]
-"**/utils/_text.py" = ["C901"]
-
-[tool.ruff.format]
-quote-style = "double"
-indent-style = "space"
-skip-magic-trailing-comma = false
-line-ending = "auto"
-
-[tool.ruff.lint.pycodestyle]
-max-line-length = 108
-max-doc-length = 200
-ignore-overlong-task-comments = true
-
-[build-system]
-requires = ["setuptools>=61.0", "wheel"]
-build-backend = "setuptools.build_meta"
-
-# 显式声明使用 src 布局(推荐)
-[tool.setuptools.packages.find]
-where = ["src"]
-
-[tool.setuptools.package-data]
-wordformat = ["data/**/*", "api/static/**/*"]
-
-[project.scripts]
-wordformat = "wordformat.cli:main"
-wordf = "wordformat.cli:main"
-
-
-[project.optional-dependencies]
-api = [
- "fastapi>=0.128.1",
- "uvicorn>=0.40.0",
- "python-multipart>=0.0.22",
-]
-test = [
- "pytest>=9.0.2",
- "pytest-asyncio>=1.3.0",
- "pytest-cov>=7.0.0",
-]
-dev = [
- "wordformat[test,api]",
- "pre-commit>=4.5.1",
- "ruff>=0.14.14",
- "pyinstaller>=6.18.0",
-]
From 4b49391a5c96bc06ca24ea07809976d2d03438d2 Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Thu, 9 Jul 2026 14:18:25 +0800
Subject: [PATCH 3/4] =?UTF-8?q?fix:=20FormatContext.config=5Fmodel=20?=
=?UTF-8?q?=E4=B8=8D=E5=8F=AF=E5=8F=98=E9=BB=98=E8=AE=A4=E5=80=BC=E6=94=B9?=
=?UTF-8?q?=E4=B8=BA=20field(default=5Ffactory=3DNodeConfigRoot)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
src/wordformat/pipeline/context.py | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/wordformat/pipeline/context.py b/src/wordformat/pipeline/context.py
index e2e2f71..42b115c 100644
--- a/src/wordformat/pipeline/context.py
+++ b/src/wordformat/pipeline/context.py
@@ -3,7 +3,7 @@
# @Author : afish
# @File : Structure.py
-from dataclasses import dataclass
+from dataclasses import dataclass, field
from pathlib import Path
from typing import Protocol
@@ -23,7 +23,7 @@ class FormatContext:
# 运行时对象(由各阶段填充)
document: DocumentObject | None = None
root_node: FormatNode = None
- config_model: NodeConfigRoot = NodeConfigRoot()
+ config_model: NodeConfigRoot = field(default_factory=NodeConfigRoot)
output_path: Path | str = ""
From 14b224255dbad8d992e2b5a87970fd5ddae4fe7a Mon Sep 17 00:00:00 2001
From: Afish <1593699665@qq.com>
Date: Thu, 9 Jul 2026 14:26:45 +0800
Subject: [PATCH 4/4] =?UTF-8?q?chore:=20=E8=A6=86=E7=9B=96=E7=8E=87?=
=?UTF-8?q?=E4=BB=8E=2086%=20=E6=8F=90=E5=8D=87=E8=87=B3=2087%=EF=BC=8C?=
=?UTF-8?q?=E9=99=8D=E4=BD=8E=E9=97=A8=E6=A7=9B=E8=87=B3=2087%?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
Makefile | 2 +-
tests/test_coverage_boost.py | 195 +++++++++++++++++++++++++++++++++++
2 files changed, 196 insertions(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index c61c449..4dcc06b 100644
--- a/Makefile
+++ b/Makefile
@@ -86,7 +86,7 @@ lint:
## tests: Run lint then tests
tests: lint
@echo "Running tests..."
- @pytest tests/ --cov=wordformat --cov-report=term-missing --cov-fail-under=85
+ @pytest tests/ --cov=wordformat --cov-report=term-missing --cov-fail-under=87
## export-requirements: Export requirements files from pyproject.toml
export-requirements:
diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py
index fc355cd..0ca0bea 100644
--- a/tests/test_coverage_boost.py
+++ b/tests/test_coverage_boost.py
@@ -5,6 +5,8 @@
覆盖模块:heading, keywords, numbering, style_enum, set_style, get_some, utils
"""
+import io
+import os
import re
from unittest.mock import MagicMock, patch, PropertyMock
@@ -1165,3 +1167,196 @@ def test_ensure_directory_exists_is_file(self, tmp_path):
file_path.write_text("test")
with pytest.raises(ValueError, match="不是文件夹"):
ensure_directory_exists(str(file_path))
+
+
+# ===========================================================================
+# 覆盖率提升:utils/_docx, config, style/xml_ops, base, tree, numbering
+# ===========================================================================
+
+
+class TestCoverageBoostRound2:
+ """补漏低覆盖率分支。"""
+
+ # --- utils/_docx.py ---
+
+ 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)
+
+ 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
+
+ def test_remove_all_numbering(self):
+ """remove_all_numbering 对空白文档不抛异常。"""
+ from wordformat.utils import remove_all_numbering
+ doc = Document()
+ remove_all_numbering(doc)
+
+ # --- config/loader.py ---
+
+ def test_config_not_loaded_error(self):
+ """ConfigNotLoadedError 可正常抛出和捕获。"""
+ from wordformat.config.loader import ConfigNotLoadedError
+ with pytest.raises(ConfigNotLoadedError):
+ raise ConfigNotLoadedError("test")
+
+ # --- style/xml_ops.py ---
+
+ 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"))
+ rPr_set_italic(rPr, False)
+ assert rPr.find(qn("w:i")) is None
+
+ 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")
+ u.set(qn("w:val"), "single")
+ rPr.append(u)
+ rPr_set_underline(rPr, False)
+ assert rPr.find(qn("w:u")) is None
+
+ # --- tree.py: JSON file path ---
+
+ 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"},
+ ]))
+ # 不应抛异常
+ with patch("sys.stdout", io.StringIO()):
+ print_tree(str(json_path), show_index=True, show_confidence=True)
+
+ # --- numbering.py ---
+
+ 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)
+
+ # --- pipeline/stages.py ---
+
+ def test_formatting_stage_skips_void_nodes(self, doc):
+ """VOIDNODELIST 中的类别跳过格式化。"""
+ 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 = []
+ config = MagicMock()
+ stage.apply_format_check_to_all_nodes(root, doc, config, check=True)
+
+ def test_summary_stage_adds_comment(self, doc):
+ """SummaryGenerationStage 在 check 模式下添加批注。"""
+ 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,
+ )
+ 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"))
+ rPr_set_bold(rPr, False)
+ assert rPr.find(qn("w:b")) is None
+
+ 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 ---
+
+ 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")
+ fs = FontSize("14")
+ fs.base_set(run)
+ assert run.font.size is not None
+
+ 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")
+ fn = FontName("Arial")
+ fn.base_set(run)
+ assert run.font.name == "Arial"
+
+ 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
+
+ # --- numbering.py ---
+
+ 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
+
+ # --- pipeline/stages.py ---
+
+ 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)
+ assert result is ctx
+
+ # --- 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"))