From c31ac33eff82bf0ebdfcde1c147cf9272f54a3f4 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 17:55:30 +0800 Subject: [PATCH 01/12] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20Markdown=20=E2=86=92?= =?UTF-8?q?=20Docx=20=E8=BD=AC=E6=8D=A2=E5=8A=9F=E8=83=BD=20+=20=E4=BC=98?= =?UTF-8?q?=E5=8C=96=20apply=20=E6=A8=A1=E5=BC=8F=E6=89=B9=E6=B3=A8?= =?UTF-8?q?=E9=80=BB=E8=BE=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 `wordf md` 命令,支持将 Markdown 文件转换为格式化 .docx - 基于 mistune 解析 Markdown AST,复用现有 FormatNode 树 + YAML 配置驱动的格式管线 - apply 模式采用"先写后 diff"策略:只对修正失败的残留差异写批注,已成功修正的不再产生噪音 - 修复标点检测批注误用红色的问题(标点 → 提醒 → 蓝色) - 修复 NodeConfigRoot.__getattr__ 对缺失 key 返回 None 导致的 .enabled 崩溃 - 新增 17 个 Markdown 解析器单元测试 --- README.md | 5 + docs/md-to-docx.md | 99 ++++++ pyproject.toml | 1 + src/wordformat/cli.py | 35 +- src/wordformat/config/models.py | 2 +- src/wordformat/markdown/__init__.py | 0 src/wordformat/markdown/parser.py | 186 ++++++++++ src/wordformat/pipeline/context.py | 12 +- src/wordformat/pipeline/orchestrate.py | 65 ++++ src/wordformat/pipeline/stages.py | 20 +- src/wordformat/pipeline/stages_md.py | 74 ++++ src/wordformat/rules/body.py | 2 +- src/wordformat/rules/node.py | 55 ++- src/wordformat/style/comments.py | 12 +- tests/api/test_api.py | 140 ++++++-- tests/classify/test_tag.py | 20 +- tests/conftest.py | 34 +- tests/markdown/__init__.py | 0 tests/markdown/test_parser.py | 132 +++++++ tests/rules/test_abstract.py | 23 +- tests/rules/test_acknowledgement.py | 9 +- tests/rules/test_body.py | 41 ++- tests/rules/test_caption.py | 116 ++++-- tests/rules/test_heading.py | 26 +- tests/rules/test_keywords.py | 14 +- tests/rules/test_node.py | 47 +-- tests/rules/test_references.py | 9 +- tests/style/test_defs.py | 364 +++++++++++++------ tests/style/test_diff.py | 185 +++++++--- tests/style/test_inheritance.py | 6 +- tests/style/test_reader.py | 167 ++++++--- tests/style/test_writer.py | 117 ++++-- tests/test_coverage_boost.py | 79 +++-- tests/test_integration.py | 469 ++++++++++++++++++------- tests/test_numbering.py | 63 +++- tests/test_utils.py | 6 +- tests/utils/test_text.py | 4 + 37 files changed, 2037 insertions(+), 602 deletions(-) create mode 100644 docs/md-to-docx.md create mode 100644 src/wordformat/markdown/__init__.py create mode 100644 src/wordformat/markdown/parser.py create mode 100644 src/wordformat/pipeline/stages_md.py create mode 100644 tests/markdown/__init__.py create mode 100644 tests/markdown/test_parser.py diff --git a/README.md b/README.md index a79252c..bdb44c1 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ - **灵活的交互方式**:支持「生成结构文件→手动调整→执行校验」的分步流程,兼顾自动化与灵活性 ### 实用功能 +- **Markdown 转 Word**:支持将 Markdown 文件直接转换为格式化后的 .docx 文档,保留标题层级结构并自动应用配置中的格式规范 - **自动批注生成**:在格式违规位置自动添加 Word 批注,标注问题类型和修正建议 - **文档结构可视化**:通过 `wordf tree` 命令以树形结构展示文档段落分类和层级关系,支持按类别过滤、显示置信度 - **格式一键修正**:支持根据规范自动修正部分常见格式问题(如标题字号、正文行距) @@ -115,6 +116,9 @@ wordf af -d 论文.docx -c 配置.yaml -f 结构文件.json # 5. 启动 Web 可视化界面 wordf startapi # 然后在浏览器打开 http://127.0.0.1:8000 + +# 6. Markdown 转 Word(从 Markdown 直接生成格式化后的 .docx) +wordf md -d 论文.md -c 配置.yaml ``` ### startapi命令行预览(推荐使用) @@ -184,6 +188,7 @@ wordformat-skill/ - [安装指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/installation.md) - 环境要求和安装步骤 - [使用指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/usage.md) - 命令行、Python编程和API调用的详细使用方法 - [配置文件说明](https://github.com/AfishInLake/WordFormat/blob/master/docs/configuration.md) - 格式规范配置项和自定义配置方法 +- [Markdown 转 Word](https://github.com/AfishInLake/WordFormat/blob/master/docs/md-to-docx.md) - Markdown → Docx 转换功能介绍与使用说明 - [常见问题](https://github.com/AfishInLake/WordFormat/blob/master/docs/faq.md) - 常见问题及解决方案 - [贡献指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/contributing.md) - 如何为项目贡献代码和文档 - [技术架构](https://github.com/AfishInLake/WordFormat/blob/master/docs/architecture.md) - 项目的技术架构和实现原理 diff --git a/docs/md-to-docx.md b/docs/md-to-docx.md new file mode 100644 index 0000000..5e7300c --- /dev/null +++ b/docs/md-to-docx.md @@ -0,0 +1,99 @@ +# Markdown 转 Word 功能 + +## 概述 + +`wordf md` 命令支持将 Markdown 文件直接转换为格式化的 .docx 文档。Markdown 提供文本内容和文档结构(标题层级、段落划分),所有格式(字体、字号、段落样式、编号等)由 YAML 配置文件驱动,与现有的 docx 格式化流程共享同一套配置。 + +## 使用场景 + +- **论文初稿写作**:先用 Markdown 写论文草稿(语法简单、纯文本、易于版本控制),定稿后一键导出为符合学校格式规范的 Word 文档 +- **自动化报告生成**:在数据分析、实验报告等场景中,用脚本生成 Markdown 文件,再批量转换为格式统一的 Word 文档 +- **混合工作流**:团队中部分成员用 Markdown 协作,最终交付 Word 格式给导师/期刊/学校系统 + +## 使用方法 + +### 命令行 + +```bash +# 基本用法 +wordf md -d 论文.md -c 配置.yaml + +# 指定输出目录 +wordf md -d 论文.md -c 配置.yaml -o output/ +``` + +### Python API + +```python +from wordformat.pipeline.orchestrate import md_to_docx + +output_path = md_to_docx( + md_path="thesis.md", + config_path="config.yaml", + save_dir="output/", +) +print(f"生成文件: {output_path}") +``` + +## Markdown 语法映射 + +| Markdown | 文档类别 | +|----------|----------| +| `# 标题` | heading_level_1(一级标题) | +| `## 标题` | heading_level_2(二级标题) | +| `### 标题` | heading_level_3(三级标题) | +| `#### 标题` | heading_level_3(四级及以上统一映射为三级) | +| 普通段落 | body_text(正文) | +| `![](path)` | figure_image(图片占位) | + +列表、引用块、代码块等均展开为 body_text。 + +## 工作流程 + +``` +Markdown 文件 + │ + ▼ mistune 解析 +AST 块列表(heading / paragraph / list / table / ...) + │ + ▼ 类别映射 +扁平段落列表 [{category, paragraph, ...}] + │ + ▼ DocumentTreeBuilder +FormatNode 文档树 + │ + ▼ DocumentCreationStage +新建 .docx Document → 逐节点 add_paragraph + │ + ▼ StyleDefinitionFixStage +修正内置样式定义(Normal, Heading 1, ...) + │ + ▼ FormattingExecutionStage +应用 YAML 配置中的格式规则 + │ + ▼ PostProcessingStage +标题自动编号 + 引用超链接 + │ + ▼ 保存 +{文件名}--生成版.docx +``` + +## 格式应用与批注 + +md→docx 的格式应用逻辑与现有 `wordf af`(apply format)流程一致: + +- **段落样式**(对齐、间距、缩进)和**字符样式**(字体、字号、颜色)由 YAML 配置定义,自动应用到每个段落 +- apply 模式先应用格式,再 diff 检查残留差异——只有实际无法修正的问题才生成批注,已成功修正的变更不产生噪音 +- 业务规则(标点检测、图片对齐等)照常运行,发现的问题以批注形式标注在文档中 +- 批注颜色由问题严重等级决定:**错误**为红色,**提醒**为蓝色 + +## 注意事项 + +- Markdown 仅提供结构和文本,所有格式完全由 YAML 配置控制——配置文件的质量决定了输出文档的格式合规度 +- 引用半角标点(如英文引号 `""`)不会自动转换,标点检测规则会以批注形式提醒 +- 图片语法 `![](path)` 生成的是文本占位段,不会真正插入图片文件 +- 如需对生成的 docx 做进一步检查,可先 `wordf gj` 生成 JSON,再 `wordf cf` 校验 + +## 依赖 + +Markdown 解析基于 [mistune](https://github.com/lepture/mistune) 库(>=3.0.0),支持标准 Markdown 语法及表格、删除线、链接等扩展。 diff --git a/pyproject.toml b/pyproject.toml index 26fd853..b775006 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,6 +26,7 @@ classifiers = [ dependencies = [ "fastapi>=0.128.1", "loguru>=0.7.3", + "mistune>=3.0.0", "numpy>=1.24.0", "onnxruntime>=1.17.0", "pydantic>=2.12.5", diff --git a/src/wordformat/cli.py b/src/wordformat/cli.py index 351d340..ebb7306 100644 --- a/src/wordformat/cli.py +++ b/src/wordformat/cli.py @@ -13,7 +13,7 @@ from rich.console import Console from wordformat.classify.tag import set_tag_main -from wordformat.pipeline.orchestrate import auto_format_thesis_document +from wordformat.pipeline.orchestrate import auto_format_thesis_document, md_to_docx from wordformat.settings import VERSION from wordformat.tree import print_tree @@ -90,6 +90,7 @@ def main(): wordf af 自动格式化论文 wordf tree 查看文档结构树 wordf config 查看所有可配置字段 +wordf md Markdown 转 Docx wordf startapi 启动API服务 【一键示例】 @@ -97,6 +98,7 @@ def main(): wordf cf -d 论文.docx -c config.yaml -f output/xxx.json -o output/ wordf af -d 论文.docx -c config.yaml -f output/xxx.json -o output/ wordf tree -f output/xxx.json +wordf md -d thesis.md -c config.yaml -o output/ wordf config wordf startapi -H 127.0.0.1 -p 8000 ================================================== @@ -206,7 +208,25 @@ def main(): ) # ------------------------------ - # 6. startapi = 启动API服务 + # 6. md = Markdown 转 Docx + # ------------------------------ + p_md = subparsers.add_parser("md", help="Markdown 转 Docx") + p_md.add_argument( + "-d", + required=True, + type=lambda x: validate_file(x, "Markdown文件", [".md"]), + help="Markdown 文件路径", + ) + p_md.add_argument( + "-c", + default=None, + type=lambda x: validate_file(x, "配置", [".yaml", ".yml"]), + help="YAML 配置路径(可选)", + ) + p_md.add_argument("-o", default="output/", help="输出目录(默认output/)") + + # ------------------------------ + # 7. startapi = 启动API服务 # ------------------------------ p_startapi = subparsers.add_parser("startapi", help="启动API服务") p_startapi.add_argument( @@ -231,7 +251,7 @@ def _validate_port(x): args = parser.parse_args() # 只在需要输出目录的命令中创建目录 - if args.mode in ["gj", "cf", "af"]: + if args.mode in ["gj", "cf", "af", "md"]: output_dir = Path(args.o) output_dir.mkdir(parents=True, exist_ok=True) @@ -330,6 +350,15 @@ def _validate_port(x): else: _show_config() + elif args.mode == "md": + logger.info("📝 开始 Markdown → Docx 转换...") + md_to_docx( + md_path=args.d, + config_path=args.c, + save_dir=args.o, + ) + logger.success(f"✅ 转换完成!文件保存在:{args.o}") + elif args.mode == "startapi": logger.info("🚀 启动API服务...") logger.info(f"🌐 服务地址:http://{args.host}:{args.port}") diff --git a/src/wordformat/config/models.py b/src/wordformat/config/models.py index 6b6ef03..d543fd4 100644 --- a/src/wordformat/config/models.py +++ b/src/wordformat/config/models.py @@ -87,7 +87,7 @@ def _walk_config_for_styles(obj, style_map: dict[str, object]) -> None: if not isinstance(obj, dict): return eng_name = _resolve_builtin_style_name(obj) - if eng_name: + if eng_name and isinstance(eng_name, str): style_map[eng_name] = obj for _key, val in obj.items(): if isinstance(val, dict): diff --git a/src/wordformat/markdown/__init__.py b/src/wordformat/markdown/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/wordformat/markdown/parser.py b/src/wordformat/markdown/parser.py new file mode 100644 index 0000000..524956a --- /dev/null +++ b/src/wordformat/markdown/parser.py @@ -0,0 +1,186 @@ +#! /usr/bin/env python +# @Time : 2026/7/12 +# @Author : afish +# @File : parser.py +"""Markdown 解析器,基于 mistune AST 将 Markdown 文本转为扁平段落列表。""" + +from __future__ import annotations + +from typing import Any + +import mistune + + +def parse_markdown(md_text: str) -> list[dict[str, Any]]: + """解析 Markdown 文本,返回与现有 JSON 格式兼容的扁平段落列表。 + + category 映射: + h1 → heading_level_1, h2 → heading_level_2, h3+ → heading_level_3 + 普通段落 → body_text + 仅含图片的段落 → figure_image + + 每个段落附带 inline_marks,记录行内格式片段(bold/italic/code 等), + 供 DocumentCreationStage 创建多 run 段落时使用。 + """ + md = mistune.create_markdown( + renderer=None, plugins=["table", "strikethrough", "url"] + ) + ast = md(md_text) + result: list[dict[str, Any]] = [] + _walk_blocks(ast, result) + return result + + +# --------------------------------------------------------------------------- +# 内部辅助 +# --------------------------------------------------------------------------- + + +def _walk_blocks(blocks: list[dict], result: list[dict]) -> None: + for block in blocks: + btype = block["type"] + + if btype == "blank_line" or btype == "thematic_break": + continue + + if btype == "heading": + result.append(_make_heading(block)) + elif btype in ("paragraph", "block_text"): + item = _make_paragraph(block) + if item: + result.append(item) + elif btype == "table": + result.append(_make_table(block)) + elif btype == "block_code": + result.append(_make_code_block(block)) + elif btype == "list": + _walk_list(block, result) + elif btype == "block_quote": + _walk_blocks(block.get("children", []), result) + + +def _make_heading(block: dict) -> dict: + level = block["attrs"]["level"] + category = f"heading_level_{min(level, 3)}" + text = _extract_text(block.get("children", [])) + segments = _extract_segments(block.get("children", [])) + return { + "category": category, + "paragraph": text, + "score": 1.0, + "inline_marks": segments, + } + + +def _make_paragraph(block: dict) -> dict | None: + children = block.get("children", []) + + # 仅含图片的段落 → figure_image + non_empty = [c for c in children if c["type"] != "softbreak"] + if len(non_empty) == 1 and non_empty[0]["type"] == "image": + return { + "category": "figure_image", + "paragraph": non_empty[0]["attrs"].get("url", ""), + "score": 1.0, + "inline_marks": [], + } + + text = _extract_text(children) + if not text.strip(): + return None + segments = _extract_segments(children) + return { + "category": "body_text", + "paragraph": text, + "score": 1.0, + "inline_marks": segments, + } + + +def _make_table(block: dict) -> dict: + rows: list[str] = [] + for child in block.get("children", []): + if child["type"] in ("table_head", "table_body"): + for row in child.get("children", []): + cells = [ + _extract_text(cell.get("children", [])) + for cell in row.get("children", []) + ] + rows.append(" | ".join(cells)) + text = "\n".join(rows) + return { + "category": "body_text", + "paragraph": text, + "score": 1.0, + "inline_marks": [], + } + + +def _make_code_block(block: dict) -> dict: + text = block.get("raw", "") + return { + "category": "body_text", + "paragraph": text, + "score": 1.0, + "inline_marks": [{"text": text, "code": True}], + } + + +def _walk_list(block: dict, result: list[dict]) -> None: + for item in block.get("children", []): + if item["type"] == "list_item": + for child in item.get("children", []): + _walk_blocks([child], result) + + +def _extract_text(children: list[dict]) -> str: + """从 inline children 提取纯文本。""" + parts: list[str] = [] + + def walk(nodes): + for node in nodes: + if node["type"] == "text": + parts.append(node["raw"]) + elif node["type"] == "softbreak": + parts.append(" ") + elif node["type"] == "linebreak": + parts.append("\n") + elif "children" in node: + walk(node["children"]) + + walk(children) + return "".join(parts) + + +def _extract_segments(children: list[dict]) -> list[dict]: # noqa: C901 + """从 inline children 提取带格式标记的文本片段。""" + segments: list[dict] = [] + + def walk(nodes, attrs): # noqa: C901 + for node in nodes: + ntype = node["type"] + if ntype == "text": + if node["raw"]: + segments.append({"text": node["raw"], **attrs}) + elif ntype in ("strong", "emphasis", "strikethrough"): + extra = { + "strong": "bold", + "emphasis": "italic", + "strikethrough": "strikethrough", + }[ntype] + walk(node["children"], {**attrs, extra: True}) + elif ntype == "link": + walk(node["children"], {**attrs, "link_url": node["attrs"]["url"]}) + elif ntype == "image": + segments.append( + {"text": "", **attrs, "image_url": node["attrs"]["url"]} + ) + elif ntype == "codespan": + segments.append({"text": node.get("raw", ""), **attrs, "code": True}) + elif ntype == "softbreak": + segments.append({"text": " ", **attrs}) + elif ntype == "linebreak": + segments.append({"text": "\n", **attrs}) + + walk(children, {}) + return segments diff --git a/src/wordformat/pipeline/context.py b/src/wordformat/pipeline/context.py index 5562626..36cb943 100644 --- a/src/wordformat/pipeline/context.py +++ b/src/wordformat/pipeline/context.py @@ -14,11 +14,15 @@ @dataclass class FormatContext: - json_path: str - docx_path: str - check: bool - config_path: str + json_path: str = "" + docx_path: str = "" + check: bool = False + config_path: str = "" save_dir: str = "/output" + # MD → Docx 专用 + md_path: str = "" + md_text: str = "" + paragraphs: list = field(default_factory=list) # 运行时对象(由各阶段填充) document: DocumentObject | None = None root_node: FormatNode = None diff --git a/src/wordformat/pipeline/orchestrate.py b/src/wordformat/pipeline/orchestrate.py index 4b513e0..9713649 100644 --- a/src/wordformat/pipeline/orchestrate.py +++ b/src/wordformat/pipeline/orchestrate.py @@ -9,6 +9,7 @@ if TYPE_CHECKING: from wordformat.pipeline import PipelineStage +from wordformat.log_config import logger from wordformat.pipeline.context import FormatContext from wordformat.pipeline.stages import ( DocumentSavingStage, @@ -22,6 +23,11 @@ TreeBuildingStage, TreeNormalizationStage, ) +from wordformat.pipeline.stages_md import ( + DocumentCreationStage, + LoadMarkdownStage, + MarkdownParseStage, +) def auto_format_thesis_document( @@ -90,3 +96,62 @@ def auto_format_thesis_document( for stage in pipeline: ctx = stage.process(ctx) return ctx.output_path + + +def md_to_docx( + md_path: str, + config_path: str | None = None, + save_dir: str = "output/", +): + """将 Markdown 文件转换为格式化后的 .docx 文档。 + + 流程: + 1. 加载 YAML 格式配置(可选); + 2. 读取并解析 Markdown 文件为扁平段落列表; + 3. 构建 FormatNode 文档树; + 4. 创建新的 Word 文档,逐节点生成段落并填入文本; + 5. 修正样式定义; + 6. 应用格式规则; + 7. 后处理(标题编号、引用超链接); + 8. 保存输出 .docx 文件。 + + Args: + md_path: Markdown 源文件路径。 + config_path: YAML 格式规范配置文件路径,为 None 时使用内置默认配置。 + save_dir: 输出目录。 + + Returns: + 生成的 .docx 文件路径。 + """ + from pathlib import Path + + from wordformat.utils import ensure_directory_exists, get_file_name + + ctx = FormatContext( + md_path=md_path, + config_path=config_path or "", + save_dir=save_dir, + check=False, + ) + + pipeline: list[PipelineStage] = [ + LoadConfigStage(), + LoadMarkdownStage(), + MarkdownParseStage(), + TreeBuildingStage(), + DocumentCreationStage(), + StyleDefinitionFixStage(), + FormattingExecutionStage(), + PostProcessingStage(), + ] + + for stage in pipeline: + ctx = stage.process(ctx) + + # 重命名输出文件 + ensure_directory_exists(save_dir) + filename = get_file_name(md_path) + out_path = Path(save_dir) / f"{filename}--生成版.docx" + ctx.document.save(str(out_path)) + logger.info(f"保存文件到 {out_path}") + return str(out_path) diff --git a/src/wordformat/pipeline/stages.py b/src/wordformat/pipeline/stages.py index 8675562..e6cb928 100644 --- a/src/wordformat/pipeline/stages.py +++ b/src/wordformat/pipeline/stages.py @@ -83,12 +83,14 @@ def process(self, ctx: FormatContext) -> FormatContext: class TreeBuildingStage: - """构建tree pipline""" + """构建tree pipline。 + 优先使用 ctx.paragraphs(内存中的段落列表,如 MD 解析结果), + 否则从 ctx.json_path 加载 JSON 文件。 + """ def process(self, ctx: FormatContext) -> FormatContext: - ctx.root_node = DocumentBuilder.build_from_json( - ctx.json_path, config=ctx.config_model - ) + source = ctx.paragraphs if ctx.paragraphs else ctx.json_path + ctx.root_node = DocumentBuilder.build_from_json(source, config=ctx.config_model) return ctx @@ -304,6 +306,9 @@ def process(self, ctx: FormatContext) -> FormatContext: class FormattingExecutionStage: """执行格式化/检查(核心遍历)""" + def __init__(self, skip_comments: bool = False): + self.skip_comments = skip_comments + def apply_format_check_to_all_nodes( self, root_node: FormatNode, document, config, check=True ): @@ -376,6 +381,8 @@ def traverse(node, parent_category="", current_chapter: int = 0): node.apply_replace(document) if check: node.check_format(document) + elif self.skip_comments: + node.apply_style(document) else: node.apply_format(document) except Exception as e: @@ -542,13 +549,14 @@ def process(self, ctx: FormatContext) -> FormatContext: return ctx config_model = ctx.config_model # 标题编号 - if hasattr(config_model, "numbering") and config_model.numbering.enabled: + numbering = config_model.numbering + if numbering and getattr(numbering, "enabled", False): from wordformat.numbering import process_heading_numbering process_heading_numbering( ctx.root_node, ctx.document, - config_model.numbering, + numbering, config_model.headings, ) diff --git a/src/wordformat/pipeline/stages_md.py b/src/wordformat/pipeline/stages_md.py new file mode 100644 index 0000000..4fbcafd --- /dev/null +++ b/src/wordformat/pipeline/stages_md.py @@ -0,0 +1,74 @@ +#! /usr/bin/env python +# @Time : 2026/7/12 +# @Author : afish +# @File : stages_md.py +"""Markdown → Docx 转换的 pipeline 阶段。 + +Markdown 仅提供文本内容和文档结构(标题层级、段落划分), +所有格式(字体、字号、段落样式)由 YAML 配置 + FormatNode 统一处理。 +""" + +from pathlib import Path + +from docx import Document + +from wordformat.log_config import logger +from wordformat.markdown.parser import parse_markdown +from wordformat.rules.node import FormatNode + +from .context import FormatContext + + +class LoadMarkdownStage: + """读取 Markdown 文件到内存。""" + + def process(self, ctx: FormatContext) -> FormatContext: + md_path = Path(ctx.md_path) + if not md_path.exists(): + raise FileNotFoundError(f"Markdown 文件不存在: {md_path}") + ctx.md_text = md_path.read_text(encoding="utf-8") + logger.info(f"已加载 Markdown 文件: {md_path.resolve()}") + return ctx + + +class MarkdownParseStage: + """解析 Markdown 为扁平段落列表(仅提取纯文本,行内标记丢弃)。""" + + def process(self, ctx: FormatContext) -> FormatContext: + ctx.paragraphs = parse_markdown(ctx.md_text) + logger.info(f"解析完成,共 {len(ctx.paragraphs)} 个段落") + return ctx + + +class DocumentCreationStage: + """从 FormatNode 树创建新的 .docx Document。 + + 每个节点创建一个段落,单 run 填充纯文本。 + 格式由后续的 FormattingExecutionStage 统一应用。 + """ + + def process(self, ctx: FormatContext) -> FormatContext: + ctx.document = Document() + nodes = self._flatten_tree_nodes(ctx.root_node) + logger.info(f"开始创建文档,共 {len(nodes)} 个节点") + + for node in nodes: + value = node.value + text = value.get("paragraph", "") if isinstance(value, dict) else str(value) + para = ctx.document.add_paragraph() + para.add_run(text.strip()) + node.paragraph = para + + return ctx + + @staticmethod + def _flatten_tree_nodes(root_node: FormatNode) -> list[FormatNode]: + result: list[FormatNode] = [] + + def dfs(node): + for child in node.children: + result.append(child) + dfs(child) + + dfs(root_node) + return result diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py index dce3fdd..1e8a8c2 100644 --- a/src/wordformat/rules/body.py +++ b/src/wordformat/rules/body.py @@ -138,7 +138,7 @@ def _check_punctuation(self, doc, rule_cfg, p: bool = False): else self.paragraph.runs ) msg = ( - f"{self.NODE_LABEL}-提醒-标点符号问题:" + f"{self.NODE_LABEL}-提醒-标点问题:" f'使用了半角英文标点符号"{half}(英文)",' f'规范:应使用全角中文标点符号"{full}(中文)"' ) diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py index 9049fb1..af86cd7 100644 --- a/src/wordformat/rules/node.py +++ b/src/wordformat/rules/node.py @@ -177,14 +177,22 @@ def _handle_paragraph_style(self, doc, rule_cfg, p: bool): ps = ParagraphStyle.from_config(cfg) if p: issues = ps.diff_from_paragraph(self.paragraph) + if issues: + self.add_comment( + doc=doc, + runs=self.paragraph.runs, + text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL), + ) else: - issues = ps.apply_to_paragraph(self.paragraph) - if issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL), - ) + ps.apply_to_paragraph(self.paragraph) + # apply 后再次 diff,只报告修正失败的残留差异 + remaining = ps.diff_from_paragraph(self.paragraph) + if remaining: + self.add_comment( + doc=doc, + runs=self.paragraph.runs, + text=ParagraphStyle.to_string(remaining, target=self.NODE_LABEL), + ) def _handle_character_style(self, doc, rule_cfg, p: bool): """默认字符样式检查/应用。配置需包含 chinese_font_name 等格式字段。""" @@ -209,14 +217,24 @@ def _handle_character_style(self, doc, rule_cfg, p: bool): continue if p: diff = cstyle.diff_from_run(run) + if diff: + self.add_comment( + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff, target=self.NODE_LABEL), + ) else: - diff = cstyle.apply_to_run(run) - if diff: - self.add_comment( - doc=doc, - runs=run, - text=CharacterStyle.to_string(diff, target=self.NODE_LABEL), - ) + cstyle.apply_to_run(run) + # apply 后再次 diff,只报告修正失败的残留差异 + remaining = cstyle.diff_from_run(run) + if remaining: + self.add_comment( + doc=doc, + runs=run, + text=CharacterStyle.to_string( + remaining, target=self.NODE_LABEL + ), + ) def _collect_comment(self, runs, text: str) -> None: """将批注文本加入缓冲区,(runs, text) 成对存储。""" @@ -290,12 +308,19 @@ def check_format(self, doc: Document): self._flush_comments(doc) def apply_format(self, doc: Document): - """格式应用:先清理、应用样式,再自动调度业务规则""" + """格式应用:先清理、应用样式,再写入批注记录变更。""" self._clean_paragraph_edge_spaces() self._base(doc, p=False, r=False) self._run_rules(doc, p=False) self._flush_comments(doc) + def apply_style(self, doc: Document): + """格式应用(无批注):仅应用样式,不生成批注。 + 用于 md→docx 等从零生成文档的场景。""" + self._clean_paragraph_edge_spaces() + self._base(doc, p=False, r=False) + self._run_rules(doc, p=False) + def apply_replace(self, doc: Document = None) -> bool: """替换段落文本内容(由 JSON 的 replace 字段驱动)。 diff --git a/src/wordformat/style/comments.py b/src/wordformat/style/comments.py index ca7d72e..488ff75 100644 --- a/src/wordformat/style/comments.py +++ b/src/wordformat/style/comments.py @@ -54,6 +54,7 @@ "字体颜色错误": "提醒", "中文字体错误": "提醒", "英文字体错误": "提醒", + "标点问题": "提醒", "标点错误": "提醒", } @@ -67,11 +68,18 @@ def get_severity(comment_text: str) -> str: - """从批注文本中提取严重等级。格式:位置-问题类型:...""" + """从批注文本中提取严重等级。 + + 支持两种格式: + - 位置-问题类型:... → 问题类型 = 首个 - 与 : 之间 + - 位置-严重等级-问题类型:... → 问题类型 = 最后一段 + """ try: prop = comment_text.split("-", 1)[1].split(":")[0] except (IndexError, AttributeError): return _DEFAULT_SEVERITY + if "-" in prop: + prop = prop.rsplit("-", 1)[-1] return SEVERITY_MAP.get(prop, _DEFAULT_SEVERITY) @@ -138,6 +146,8 @@ def split_comment_line(line: str) -> list[Segment]: if not sep: return [(line, None)] prop = prefix.split("-", 1)[1] if "-" in prefix else prefix + if "-" in prop: + prop = prop.rsplit("-", 1)[-1] color = severity_color(prop) segments: list[Segment] = [(prefix + sep, {"color": color} if color else None)] if tail: diff --git a/tests/api/test_api.py b/tests/api/test_api.py index b5bca21..04a42de 100644 --- a/tests/api/test_api.py +++ b/tests/api/test_api.py @@ -3,6 +3,7 @@ 覆盖模块:cli.py, config, agent, set_style.py, set_tag.py, word_structure """ + import argparse import io import os @@ -47,7 +48,9 @@ from wordformat.rules.body import BodyText from wordformat.api import save_upload_file -apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes +apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes +) # ==================== (h) CLI 集成测试 ==================== @@ -100,7 +103,14 @@ def test_main_gj_mode(self, mock_argv, mock_json, mock_set_tag): out_dir = tempfile.mkdtemp() try: mock_argv.__getitem__.side_effect = lambda i: [ - "wf", "gj", "-d", docx_path, "-c", cfg_path, "-o", out_dir + "wf", + "gj", + "-d", + docx_path, + "-c", + cfg_path, + "-o", + out_dir, ][i] mock_argv.__len__.return_value = 8 main() @@ -123,13 +133,25 @@ def test_main_cf_mode(self, mock_argv, mock_auto): out_dir = tempfile.mkdtemp() try: mock_argv.__getitem__.side_effect = lambda i: [ - "wf", "cf", "-d", docx_path, "-c", cfg_path, "-f", json_path, "-o", out_dir + "wf", + "cf", + "-d", + docx_path, + "-c", + cfg_path, + "-f", + json_path, + "-o", + out_dir, ][i] mock_argv.__len__.return_value = 10 main() mock_auto.assert_called_once_with( - jsonpath=json_path, docxpath=docx_path, - configpath=cfg_path, savepath=out_dir, check=True, + jsonpath=json_path, + docxpath=docx_path, + configpath=cfg_path, + savepath=out_dir, + check=True, ) finally: for p in [docx_path, cfg_path, json_path]: @@ -148,13 +170,25 @@ def test_main_af_mode(self, mock_argv, mock_auto): out_dir = tempfile.mkdtemp() try: mock_argv.__getitem__.side_effect = lambda i: [ - "wf", "af", "-d", docx_path, "-c", cfg_path, "-f", json_path, "-o", out_dir + "wf", + "af", + "-d", + docx_path, + "-c", + cfg_path, + "-f", + json_path, + "-o", + out_dir, ][i] mock_argv.__len__.return_value = 10 main() mock_auto.assert_called_once_with( - jsonpath=json_path, docxpath=docx_path, - configpath=cfg_path, savepath=out_dir, check=False, + jsonpath=json_path, + docxpath=docx_path, + configpath=cfg_path, + savepath=out_dir, + check=False, ) finally: for p in [docx_path, cfg_path, json_path]: @@ -163,14 +197,15 @@ def test_main_af_mode(self, mock_argv, mock_auto): @mock.patch("sys.argv") def test_main_startapi_rejects_invalid_port(self, mock_argv): - mock_argv.__getitem__.side_effect = lambda i: ["wf", "startapi", "-p", "99999"][i] + mock_argv.__getitem__.side_effect = lambda i: ["wf", "startapi", "-p", "99999"][ + i + ] mock_argv.__len__.return_value = 4 with mock.patch.dict("sys.modules", {"uvicorn": mock.MagicMock()}): with pytest.raises(SystemExit): main() - # ==================== (u) api/__init__.py 覆盖测试 ==================== @@ -179,10 +214,13 @@ class TestSaveUploadFile: def test_save_upload_file_normal(self, tmp_path): """正常保存上传文件""" - with mock.patch("wordformat.api.BASE_DIR", tmp_path), \ - mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), \ - mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"): + with ( + mock.patch("wordformat.api.BASE_DIR", tmp_path), + mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), + mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"), + ): from wordformat.api import save_upload_file + wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True) mock_file = mock.MagicMock() @@ -196,9 +234,11 @@ def test_save_upload_file_normal(self, tmp_path): def test_save_upload_file_name_conflict(self, tmp_path): """文件名冲突时自动重命名""" - with mock.patch("wordformat.api.BASE_DIR", tmp_path), \ - mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), \ - mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"): + with ( + mock.patch("wordformat.api.BASE_DIR", tmp_path), + mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), + mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"), + ): wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True) # 预先创建同名文件 @@ -219,10 +259,13 @@ def test_save_upload_file_name_conflict(self, tmp_path): def test_save_upload_file_multiple_conflicts(self, tmp_path): """多次冲突时递增后缀""" - with mock.patch("wordformat.api.BASE_DIR", tmp_path), \ - mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), \ - mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"): + with ( + mock.patch("wordformat.api.BASE_DIR", tmp_path), + mock.patch("wordformat.api.OUTPUT_DIR", tmp_path / "output"), + mock.patch("wordformat.api.TEMP_DIR", tmp_path / "temp"), + ): from wordformat.api import save_upload_file + wordformat.api.TEMP_DIR.mkdir(parents=True, exist_ok=True) (wordformat.api.TEMP_DIR / "test.docx").write_bytes(b"a") @@ -237,7 +280,6 @@ def test_save_upload_file_multiple_conflicts(self, tmp_path): assert result.endswith("test_3.docx") - class TestAPIEndpoints: """覆盖 api/__init__.py 所有 API 端点""" @@ -249,10 +291,13 @@ def api_client(self, tmp_path): temp_dir.mkdir(parents=True, exist_ok=True) output_dir.mkdir(parents=True, exist_ok=True) - with mock.patch("wordformat.api.BASE_DIR", tmp_path), \ - mock.patch("wordformat.api.TEMP_DIR", temp_dir), \ - mock.patch("wordformat.api.OUTPUT_DIR", output_dir): + with ( + mock.patch("wordformat.api.BASE_DIR", tmp_path), + mock.patch("wordformat.api.TEMP_DIR", temp_dir), + mock.patch("wordformat.api.OUTPUT_DIR", output_dir), + ): from wordformat.api import app + client = TestClient(app) yield client, temp_dir, output_dir @@ -260,7 +305,14 @@ def test_generate_json_success(self, api_client): """POST /generate-json 成功调用 set_tag_main""" client, temp_dir, output_dir = api_client - mock_result = [{"category": "body_text", "score": 0.9, "paragraph": "test", "fingerprint": "fp1"}] + mock_result = [ + { + "category": "body_text", + "score": 0.9, + "paragraph": "test", + "fingerprint": "fp1", + } + ] with mock.patch("wordformat.api.set_tag_main", return_value=mock_result): docx_bytes = io.BytesIO(b"fake docx") yaml_bytes = io.BytesIO(b"key: value") @@ -269,7 +321,11 @@ def test_generate_json_success(self, api_client): "/generate-json", files={ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"), - "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"), + "config_file": ( + "config.yaml", + yaml_bytes, + "application/octet-stream", + ), }, ) @@ -303,7 +359,9 @@ def test_check_format_success(self, api_client): client, temp_dir, output_dir = api_client mock_result_path = str(output_dir / "test--标注版.docx") - with mock.patch("wordformat.api.auto_format_thesis_document", return_value=mock_result_path): + with mock.patch( + "wordformat.api.auto_format_thesis_document", return_value=mock_result_path + ): docx_bytes = io.BytesIO(b"fake docx") yaml_bytes = io.BytesIO(b"key: value") json_str = '[{"category": "body_text", "score": 0.9}]' @@ -312,7 +370,11 @@ def test_check_format_success(self, api_client): "/check-format", files={ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"), - "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"), + "config_file": ( + "config.yaml", + yaml_bytes, + "application/octet-stream", + ), }, data={"json_data": json_str}, ) @@ -328,7 +390,9 @@ def test_apply_format_success(self, api_client): client, temp_dir, output_dir = api_client mock_result_path = str(output_dir / "test--修改版.docx") - with mock.patch("wordformat.api.auto_format_thesis_document", return_value=mock_result_path): + with mock.patch( + "wordformat.api.auto_format_thesis_document", return_value=mock_result_path + ): docx_bytes = io.BytesIO(b"fake docx") yaml_bytes = io.BytesIO(b"key: value") json_str = '[{"category": "body_text", "score": 0.9}]' @@ -337,7 +401,11 @@ def test_apply_format_success(self, api_client): "/apply-format", files={ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"), - "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"), + "config_file": ( + "config.yaml", + yaml_bytes, + "application/octet-stream", + ), }, data={"json_data": json_str}, ) @@ -372,7 +440,9 @@ def test_generate_json_exception_returns_500(self, api_client): """POST /generate-json 异常时返回 500""" client, temp_dir, output_dir = api_client - with mock.patch("wordformat.api.set_tag_main", side_effect=RuntimeError("test error")): + with mock.patch( + "wordformat.api.set_tag_main", side_effect=RuntimeError("test error") + ): docx_bytes = io.BytesIO(b"fake docx") yaml_bytes = io.BytesIO(b"key: value") @@ -380,20 +450,24 @@ def test_generate_json_exception_returns_500(self, api_client): "/generate-json", files={ "docx_file": ("test.docx", docx_bytes, "application/octet-stream"), - "config_file": ("config.yaml", yaml_bytes, "application/octet-stream"), + "config_file": ( + "config.yaml", + yaml_bytes, + "application/octet-stream", + ), }, ) assert response.status_code == 500 - class TestCLIStartApiMode: """覆盖 cli.py startapi 模式(lines 151-169)""" def test_startapi_mode_calls_uvicorn(self): """startapi 模式调用 uvicorn.run""" import unittest.mock as um + # uvicorn 在函数内部动态导入,需要 mock import with um.patch("sys.argv", ["wf", "startapi"]): with um.patch.dict("sys.modules", {"uvicorn": um.MagicMock()}): @@ -401,6 +475,6 @@ def test_startapi_mode_calls_uvicorn(self): main() # 验证 uvicorn.run 被调用 import sys + if "uvicorn" in sys.modules: sys.modules["uvicorn"].run.assert_called_once() - diff --git a/tests/classify/test_tag.py b/tests/classify/test_tag.py index fe66ef9..e439af1 100644 --- a/tests/classify/test_tag.py +++ b/tests/classify/test_tag.py @@ -3,6 +3,7 @@ 覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py """ + import os import pytest from io import StringIO @@ -101,13 +102,16 @@ def test_parse_low_score_forced_to_body_text(self, tmp_path): assert result[1]["category"] == "body_text" assert "强制设为" in result[1]["comment"] - def test_parse_batch_failure_fallback_to_single(self, temp_docx): """测试批量推理失败时降级到单条推理""" mock_single_result = {"label": "body_text", "score": 0.9} - with patch("wordformat.base.onnx_batch_infer", side_effect=RuntimeError("ONNX error")): - with patch("wordformat.base.onnx_single_infer", return_value=mock_single_result): + with patch( + "wordformat.base.onnx_batch_infer", side_effect=RuntimeError("ONNX error") + ): + with patch( + "wordformat.base.onnx_single_infer", return_value=mock_single_result + ): base = DocxBase(temp_docx, "/fake/config.yaml") result = base.parse() @@ -212,7 +216,12 @@ def test_parse_includes_numbering_text(self, tmp_path): path = str(tmp_path / "numbered.docx") doc.save(path) - with patch("wordformat.base.onnx_batch_infer", return_value=[{"text": "1. 绪论", "label": "body_text", "pred_id": 0, "score": 0.9}]): + with patch( + "wordformat.base.onnx_batch_infer", + return_value=[ + {"text": "1. 绪论", "label": "body_text", "pred_id": 0, "score": 0.9} + ], + ): base = DocxBase(path, "/fake/config.yaml") result = base.parse() @@ -224,6 +233,3 @@ def test_parse_includes_numbering_text(self, tmp_path): # ============================================================ # utils.py — _format_number 额外覆盖测试 # ============================================================ - - - diff --git a/tests/conftest.py b/tests/conftest.py index 26e6a0b..be605ec 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,6 +3,7 @@ 将所有测试文件中重复定义的 fixture 集中管理。 """ + import os import json import pytest @@ -18,6 +19,7 @@ # ==================== Document Fixtures ==================== + @pytest.fixture def doc(): """创建一个空的 Document 对象""" @@ -202,6 +204,7 @@ def temp_json(tmp_path): enabled: false """ + @pytest.fixture def config_path(tmp_path): """用内联 YAML 生成临时配置文件,不依赖外部文件。""" @@ -215,19 +218,26 @@ def mock_config(): """创建一个 mock 配置对象,模拟 NodeConfigRoot""" config = MagicMock() config.style_checks_warning = MagicMock( - bold=True, italic=True, underline=True, - font_size=True, font_name=True, font_color=True, - alignment=True, space_before=True, space_after=True, - line_spacing=True, line_spacingrule=True, - left_indent=True, right_indent=True, - first_line_indent=True, builtin_style_name=True, + bold=True, + italic=True, + underline=True, + font_size=True, + font_name=True, + font_color=True, + alignment=True, + space_before=True, + space_after=True, + line_spacing=True, + line_spacingrule=True, + left_indent=True, + right_indent=True, + first_line_indent=True, + builtin_style_name=True, ) config.numbering = MagicMock(enabled=False) return config - - @pytest.fixture def sample_yaml_config(tmp_path): """用内联 YAML 生成临时配置文件。""" @@ -238,16 +248,18 @@ def sample_yaml_config(tmp_path): # ==================== Mock Fixtures ==================== + @pytest.fixture def mock_onnx_infer(): """Mock ONNX 推理函数,返回可控结果""" + def _infer(texts): if isinstance(texts, str): texts = [texts] return [ - {"text": t, "label": "body_text", "pred_id": 0, "score": 0.9} - for t in texts + {"text": t, "label": "body_text", "pred_id": 0, "score": 0.9} for t in texts ] + return _infer @@ -261,6 +273,7 @@ def mock_onnx_single(): def reset_config(): """每个测试前后自动清理配置状态""" from wordformat.config.loader import clear_config + clear_config() yield clear_config() @@ -270,6 +283,7 @@ def reset_config(): def reset_style_warning(): """每个测试前后重置警告缓存。""" from wordformat.style import diff + diff._warnings = None yield diff._warnings = None diff --git a/tests/markdown/__init__.py b/tests/markdown/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/markdown/test_parser.py b/tests/markdown/test_parser.py new file mode 100644 index 0000000..0ecbe4d --- /dev/null +++ b/tests/markdown/test_parser.py @@ -0,0 +1,132 @@ +#! /usr/bin/env python +"""Markdown 解析器单元测试。""" + +import pytest + +from wordformat.markdown.parser import parse_markdown + + +class TestParseMarkdown: + """基础解析测试。""" + + def test_empty_text(self): + result = parse_markdown("") + assert result == [] + + def test_blank_lines_only(self): + result = parse_markdown("\n\n\n") + assert result == [] + + def test_single_heading_h1(self): + result = parse_markdown("# 第一章 绪论") + assert len(result) == 1 + assert result[0]["category"] == "heading_level_1" + assert result[0]["paragraph"] == "第一章 绪论" + assert result[0]["score"] == 1.0 + + def test_single_heading_h2(self): + result = parse_markdown("## 1.1 研究背景") + assert len(result) == 1 + assert result[0]["category"] == "heading_level_2" + assert result[0]["paragraph"] == "1.1 研究背景" + + def test_heading_h4_maps_to_level_3(self): + result = parse_markdown("#### 四级标题") + assert len(result) == 1 + assert result[0]["category"] == "heading_level_3" + + def test_paragraph(self): + result = parse_markdown("这是一段正文。") + assert len(result) == 1 + assert result[0]["category"] == "body_text" + assert result[0]["paragraph"] == "这是一段正文。" + + def test_multiple_paragraphs(self): + text = "第一段。\n\n第二段。" + result = parse_markdown(text) + assert len(result) == 2 + assert result[0]["category"] == "body_text" + assert result[0]["paragraph"] == "第一段。" + assert result[1]["category"] == "body_text" + assert result[1]["paragraph"] == "第二段。" + + def test_heading_and_paragraphs(self): + text = "# 标题\n\n正文内容。\n\n更多正文。" + result = parse_markdown(text) + assert len(result) == 3 + assert result[0]["category"] == "heading_level_1" + assert result[1]["category"] == "body_text" + assert result[2]["category"] == "body_text" + + +class TestHeadingHierarchy: + """测试多级标题的层级映射。""" + + def test_deep_hierarchy(self): + text = "# 一级\n\n## 二级\n\n### 三级\n\n正文。" + result = parse_markdown(text) + assert len(result) == 4 + assert result[0]["category"] == "heading_level_1" + assert result[1]["category"] == "heading_level_2" + assert result[2]["category"] == "heading_level_3" + assert result[3]["category"] == "body_text" + + +class TestInlineFormatting: + """测试行内格式标记(inline_marks)的提取。""" + + def test_bold_text(self): + result = parse_markdown("这是**粗体**文字。") + assert len(result) == 1 + item = result[0] + marks = item["inline_marks"] + # 应有三个片段:普通 + 粗体 + 普通 + assert len(marks) >= 2 + bold_segs = [m for m in marks if m.get("bold")] + assert len(bold_segs) == 1 + assert bold_segs[0]["text"] == "粗体" + + def test_italic_text(self): + result = parse_markdown("这是*斜体*文字。") + marks = result[0]["inline_marks"] + italic_segs = [m for m in marks if m.get("italic")] + assert len(italic_segs) == 1 + assert italic_segs[0]["text"] == "斜体" + + def test_plain_text_has_no_marks(self): + result = parse_markdown("普通正文。") + marks = result[0]["inline_marks"] + for m in marks: + assert not m.get("bold") + assert not m.get("italic") + + def test_heading_no_inline_marks_used(self): + """标题中的 inline_marks 也存在但不影响使用。""" + result = parse_markdown("# 标题") + assert "inline_marks" in result[0] + + +class TestSpecialBlocks: + """测试特殊块的处理。""" + + def test_code_block_becomes_body_text(self): + result = parse_markdown('```\nprint("hello")\n```') + assert len(result) == 1 + assert result[0]["category"] == "body_text" + assert 'print("hello")' in result[0]["paragraph"] + + def test_list_flattens_to_paragraphs(self): + text = "- 第一项\n- 第二项" + result = parse_markdown(text) + assert len(result) == 2 + assert all(r["category"] == "body_text" for r in result) + + def test_standalone_image_becomes_figure(self): + result = parse_markdown("![](test.png)") + assert len(result) == 1 + assert result[0]["category"] == "figure_image" + assert result[0]["paragraph"] == "test.png" + + def test_thematic_break_skipped(self): + result = parse_markdown("---") + assert len(result) == 0 diff --git a/tests/rules/test_abstract.py b/tests/rules/test_abstract.py index d7a41bb..c0fee4c 100644 --- a/tests/rules/test_abstract.py +++ b/tests/rules/test_abstract.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,7 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - class TestAbstractTitleCNBase: """覆盖 abstract.py AbstractTitleCN._base 的 diff 和 apply 分支。""" @@ -104,7 +106,7 @@ def test_check_with_wrong_format_triggers_comments(self, root_config): assert mock_comment.call_count >= 2 def test_apply_fixes_wrong_format(self, root_config): - """apply 模式:应调用 apply_to_paragraph 和 apply_to_run。""" + """apply 模式:格式应用成功,不产生批注。""" doc = Document() p = doc.add_paragraph() r = p.add_run("摘要") @@ -114,8 +116,8 @@ def test_apply_fixes_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - # apply 模式也会调用 add_comment - assert mock_comment.call_count >= 2 + # apply 模式格式应用成功,不产生批注(check 模式才写批注) + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_no_runs_skips_without_error(self, root_config): """段落无 run 时(空段),check_format 安全跳过不报错。""" @@ -131,7 +133,6 @@ def test_check_no_runs_skips_without_error(self, root_config): # --------------------------------------------------------------------------- - class TestAbstractTitleContentCNBase: """覆盖 abstract.py AbstractTitleContentCN._base 的 check_title 分支。""" @@ -192,7 +193,6 @@ def test_check_mode_does_not_mutate_run_text(self, root_config): # --------------------------------------------------------------------------- - class TestAbstractContentCNBase: """覆盖 abstract.py AbstractContentCN._base 的 diff/apply 逻辑。""" @@ -241,7 +241,6 @@ def test_check_multiple_runs(self, root_config): # --------------------------------------------------------------------------- - class TestAbstractTitleENBase: """覆盖 abstract.py AbstractTitleEN._base 的 diff/apply 逻辑。 注意:AbstractTitleEN 只在 diff_result 非空时才 add_comment。""" @@ -280,7 +279,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 # --------------------------------------------------------------------------- @@ -288,7 +287,6 @@ def test_apply_with_wrong_format(self, root_config): # --------------------------------------------------------------------------- - class TestAbstractTitleContentENBase: """覆盖 abstract.py AbstractTitleContentEN._base 的 check_title 分支。""" @@ -353,7 +351,7 @@ def test_check_title_normalizes_case_upper(self, root_config): assert r.text.startswith("Abstract") def test_split_abstract_across_runs(self, root_config): - """"Abstract" 被拆分到两个 run 时仍能正确识别标题部分。""" + """ "Abstract" 被拆分到两个 run 时仍能正确识别标题部分。""" doc = Document() p = doc.add_paragraph() r1 = p.add_run("Abst") @@ -368,7 +366,7 @@ def test_split_abstract_across_runs(self, root_config): assert "body text" in r2.text def test_split_abstract_across_three_runs(self, root_config): - """"Abstract" 被拆分到三个 run 时仍能正确识别。""" + """ "Abstract" 被拆分到三个 run 时仍能正确识别。""" doc = Document() p = doc.add_paragraph() r1 = p.add_run("Abs") @@ -392,7 +390,6 @@ def test_split_abstract_across_three_runs(self, root_config): # --------------------------------------------------------------------------- - class TestAbstractContentENBase: """覆盖 abstract.py AbstractContentEN._base 的 diff/apply 逻辑。 注意:AbstractContentEN 只在 diff_result/issues 非空时才 add_comment。""" @@ -421,7 +418,7 @@ def test_apply_with_wrong_format(self, root_config): # 配置中 line_spacing 为 "0倍",现会触发 ValueError,mock 掉该步 with patch("wordformat.style.diff.LineSpacing.format"): node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 2 def test_check_multiple_runs(self, root_config): """多个 run 都应被检查。""" diff --git a/tests/rules/test_acknowledgement.py b/tests/rules/test_acknowledgement.py index 3bc742e..b868ad7 100644 --- a/tests/rules/test_acknowledgement.py +++ b/tests/rules/test_acknowledgement.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,7 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - class TestAcknowledgementsBase: """覆盖 acknowledgement.py Acknowledgements._base 的 diff/apply 逻辑。""" @@ -123,7 +125,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_no_diffs_no_comment(self, root_config): """格式完全正确时,不应调用 add_comment。""" @@ -144,7 +146,6 @@ def test_check_no_diffs_no_comment(self, root_config): # --------------------------------------------------------------------------- - class TestAcknowledgementsCNBase: """覆盖 acknowledgement.py AcknowledgementsCN._base 的 diff/apply 逻辑。""" @@ -180,7 +181,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_first_line_indent(self, root_config): """验证 first_line_indent 配置被正确使用。""" diff --git a/tests/rules/test_body.py b/tests/rules/test_body.py index 5a9338d..37caa5e 100644 --- a/tests/rules/test_body.py +++ b/tests/rules/test_body.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,8 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - - class TestBodyTextCitationSuperscript: """测试 BodyText.apply_format 中的引用上标自动设置。""" @@ -104,8 +105,9 @@ def test_single_citation_gets_superscript(self): """单引用 [1] 应被设为上标。""" doc = Document() p = doc.add_paragraph("这是一篇论文[1]的引用。") - node = BodyText(value={"category": "body_text", "fingerprint": "fp1"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp1"}, level=0, paragraph=p + ) # 直接调用 apply_format(跳过 load_config,不依赖完整配置) node._apply_citation_superscript() # "[1]" 应在独立的上标 run 中 @@ -119,8 +121,9 @@ def test_multiple_citations(self): """多个引用 [1] 和 [2,3] 都应被设为上标。""" doc = Document() p = doc.add_paragraph("参见文献[1]和[2,3]的讨论。") - node = BodyText(value={"category": "body_text", "fingerprint": "fp2"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp2"}, level=0, paragraph=p + ) node._apply_citation_superscript() superscript_texts = [ r.text for r in p.runs if self._get_vertAlign(r) == "superscript" @@ -132,8 +135,9 @@ def test_range_citation(self): """范围引用 [1-3] 应被设为上标。""" doc = Document() p = doc.add_paragraph("文献[1-3]提供了详细分析。") - node = BodyText(value={"category": "body_text", "fingerprint": "fp3"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp3"}, level=0, paragraph=p + ) node._apply_citation_superscript() superscript_texts = [ r.text for r in p.runs if self._get_vertAlign(r) == "superscript" @@ -144,8 +148,9 @@ def test_chinese_comma_citation(self): """中文逗号分隔的引用 [1,2] 应被设为上标。""" doc = Document() p = doc.add_paragraph("见[1,2,3]的研究。") - node = BodyText(value={"category": "body_text", "fingerprint": "fp4"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp4"}, level=0, paragraph=p + ) node._apply_citation_superscript() superscript_texts = [ r.text for r in p.runs if self._get_vertAlign(r) == "superscript" @@ -156,8 +161,9 @@ def test_no_citation_leaves_runs_unchanged(self): """无引用的段落应保持原样。""" doc = Document() p = doc.add_paragraph("这是一段没有引用的正文。") - node = BodyText(value={"category": "body_text", "fingerprint": "fp5"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp5"}, level=0, paragraph=p + ) original_text = p.text node._apply_citation_superscript() # 文本不变,且无上标 run @@ -168,8 +174,9 @@ def test_non_citation_brackets_not_affected(self): """非数字方括号如 [注] 不应被设为上标。""" doc = Document() p = doc.add_paragraph("这是一个[注]释说明。") - node = BodyText(value={"category": "body_text", "fingerprint": "fp6"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp6"}, level=0, paragraph=p + ) node._apply_citation_superscript() assert all(self._get_vertAlign(r) is None for r in p.runs) @@ -179,8 +186,9 @@ def test_citation_split_across_runs(self): p = doc.add_paragraph() p.add_run("前面文字[1") p.add_run("2]后面文字") - node = BodyText(value={"category": "body_text", "fingerprint": "fp7"}, - level=0, paragraph=p) + node = BodyText( + value={"category": "body_text", "fingerprint": "fp7"}, level=0, paragraph=p + ) node._apply_citation_superscript() superscript_texts = [ r.text for r in p.runs if self._get_vertAlign(r) == "superscript" @@ -193,4 +201,3 @@ def test_citation_split_across_runs(self): # --------------------------------------------------------------------------- # 8. AbstractTitleCN._base 完整 diff/apply 覆盖 # --------------------------------------------------------------------------- - diff --git a/tests/rules/test_caption.py b/tests/rules/test_caption.py index 39d46f7..d22c176 100644 --- a/tests/rules/test_caption.py +++ b/tests/rules/test_caption.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,7 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - class TestCaptionFigureBase: """覆盖 caption.py CaptionFigure._base 的 diff/apply 逻辑。""" @@ -112,7 +114,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 # --------------------------------------------------------------------------- @@ -120,7 +122,6 @@ def test_apply_with_wrong_format(self, root_config): # --------------------------------------------------------------------------- - class TestCaptionTableBase: """覆盖 caption.py CaptionTable._base 的 diff/apply 逻辑。""" @@ -146,7 +147,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 # --------------------------------------------------------------------------- @@ -167,7 +168,6 @@ def test_apply_with_wrong_format(self, root_config): # ======================== _from_roman ======================== - class TestApplyCaptionNumbering: """直接测试 _apply_caption_numbering 函数。""" @@ -265,6 +265,7 @@ def test_continued_table_corrects_separator(self): # ======================== apply_format_check_to_all_nodes 集成 ======================== + class TestCaptionNumberingIntegration: """测试 caption numbering 在 apply_format_check_to_all_nodes 中的集成。""" @@ -347,7 +348,11 @@ def _make_paragraph(self, text): def test_check_mode_injects_chapter_and_seq(self, caption_yaml): """验证遍历时章节号和顺序号被注入到 node.value 中。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p = self._make_paragraph("图1.1 系统架构图") @@ -371,19 +376,26 @@ def _suppress_format_comments(self): mock_cs = MagicMock() mock_cs.diff_from_run.return_value = {} mock_cs.apply_to_run.return_value = {} - with patch( - "wordformat.style.diff.ParagraphStyle.from_config", - return_value=mock_ps, - ), patch( - "wordformat.style.diff.CharacterStyle", - return_value=mock_cs, + with ( + patch( + "wordformat.style.diff.ParagraphStyle.from_config", + return_value=mock_ps, + ), + patch( + "wordformat.style.diff.CharacterStyle", + return_value=mock_cs, + ), ): yield @pytest.mark.usefixtures("_suppress_format_comments") def test_check_mode_correct_no_comment(self, caption_yaml): """check 模式:格式正确,不添加批注。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p = self._make_paragraph("图1.1 系统架构图") @@ -399,7 +411,11 @@ def test_check_mode_correct_no_comment(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_check_mode_wrong_chapter_adds_comment(self, caption_yaml): """check 模式:章节号错误应添加批注。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p = self._make_paragraph("图2.1 测试图") @@ -416,7 +432,11 @@ def test_check_mode_wrong_chapter_adds_comment(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_apply_mode_rewrites_caption(self, caption_yaml): """apply 模式:重写题注文本。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p = self._make_paragraph("图2-1 旧名称") @@ -431,7 +451,11 @@ def test_apply_mode_rewrites_caption(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_per_type_counters(self, caption_yaml): """图和表使用独立计数器。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p_fig = self._make_paragraph("图1.1 图") @@ -442,8 +466,10 @@ def test_per_type_counters(self, caption_yaml): heading = self._make_heading_node(children=[fig, tab]) config = self._init_config(caption_yaml) - with patch.object(fig, "add_comment") as mc1, \ - patch.object(tab, "add_comment") as mc2: + with ( + patch.object(fig, "add_comment") as mc1, + patch.object(tab, "add_comment") as mc2, + ): apply_format_check_to_all_nodes(heading, doc, config, check=True) mc1.assert_not_called() @@ -454,7 +480,11 @@ def test_per_type_counters(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_multi_chapter_counters_reset(self, caption_yaml): """不同章节的题注计数器独立重置。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p1 = self._make_paragraph("图1.1 第一章图") @@ -472,8 +502,7 @@ def test_multi_chapter_counters_reset(self, caption_yaml): config = self._init_config(caption_yaml) - with patch.object(fig1, "add_comment"), \ - patch.object(fig2, "add_comment"): + with patch.object(fig1, "add_comment"), patch.object(fig2, "add_comment"): apply_format_check_to_all_nodes(root, doc, config, check=True) assert fig1.value["chapter_number"] == 1 @@ -484,7 +513,11 @@ def test_multi_chapter_counters_reset(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_disabled_skips_numbering_check(self, caption_yaml): """rules.caption_numbering.enabled=False 时不检查编号(但仍注入 chapter/seq)。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p = self._make_paragraph("图2.1 测试") @@ -503,7 +536,11 @@ def test_disabled_skips_numbering_check(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_continued_table_does_not_increment_counter(self, caption_yaml): """续表不消耗题注编号,后续普通题注编号正确递增。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p1 = self._make_paragraph("表5.1 岗位信息表") @@ -516,9 +553,11 @@ def test_continued_table_does_not_increment_counter(self, caption_yaml): heading = self._make_heading_node(children=[tab1, tab_continued, tab2]) config = self._init_config(caption_yaml) - with patch.object(tab1, "add_comment"), \ - patch.object(tab_continued, "add_comment"), \ - patch.object(tab2, "add_comment"): + with ( + patch.object(tab1, "add_comment"), + patch.object(tab_continued, "add_comment"), + patch.object(tab2, "add_comment"), + ): apply_format_check_to_all_nodes(heading, doc, config, check=True) # 表5.1: 章节号5,序号1 @@ -534,7 +573,11 @@ def test_continued_table_does_not_increment_counter(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_continued_table_apply_mode_preserves_prefix(self, caption_yaml): """apply 模式:续表文本保留续前缀。""" - from wordformat.pipeline.stages import FormattingExecutionStage; apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes + from wordformat.pipeline.stages import FormattingExecutionStage + + apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes + ) doc = Document() p = self._make_paragraph("续表5.3 API接口测试结果") @@ -546,6 +589,7 @@ def test_continued_table_apply_mode_preserves_prefix(self, caption_yaml): assert p.text == "续表5.3 API接口测试结果" + class TestCheckCaptionNumbering: """直接测试 _check_caption_numbering 函数。""" @@ -744,6 +788,7 @@ def test_continued_table_label_space_wrong(self): # ======================== _apply_caption_numbering ======================== + class TestReplaceParagraphText: def test_single_run(self): from wordformat.rules.caption import _replace_paragraph_text @@ -785,6 +830,7 @@ def test_replace_paragraph_text_empty_runs(self): """空 runs 列表不抛异常。""" from docx import Document from wordformat.rules.caption import _replace_paragraph_text + doc = Document() p = doc.add_paragraph() # 没有 runs — 不应抛异常 @@ -793,17 +839,26 @@ def test_replace_paragraph_text_empty_runs(self): def test_check_caption_numbering_no_paragraph(self): """paragraph 为 None 时直接返回。""" from wordformat.rules.caption import _check_caption_numbering, CaptionFigure + node = CaptionFigure(value={"category": "test"}, level=0) node.paragraph = None # 不应抛异常 - _check_caption_numbering(node, None, "图", {"enabled": True, "separator": ".", "label_number_space": False}) + _check_caption_numbering( + node, + None, + "图", + {"enabled": True, "separator": ".", "label_number_space": False}, + ) def test_apply_caption_numbering_no_paragraph(self): """paragraph 为 None 时直接返回。""" from wordformat.rules.caption import _apply_caption_numbering, CaptionFigure + node = CaptionFigure(value={"category": "test"}, level=0) node.paragraph = None - _apply_caption_numbering(node, "图", {"enabled": True, "separator": ".", "label_number_space": False}) + _apply_caption_numbering( + node, "图", {"enabled": True, "separator": ".", "label_number_space": False} + ) class TestNodeConfigRoot: @@ -811,16 +866,19 @@ class TestNodeConfigRoot: def test_setattr(self): from wordformat.config.models import NodeConfigRoot + cfg = NodeConfigRoot() cfg.template_name = "测试" assert cfg["template_name"] == "测试" def test_getattr_missing_returns_none(self): from wordformat.config.models import NodeConfigRoot + cfg = NodeConfigRoot() assert cfg.nonexistent is None def test_model_dump(self): from wordformat.config.models import NodeConfigRoot + cfg = NodeConfigRoot(a=1, b=2) assert cfg.model_dump() == {"a": 1, "b": 2} diff --git a/tests/rules/test_heading.py b/tests/rules/test_heading.py index 554de80..cd2d137 100644 --- a/tests/rules/test_heading.py +++ b/tests/rules/test_heading.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,8 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - - class TestHeadingBug: """ NODE_TYPE 现在自动回退为 CONFIG_PATH, @@ -114,8 +115,6 @@ def test_heading_level_configs(self, root_config): # --------------------------------------------------------------------------- - - class TestHeadingLevel1NodeBase: """覆盖 heading.py HeadingLevel1Node._base 的 diff/apply 逻辑。""" @@ -142,7 +141,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_returns_issues_list(self, root_config): """返回值应为包含 issue 字典的列表。""" @@ -168,8 +167,7 @@ def test_check_skips_empty_runs(self, root_config): node.check_format(doc) # 空白 run 不应触发 comment run_comments = [ - c for c in mock_comment.call_args_list - if c.kwargs.get("runs") is r1 + c for c in mock_comment.call_args_list if c.kwargs.get("runs") is r1 ] assert len(run_comments) == 0 @@ -179,8 +177,6 @@ def test_check_skips_empty_runs(self, root_config): # --------------------------------------------------------------------------- - - class TestHeadingLevel2NodeBase: """覆盖 heading.py HeadingLevel2Node._base 的 diff/apply 逻辑。""" @@ -206,7 +202,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_returns_issues(self, root_config): """返回值应包含 issue 字典。""" @@ -224,8 +220,6 @@ def test_check_returns_issues(self, root_config): # --------------------------------------------------------------------------- - - class TestHeadingLevel3NodeBase: """覆盖 heading.py HeadingLevel3Node._base 的 diff/apply 逻辑。""" @@ -251,7 +245,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_returns_issues(self, root_config): """返回值应包含 issue 字典。""" @@ -275,6 +269,7 @@ class TestHeadingLevelNodes: def test_heading_level1_load_config_dict(self): """HeadingLevel1Node.load_config with dict (lines 36-41)""" from wordformat.rules.heading import HeadingLevel1Node + node = HeadingLevel1Node( value={"category": "headings.level_1", "fingerprint": "fp"}, level=1, @@ -294,6 +289,7 @@ def test_heading_level1_load_config_dict(self): def test_heading_level2_load_config_dict(self): """HeadingLevel2Node.load_config with dict (lines 52-58)""" from wordformat.rules.heading import HeadingLevel2Node + node = HeadingLevel2Node( value={"category": "headings.level_2", "fingerprint": "fp"}, level=2, @@ -312,6 +308,7 @@ def test_heading_level2_load_config_dict(self): def test_heading_level3_load_config_dict(self): """HeadingLevel3Node.load_config with dict""" from wordformat.rules.heading import HeadingLevel3Node + node = HeadingLevel3Node( value={"category": "headings.level_3", "fingerprint": "fp"}, level=3, @@ -331,6 +328,7 @@ def test_heading_base_with_config(self, sample_yaml_config): """_base method with loaded config""" from wordformat.config.loader import init_config, get_config from wordformat.rules.heading import HeadingLevel1Node + init_config(sample_yaml_config) config = get_config() @@ -346,6 +344,4 @@ def test_heading_base_with_config(self, sample_yaml_config): node.check_format(doc) # 通过 RULES handler 执行格式检查 - # ==================== (o) set_style.py 额外覆盖测试 ==================== - diff --git a/tests/rules/test_keywords.py b/tests/rules/test_keywords.py index 0c02284..74f3ba3 100644 --- a/tests/rules/test_keywords.py +++ b/tests/rules/test_keywords.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,8 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - - class TestKeywordsLogic: """关键词节点的标签识别、数量校验、标点校验。""" @@ -174,6 +175,7 @@ def test_en_count_validation_too_few(self, root_config): texts = [c.kwargs["text"] for c in mock_comment.call_args_list] assert any("数量过少" in t for t in texts) + # --------------------------------------------------------------------------- # 6. _base 实现实际调用 diff/apply 逻辑 # --------------------------------------------------------------------------- @@ -185,6 +187,7 @@ class TestKeywordsENBase: def _make_en_node(self, config_dict=None): """Helper to create a KeywordsEN node with config loaded""" from wordformat.rules.keywords import KeywordsEN + node = KeywordsEN( value={"category": "abstract.keywords.english", "fingerprint": "fp"}, level=1, @@ -196,6 +199,7 @@ def _make_en_node(self, config_dict=None): def test_empty_run_skip(self, sample_yaml_config): """Empty run text is skipped (line 114)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -211,6 +215,7 @@ def test_empty_run_skip(self, sample_yaml_config): def test_label_style_check(self, sample_yaml_config): """Label run style is checked (line 121)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -226,6 +231,7 @@ def test_label_style_check(self, sample_yaml_config): def test_content_style_check(self, sample_yaml_config): """Content run style is checked (lines 130-135)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -242,6 +248,7 @@ def test_content_style_check(self, sample_yaml_config): def test_keyword_count_validation_min(self, sample_yaml_config): """Keyword count < count_min triggers warning (via _run_rules)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -258,6 +265,7 @@ def test_keyword_count_validation_min(self, sample_yaml_config): def test_keyword_count_validation_max(self, sample_yaml_config): """Keyword count > count_max triggers warning (via _run_rules)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -269,4 +277,4 @@ def test_keyword_count_validation_max(self, sample_yaml_config): content_run = p.add_run("AI, ML, NLP, CV, DB, SE") node.paragraph = p node.check_format(doc) - # count_max is 5, 6 keywords -> should trigger count warning \ No newline at end of file + # count_max is 5, 6 keywords -> should trigger count warning diff --git a/tests/rules/test_node.py b/tests/rules/test_node.py index 05b3bbf..5043210 100644 --- a/tests/rules/test_node.py +++ b/tests/rules/test_node.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,8 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - - class TestFormatNodeBase: """FormatNode 基类的核心契约。""" @@ -100,39 +101,46 @@ def test_base_is_noop(self, doc, para): def test_check_format_calls_base_with_true(self, doc, para): """check_format 应以 p=True, r=True 调用 _base。""" node = FormatNodeBase(value=para, level=0, paragraph=para) - with patch.object(node, "_base") as mock_base, \ - patch.object(node, "_run_rules"): + with patch.object(node, "_base") as mock_base, patch.object(node, "_run_rules"): node.check_format(doc) mock_base.assert_called_once_with(doc, p=True, r=True) def test_apply_format_calls_base_with_false(self, doc, para): """apply_format 应以 p=False, r=False 调用 _base。""" node = FormatNodeBase(value=para, level=0, paragraph=para) - with patch.object(node, "_base") as mock_base, \ - patch.object(node, "_run_rules"): + with patch.object(node, "_base") as mock_base, patch.object(node, "_run_rules"): node.apply_format(doc) mock_base.assert_called_once_with(doc, p=False, r=False) + # --------------------------------------------------------------------------- # 2. 所有节点类型可实例化 # --------------------------------------------------------------------------- NODE_CLASSES = [ - AbstractTitleCN, AbstractTitleContentCN, AbstractContentCN, - AbstractTitleEN, AbstractTitleContentEN, AbstractContentEN, - KeywordsCN, KeywordsEN, - HeadingLevel1Node, HeadingLevel2Node, HeadingLevel3Node, + AbstractTitleCN, + AbstractTitleContentCN, + AbstractContentCN, + AbstractTitleEN, + AbstractTitleContentEN, + AbstractContentEN, + KeywordsCN, + KeywordsEN, + HeadingLevel1Node, + HeadingLevel2Node, + HeadingLevel3Node, BodyText, - CaptionFigure, CaptionTable, - References, ReferenceEntry, - Acknowledgements, AcknowledgementsCN, + CaptionFigure, + CaptionTable, + References, + ReferenceEntry, + Acknowledgements, + AcknowledgementsCN, ] @pytest.mark.parametrize("cls", NODE_CLASSES, ids=lambda c: c.__name__) - - class TestNodeInstantiation: """每个节点类型都能正确实例化。""" @@ -156,8 +164,6 @@ def test_has_node_type(self, cls): # --------------------------------------------------------------------------- - - class TestLoadConfig: """验证 load_config 正确合并 DEFAULTS 与 YAML 配置。""" @@ -202,7 +208,9 @@ def test_caption_table_content_font_size(self, root_config): node = _make_node(CaptionTable) node.load_config(root_config) # YAML: tables.content.font_size = '五号' - assert node.pydantic_config.font_size == "小四" # DEFAULTS 值(非 content 子对象) + assert ( + node.pydantic_config.font_size == "小四" + ) # DEFAULTS 值(非 content 子对象) def test_references(self, root_config): node = _make_node(References) @@ -247,8 +255,6 @@ def test_keywords_en_loads_label(self, root_config): # --------------------------------------------------------------------------- - - class TestBaseImplementation: """验证 _base 实现确实执行了 diff_from_run / apply_to_run 逻辑。""" @@ -300,4 +306,3 @@ def test_references_check_runs(self, root_config): # --------------------------------------------------------------------------- # 7. BodyText._apply_citation_superscript # --------------------------------------------------------------------------- - diff --git a/tests/rules/test_references.py b/tests/rules/test_references.py index f8234ba..caece61 100644 --- a/tests/rules/test_references.py +++ b/tests/rules/test_references.py @@ -1,6 +1,7 @@ """ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ + from unittest.mock import patch import pytest @@ -51,6 +52,7 @@ def _load_root_config(config_path): def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -59,6 +61,7 @@ def _load_yaml(path): def root_config(sample_yaml_config): """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.loader import init_config + init_config(sample_yaml_config) return _load_root_config(sample_yaml_config) @@ -86,7 +89,6 @@ def run_with_text(para): # --------------------------------------------------------------------------- - class TestReferencesBase: """覆盖 references.py References._base 的 diff/apply 逻辑。""" @@ -123,7 +125,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 # --------------------------------------------------------------------------- @@ -131,7 +133,6 @@ def test_apply_with_wrong_format(self, root_config): # --------------------------------------------------------------------------- - class TestReferenceEntryBase: """覆盖 references.py ReferenceEntry._base 的 diff/apply 逻辑。""" @@ -167,7 +168,7 @@ def test_apply_with_wrong_format(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: node.apply_format(doc) - assert mock_comment.call_count >= 1 + assert mock_comment.call_count >= 1 # apply 后 diff 仍有残留差异 def test_check_alignment_and_indent(self, root_config): """验证 alignment 和 first_line_indent 配置被正确使用。""" diff --git a/tests/style/test_defs.py b/tests/style/test_defs.py index 4dded93..a2340a8 100644 --- a/tests/style/test_defs.py +++ b/tests/style/test_defs.py @@ -15,21 +15,45 @@ from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle from wordformat.style.reader import ( - paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after, - paragraph_get_line_spacing, paragraph_get_first_line_indent, - paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt, - run_get_font_color, run_get_font_bold, run_get_font_italic, - run_get_font_underline, GetIndent, + paragraph_get_alignment, + paragraph_get_space_before, + paragraph_get_space_after, + paragraph_get_line_spacing, + paragraph_get_first_line_indent, + paragraph_get_builtin_style_name, + run_get_font_name, + run_get_font_size_pt, + run_get_font_color, + run_get_font_bold, + run_get_font_italic, + run_get_font_underline, + GetIndent, ) from wordformat.style.writer import ( - run_set_font_name, set_paragraph_space_before_by_lines, - set_paragraph_space_after_by_lines, _paragraph_space_by_lines, - SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent, + run_set_font_name, + set_paragraph_space_before_by_lines, + set_paragraph_space_after_by_lines, + _paragraph_space_by_lines, + SetSpacing, + SetLineSpacing, + SetIndent, + SetFirstLineIndent, ) from wordformat.style.defs import ( - FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing, - FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter, - Spacing, UnitLabelEnum, + FontName, + FontSize, + FontColor, + Alignment, + LineSpacingRule, + LineSpacing, + FirstLineIndent, + LeftIndent, + RightIndent, + BuiltInStyle, + SpaceBefore, + SpaceAfter, + Spacing, + UnitLabelEnum, ) from wordformat.style.units import extract_unit_from_string, UnitResult @@ -38,6 +62,7 @@ # fixtures & helpers # --------------------------------------------------------------------------- + @pytest.fixture def doc(): return Document() @@ -46,10 +71,23 @@ def doc(): @pytest.fixture def mock_warning(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, True) return w @@ -57,21 +95,36 @@ def mock_warning(): @pytest.fixture def mock_warning_off(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, False) return w def _set_warning(w): import wordformat.style.diff as m + m.style_checks_warning = w def _clear_warning(): import wordformat.style.diff as m + m.__dict__.pop("style_checks_warning", None) @@ -79,10 +132,19 @@ def _clear_warning(): # style_enum - FontSize # =========================================================================== + class TestFontSize: - @pytest.mark.parametrize("label,expected", [ - ("小四", 12), ("四号", 14), ("三号", 16), ("五号", 10.5), ("一号", 26), ("七号", 5.5), - ]) + @pytest.mark.parametrize( + "label,expected", + [ + ("小四", 12), + ("四号", 14), + ("三号", 16), + ("五号", 10.5), + ("一号", 26), + ("七号", 5.5), + ], + ) def test_label_map(self, label, expected): assert FontSize(label).rel_value == expected @@ -100,18 +162,27 @@ def test_base_set_invalid_raises(self, doc): FontSize("abc").base_set(doc.add_paragraph().add_run("x")) - # =========================================================================== # style_enum - FontColor # =========================================================================== + class TestFontColor: - @pytest.mark.parametrize("spec,expected", [ - ("BLACK", (0, 0, 0)), ("black", (0, 0, 0)), ("黑色", (0, 0, 0)), - ("red", (255, 0, 0)), ("红色", (255, 0, 0)), - ("#FF0000", (255, 0, 0)), ("#f00", (255, 0, 0)), ("FF0000", (255, 0, 0)), - ("white", (255, 255, 255)), ("白色", (255, 255, 255)), - ]) + @pytest.mark.parametrize( + "spec,expected", + [ + ("BLACK", (0, 0, 0)), + ("black", (0, 0, 0)), + ("黑色", (0, 0, 0)), + ("red", (255, 0, 0)), + ("红色", (255, 0, 0)), + ("#FF0000", (255, 0, 0)), + ("#f00", (255, 0, 0)), + ("FF0000", (255, 0, 0)), + ("white", (255, 255, 255)), + ("白色", (255, 255, 255)), + ], + ) def test_parse_color(self, spec, expected): assert FontColor(spec).rel_value == expected @@ -137,7 +208,9 @@ def test_non_string_raises(self): with pytest.raises(TypeError): FontColor(123).rel_value - @pytest.mark.parametrize("h,ok", [("#f00", True), ("#FF0000", True), ("FF0000", True), ("#GGG", False)]) + @pytest.mark.parametrize( + "h,ok", [("#f00", True), ("#FF0000", True), ("FF0000", True), ("#GGG", False)] + ) def test_is_hex(self, h, ok): assert FontColor._is_hex(h) == ok @@ -146,16 +219,21 @@ def test_normalize_hex(self): assert FontColor._normalize_hex("AABBCC") == "#aabbcc" - # =========================================================================== # style_enum - Alignment / LineSpacingRule / LineSpacing / BuiltInStyle / FontName # =========================================================================== + class TestAlignmentEnum: - @pytest.mark.parametrize("label,val", [ - ("左对齐", WD_ALIGN_PARAGRAPH.LEFT), ("居中对齐", WD_ALIGN_PARAGRAPH.CENTER), - ("右对齐", WD_ALIGN_PARAGRAPH.RIGHT), ("两端对齐", WD_ALIGN_PARAGRAPH.JUSTIFY), - ]) + @pytest.mark.parametrize( + "label,val", + [ + ("左对齐", WD_ALIGN_PARAGRAPH.LEFT), + ("居中对齐", WD_ALIGN_PARAGRAPH.CENTER), + ("右对齐", WD_ALIGN_PARAGRAPH.RIGHT), + ("两端对齐", WD_ALIGN_PARAGRAPH.JUSTIFY), + ], + ) def test_label_map(self, label, val): assert Alignment(label).rel_value == val @@ -166,25 +244,32 @@ def test_base_set_and_get(self, doc): assert Alignment("左对齐").get_from_paragraph(p) == WD_ALIGN_PARAGRAPH.CENTER def test_get_fallback_left(self, doc): - assert Alignment("左对齐").get_from_paragraph(doc.add_paragraph()) == WD_ALIGN_PARAGRAPH.LEFT + assert ( + Alignment("左对齐").get_from_paragraph(doc.add_paragraph()) + == WD_ALIGN_PARAGRAPH.LEFT + ) def test_invalid_raises(self, doc): with pytest.raises(ValueError, match="无效的对齐方式"): Alignment("无效").base_set(doc.add_paragraph()) - class TestLineSpacingRuleEnum: - @pytest.mark.parametrize("label,val", [ - ("单倍行距", WD_LINE_SPACING.SINGLE), ("1.5倍行距", WD_LINE_SPACING.ONE_POINT_FIVE), - ("2倍行距", WD_LINE_SPACING.DOUBLE), ("固定值", WD_LINE_SPACING.EXACTLY), - ("最小值", WD_LINE_SPACING.AT_LEAST), ("多倍行距", WD_LINE_SPACING.MULTIPLE), - ]) + @pytest.mark.parametrize( + "label,val", + [ + ("单倍行距", WD_LINE_SPACING.SINGLE), + ("1.5倍行距", WD_LINE_SPACING.ONE_POINT_FIVE), + ("2倍行距", WD_LINE_SPACING.DOUBLE), + ("固定值", WD_LINE_SPACING.EXACTLY), + ("最小值", WD_LINE_SPACING.AT_LEAST), + ("多倍行距", WD_LINE_SPACING.MULTIPLE), + ], + ) def test_label_map(self, label, val): assert LineSpacingRule(label).rel_value == val - class TestLineSpacingEnum: def test_rel_value(self): assert LineSpacing("1.5倍").rel_value == 1.5 @@ -205,9 +290,11 @@ def test_zero_should_raise(self, doc): LineSpacing("0倍").base_set(doc.add_paragraph()) - class TestBuiltInStyleEnum: - @pytest.mark.parametrize("label,expected", [("正文", "Normal"), ("标题", "Title"), ("Heading 1", "Heading 1")]) + @pytest.mark.parametrize( + "label,expected", + [("正文", "Normal"), ("标题", "Title"), ("Heading 1", "Heading 1")], + ) def test_label_map(self, label, expected): assert BuiltInStyle(label).rel_value == expected @@ -226,11 +313,16 @@ def test_base_set_invalid_raises(self, doc): assert p.style.name == "NonExistent" - class TestFontNameEnum: - @pytest.mark.parametrize("name,is_cn", [ - ("宋体", True), ("黑体", True), ("Times New Roman", False), ("Arial", False), - ]) + @pytest.mark.parametrize( + "name,is_cn", + [ + ("宋体", True), + ("黑体", True), + ("Times New Roman", False), + ("Arial", False), + ], + ) def test_is_chinese(self, name, is_cn): assert FontName(name).is_chinese(name) == is_cn @@ -245,7 +337,6 @@ def test_base_set_english(self, doc): assert run.font.name == "Arial" - class TestUnitLabelEnumEq: def test_same_class_same_rel(self): assert FontSize("小四") == FontSize("小四") @@ -263,19 +354,29 @@ def test_different_class(self): assert FontSize("小四") != Alignment("左对齐") - # =========================================================================== # utils # =========================================================================== + class TestExtractUnitFromString: - @pytest.mark.parametrize("text,val,unit", [ - ("12pt", 12.0, "pt"), ("1.5磅", 1.5, "pt"), ("2cm", 2.0, "cm"), - ("3.5厘米", 3.5, "cm"), ("1inch", 1.0, "inch"), ("0.5英寸", 0.5, "inch"), - ("10mm", 10.0, "mm"), ("2毫米", 2.0, "mm"), - ("1.5行", 1.5, "hang"), ("2字符", 2.0, "char"), ("1.5倍", 1.5, "bei"), - ("100emu", 100.0, "emu"), - ]) + @pytest.mark.parametrize( + "text,val,unit", + [ + ("12pt", 12.0, "pt"), + ("1.5磅", 1.5, "pt"), + ("2cm", 2.0, "cm"), + ("3.5厘米", 3.5, "cm"), + ("1inch", 1.0, "inch"), + ("0.5英寸", 0.5, "inch"), + ("10mm", 10.0, "mm"), + ("2毫米", 2.0, "mm"), + ("1.5行", 1.5, "hang"), + ("2字符", 2.0, "char"), + ("1.5倍", 1.5, "bei"), + ("100emu", 100.0, "emu"), + ], + ) def test_valid(self, text, val, unit): r = extract_unit_from_string(text) assert r.is_valid and r.value == val and r.standard_unit == unit @@ -288,32 +389,58 @@ def test_case_insensitive(self): assert extract_unit_from_string("12PT").standard_unit == "pt" - class TestUnitResult: def test_to_dict(self): - r = UnitResult(original_unit="pt", standard_unit="pt", value=12.0, is_valid=True) + r = UnitResult( + original_unit="pt", standard_unit="pt", value=12.0, is_valid=True + ) assert r.to_dict()["value"] == 12.0 - @pytest.mark.parametrize("unit,val,emu", [ - ("pt", 1, 12700), ("cm", 1, 360000), ("inch", 1, 914400), ("mm", 1, 36000), ("emu", 1, 1), - ]) + @pytest.mark.parametrize( + "unit,val,emu", + [ + ("pt", 1, 12700), + ("cm", 1, 360000), + ("inch", 1, 914400), + ("mm", 1, 36000), + ("emu", 1, 1), + ], + ) def test_convert_to_emu(self, unit, val, emu): - assert UnitResult(standard_unit=unit, value=val, is_valid=True).convert_to_emu() == emu + assert ( + UnitResult(standard_unit=unit, value=val, is_valid=True).convert_to_emu() + == emu + ) def test_convert_to_emu_hang_bug(self): """hang 单位现在返回 None 而非 0。""" - assert UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu() is None + assert ( + UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu() + is None + ) def test_convert_to_emu_hang_should_be_none(self): - assert UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu() is None + assert ( + UnitResult(standard_unit="hang", value=1.5, is_valid=True).convert_to_emu() + is None + ) def test_convert_to_emu_invalid(self): assert UnitResult(is_valid=False).convert_to_emu() is None - @pytest.mark.parametrize("unit,ch", [ - ("pt", "磅"), ("cm", "厘米"), ("inch", "英寸"), ("mm", "毫米"), - ("emu", "emu"), ("hang", "行"), ("char", "字符"), ("bei", "倍"), - ]) + @pytest.mark.parametrize( + "unit,ch", + [ + ("pt", "磅"), + ("cm", "厘米"), + ("inch", "英寸"), + ("mm", "毫米"), + ("emu", "emu"), + ("hang", "行"), + ("char", "字符"), + ("bei", "倍"), + ], + ) def test_unit_ch(self, unit, ch): assert UnitResult(standard_unit=unit).unit_ch == ch @@ -325,7 +452,6 @@ def test_unit_ch_invalid_raises(self): UnitResult(standard_unit="xyz").unit_ch - # =========================================================================== # Coverage: style_enum.py uncovered lines # =========================================================================== @@ -342,12 +468,12 @@ def test_rel_value_setter(self): assert e.rel_value == 99 - class TestUnitLabelEnumBaseSetDefault: """Cover line 135: base_set default implementation (just logs debug)""" def test_base_set_default_no_crash(self, doc): """UnitLabelEnum.base_set default just logs, no crash""" + # Use a bare UnitLabelEnum instance - but it's abstract due to get_from_paragraph # So we create a concrete subclass that doesn't override base_set class MinimalEnum(UnitLabelEnum): @@ -359,7 +485,6 @@ def get_from_paragraph(self, paragraph): e.base_set(doc.add_paragraph()) - class TestUnitLabelEnumFormatWithRun: """Cover lines 152-155: format() method with Run object (not Paragraph)""" @@ -382,12 +507,12 @@ def test_format_no_fun_falls_to_base_set(self, doc): assert run_get_font_name(run) == "宋体" - class TestUnitLabelEnumGetFromParagraphAbstract: """Cover line 163: get_from_paragraph abstract method raises NotImplementedError""" def test_abstract_raises_not_implemented(self, doc): """Calling get_from_paragraph on base UnitLabelEnum raises NotImplementedError""" + # Can't instantiate UnitLabelEnum directly due to ABC, use a minimal subclass class MinimalEnum(UnitLabelEnum): pass @@ -397,7 +522,6 @@ class MinimalEnum(UnitLabelEnum): e.get_from_paragraph(doc.add_paragraph()) - class TestFontColorParseColorValueError: """Cover lines 304-305: FontColor._parse_color ValueError for invalid hex""" @@ -409,6 +533,7 @@ def test_parse_color_invalid_hex_raises(self): # Actually, all valid 6-digit hex are valid RGB colors, so this path is hard to trigger # We'll test with a mock to force the ValueError import webcolors + original = webcolors.hex_to_rgb webcolors.hex_to_rgb = MagicMock(side_effect=ValueError("mock error")) try: @@ -418,7 +543,6 @@ def test_parse_color_invalid_hex_raises(self): webcolors.hex_to_rgb = original - class TestFontColorBaseSetNonString: """Cover line 330: FontColor.base_set with non-string value raises TypeError""" @@ -436,16 +560,15 @@ def test_base_set_non_string_raises(self, doc): fc.base_set(run) - class TestFontColorEqEdgeCases: """Cover lines 344-345: FontColor.__eq__ with non-tuple or wrong length tuple""" def test_eq_non_tuple_returns_false(self): """__eq__ with non-color values returns False""" fc = FontColor("red") - assert fc == "red" # 字符串颜色名现在被正确比较 - assert fc != 42 # 非颜色应返回 False - assert fc != None # None 应返回 False + assert fc == "red" # 字符串颜色名现在被正确比较 + assert fc != 42 # 非颜色应返回 False + assert fc != None # None 应返回 False def test_eq_wrong_length_tuple_returns_false(self): """__eq__ with tuple of wrong length returns False (line 339-340)""" @@ -466,24 +589,30 @@ def test_eq_parse_error_returns_false(self): assert fc != (255, 0, 0) - class TestSpacingBaseSetHangNotImplemented: """Cover lines 400-404: Spacing.base_set raises NotImplementedError for 'hang' unit""" def test_spacing_get_from_paragraph_hang_raises(self, doc): """Spacing.get_from_paragraph with 'hang' unit raises NotImplementedError""" s = Spacing("1行") - with pytest.raises(NotImplementedError, match="Spacing 需要知道是 before 还是 after"): + with pytest.raises( + NotImplementedError, match="Spacing 需要知道是 before 还是 after" + ): s.get_from_paragraph(doc.add_paragraph()) - class TestSpaceBeforeGetFromParagraphUnits: """Cover lines 412-416: SpaceBefore.get_from_paragraph with pt/mm/cm/inch units""" - @pytest.mark.parametrize("unit,val,attr", [ - ("pt", 12, "pt"), ("mm", 5, "mm"), ("cm", 1, "cm"), ("inch", 0.5, "inches"), - ]) + @pytest.mark.parametrize( + "unit,val,attr", + [ + ("pt", 12, "pt"), + ("mm", 5, "mm"), + ("cm", 1, "cm"), + ("inch", 0.5, "inches"), + ], + ) def test_get_from_paragraph_physical_units(self, doc, unit, val, attr): """SpaceBefore.get_from_paragraph returns value in specified physical unit""" p = doc.add_paragraph() @@ -491,6 +620,7 @@ def test_get_from_paragraph_physical_units(self, doc, unit, val, attr): if unit != "pt": # Set via EMU conversion from docx.shared import Emu + if unit == "mm": p.paragraph_format.space_before = Emu(36000 * val) elif unit == "cm": @@ -519,17 +649,23 @@ def test_get_from_paragraph_none(self, doc): assert result is None - class TestSpaceAfterGetFromParagraphUnits: """Cover lines 424-428: SpaceAfter.get_from_paragraph with pt/mm/cm/inch units""" - @pytest.mark.parametrize("unit,val", [ - ("pt", 12), ("mm", 5), ("cm", 1), ("inch", 0.5), - ]) + @pytest.mark.parametrize( + "unit,val", + [ + ("pt", 12), + ("mm", 5), + ("cm", 1), + ("inch", 0.5), + ], + ) def test_get_from_paragraph_physical_units(self, doc, unit, val): """SpaceAfter.get_from_paragraph returns value in specified physical unit""" p = doc.add_paragraph() from docx.shared import Emu + if unit == "pt": p.paragraph_format.space_after = Pt(val) elif unit == "mm": @@ -559,7 +695,6 @@ def test_get_from_paragraph_none(self, doc): assert result is None - class TestLineSpacingRuleBaseSetInvalid: """Cover lines 447-451: LineSpacingRule.base_set with invalid value raises ValueError""" @@ -577,7 +712,6 @@ def test_base_set_invalid_raises(self, doc): lsr.base_set(p) - class TestLineSpacingBaseSetInvalidValue: """Cover line 489: LineSpacing.base_set with invalid value raises ValueError""" @@ -608,17 +742,23 @@ def test_base_set_none_raises(self, doc): ls.base_set(p) - class TestLeftIndentGetFromParagraphUnits: """Cover lines 513-517: LeftIndent.get_from_paragraph with pt/mm/cm/inch units""" - @pytest.mark.parametrize("unit,val", [ - ("pt", 12), ("mm", 5), ("cm", 1), ("inch", 0.5), - ]) + @pytest.mark.parametrize( + "unit,val", + [ + ("pt", 12), + ("mm", 5), + ("cm", 1), + ("inch", 0.5), + ], + ) def test_get_from_paragraph_physical_units(self, doc, unit, val): """LeftIndent.get_from_paragraph returns value in specified physical unit""" p = doc.add_paragraph() from docx.shared import Emu + if unit == "pt": p.paragraph_format.left_indent = Pt(val) elif unit == "mm": @@ -640,17 +780,23 @@ def test_get_from_paragraph_none(self, doc): assert result is None - class TestRightIndentGetFromParagraphUnits: """Cover lines 525-529: RightIndent.get_from_paragraph with pt/mm/cm/inch units""" - @pytest.mark.parametrize("unit,val", [ - ("pt", 12), ("mm", 5), ("cm", 1), ("inch", 0.5), - ]) + @pytest.mark.parametrize( + "unit,val", + [ + ("pt", 12), + ("mm", 5), + ("cm", 1), + ("inch", 0.5), + ], + ) def test_get_from_paragraph_physical_units(self, doc, unit, val): """RightIndent.get_from_paragraph returns value in specified physical unit""" p = doc.add_paragraph() from docx.shared import Emu + if unit == "pt": p.paragraph_format.right_indent = Pt(val) elif unit == "mm": @@ -672,17 +818,23 @@ def test_get_from_paragraph_none(self, doc): assert result is None - class TestFirstLineIndentGetFromParagraphUnits: """Cover lines 548-552: FirstLineIndent.get_from_paragraph with pt/mm/cm/inch units""" - @pytest.mark.parametrize("unit,val", [ - ("pt", 24), ("mm", 5), ("cm", 1), ("inch", 0.5), - ]) + @pytest.mark.parametrize( + "unit,val", + [ + ("pt", 24), + ("mm", 5), + ("cm", 1), + ("inch", 0.5), + ], + ) def test_get_from_paragraph_physical_units(self, doc, unit, val): """FirstLineIndent.get_from_paragraph returns value in specified physical unit""" p = doc.add_paragraph() from docx.shared import Emu + if unit == "pt": p.paragraph_format.first_line_indent = Pt(val) elif unit == "mm": @@ -704,10 +856,9 @@ def test_get_from_paragraph_none(self, doc): assert result is None - class TestBuiltInStyleBaseSetElseBranch: """Cover lines 594-595: BuiltInStyle.base_set with value NOT in _LABEL_MAP (else branch) - Cover line 600: BuiltInStyle.base_set with invalid style raises ValueError""" + Cover line 600: BuiltInStyle.base_set with invalid style raises ValueError""" def test_base_set_else_branch_valid_style(self, doc): """base_set with value not in _LABEL_MAP but valid docx style (else branch, line 596-600)""" @@ -770,4 +921,3 @@ def test_base_set_if_branch_invalid_style_raises(self, doc): # This should succeed since Normal is a valid style bis.base_set(p) assert p.style.name == "Normal" - diff --git a/tests/style/test_diff.py b/tests/style/test_diff.py index 85b17d8..70f2aca 100644 --- a/tests/style/test_diff.py +++ b/tests/style/test_diff.py @@ -15,21 +15,45 @@ from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle from wordformat.style.reader import ( - paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after, - paragraph_get_line_spacing, paragraph_get_first_line_indent, - paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt, - run_get_font_color, run_get_font_bold, run_get_font_italic, - run_get_font_underline, GetIndent, + paragraph_get_alignment, + paragraph_get_space_before, + paragraph_get_space_after, + paragraph_get_line_spacing, + paragraph_get_first_line_indent, + paragraph_get_builtin_style_name, + run_get_font_name, + run_get_font_size_pt, + run_get_font_color, + run_get_font_bold, + run_get_font_italic, + run_get_font_underline, + GetIndent, ) from wordformat.style.writer import ( - run_set_font_name, set_paragraph_space_before_by_lines, - set_paragraph_space_after_by_lines, _paragraph_space_by_lines, - SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent, + run_set_font_name, + set_paragraph_space_before_by_lines, + set_paragraph_space_after_by_lines, + _paragraph_space_by_lines, + SetSpacing, + SetLineSpacing, + SetIndent, + SetFirstLineIndent, ) from wordformat.style.defs import ( - FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing, - FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter, - Spacing, UnitLabelEnum, + FontName, + FontSize, + FontColor, + Alignment, + LineSpacingRule, + LineSpacing, + FirstLineIndent, + LeftIndent, + RightIndent, + BuiltInStyle, + SpaceBefore, + SpaceAfter, + Spacing, + UnitLabelEnum, ) from wordformat.style.units import extract_unit_from_string, UnitResult @@ -38,6 +62,7 @@ # fixtures & helpers # --------------------------------------------------------------------------- + @pytest.fixture def doc(): return Document() @@ -46,10 +71,23 @@ def doc(): @pytest.fixture def mock_warning(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, True) return w @@ -57,21 +95,36 @@ def mock_warning(): @pytest.fixture def mock_warning_off(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, False) return w def _set_warning(w): import wordformat.style.diff as m + m._warnings = w def _clear_warning(): import wordformat.style.diff as m + m._warnings = m._load_warnings() @@ -79,6 +132,7 @@ def _clear_warning(): # DIFFResult # =========================================================================== + class TestDIFFResult: def test_defaults_and_str(self): d = DIFFResult() @@ -92,11 +146,11 @@ def test_level(self, lvl): assert DIFFResult(level=lvl).level == lvl - # =========================================================================== # CharacterStyle # =========================================================================== + class TestCharacterStyle: def test_defaults(self, mock_warning): _set_warning(mock_warning) @@ -116,13 +170,22 @@ def test_diff_from_run_no_diff_when_matching(self, doc, mock_warning): run_set_font_name(run, "宋体") run.font.name = "Times New Roman" types = [d.diff_type for d in cs.diff_from_run(run)] - assert "bold" not in types and "italic" not in types and "underline" not in types + assert ( + "bold" not in types and "italic" not in types and "underline" not in types + ) _clear_warning() - @pytest.mark.parametrize("prop,expected_val,current_val", [ - ("bold", False, True), ("italic", False, True), ("underline", False, True), - ]) - def test_diff_boolean_mismatch(self, doc, mock_warning, prop, expected_val, current_val): + @pytest.mark.parametrize( + "prop,expected_val,current_val", + [ + ("bold", False, True), + ("italic", False, True), + ("underline", False, True), + ], + ) + def test_diff_boolean_mismatch( + self, doc, mock_warning, prop, expected_val, current_val + ): _set_warning(mock_warning) cs = CharacterStyle(**{prop: expected_val}) run = doc.add_paragraph().add_run("t") @@ -139,7 +202,12 @@ def test_diff_font_size_and_name_cn(self, doc, mock_warning): run_set_font_name(run, "黑体") types = [d.diff_type for d in cs.diff_from_run(run)] assert "font_size" in types - assert next(d for d in cs.diff_from_run(run) if d.diff_type == "font_name_cn").current_value == "黑体" + assert ( + next( + d for d in cs.diff_from_run(run) if d.diff_type == "font_name_cn" + ).current_value + == "黑体" + ) _clear_warning() def test_apply_to_run_fixes_bold(self, doc, mock_warning): @@ -160,11 +228,11 @@ def test_to_string_filters_by_warning(self, mock_warning, mock_warning_off): _clear_warning() - # =========================================================================== # ParagraphStyle # =========================================================================== + class TestParagraphStyle: def test_defaults(self, mock_warning): _set_warning(mock_warning) @@ -183,14 +251,20 @@ def test_diff_detects_alignment(self, doc, mock_warning): _set_warning(mock_warning) p = doc.add_paragraph() p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER - assert "alignment" in [d.diff_type for d in ParagraphStyle(alignment="左对齐").diff_from_paragraph(p)] + assert "alignment" in [ + d.diff_type + for d in ParagraphStyle(alignment="左对齐").diff_from_paragraph(p) + ] _clear_warning() def test_diff_builtin_style_name_match(self, doc, mock_warning): """BuiltInStyle('正文').rel_value='Normal' 与 'normal' 应视为一致。""" _set_warning(mock_warning) p = doc.add_paragraph() - types = [d.diff_type for d in ParagraphStyle(builtin_style_name="正文").diff_from_paragraph(p)] + types = [ + d.diff_type + for d in ParagraphStyle(builtin_style_name="正文").diff_from_paragraph(p) + ] assert "builtin_style_name" not in types # 英文"normal" = 中文"正文" _clear_warning() @@ -214,7 +288,6 @@ def test_from_config(self, mock_warning): _clear_warning() - # =========================================================================== # Additional coverage tests for check_format.py # =========================================================================== @@ -228,7 +301,6 @@ def test_level_string(self): assert d.level == "warning" - class TestParagraphStyleApplyToParagraph: """Cover lines 315-358: ParagraphStyle.apply_to_paragraph with various indent types""" @@ -266,9 +338,12 @@ def test_apply_unknown_diff_type_skipped(self, doc, mock_warning): ps = ParagraphStyle() # Force an unknown diff_type by monkey-patching from wordformat.style.diff import DIFFResult + original_diff = ps.diff_from_paragraph + def fake_diff(paragraph): return [DIFFResult(diff_type="unknown_type", comment="test")] + ps.diff_from_paragraph = fake_diff result = ps.apply_to_paragraph(p) # unknown_type should be skipped (continue), so result should be empty @@ -286,7 +361,6 @@ def test_apply_builtin_style_name(self, doc, mock_warning): _clear_warning() - class TestParagraphStyleFromConfigExtended: """Cover line 478: ParagraphStyle.from_config with real config""" @@ -327,7 +401,6 @@ def test_from_config_partial_fields(self, mock_warning): _clear_warning() - # =========================================================================== # Additional coverage tests for check_format.py (CharacterStyle / ParagraphStyle) # =========================================================================== @@ -350,7 +423,6 @@ def test_init_loads_warning_from_config(self, config_path): m._warnings = m._load_warnings() - class TestCharacterStyleDiffFontColor: """Cover line 157: CharacterStyle.diff_from_run font_color diff""" @@ -365,7 +437,6 @@ def test_diff_font_color_mismatch(self, doc, mock_warning): _clear_warning() - class TestCharacterStyleApplyToRunItalic: """Cover lines 207-208: CharacterStyle.apply_to_run 'italic' case""" @@ -383,7 +454,6 @@ def test_apply_to_run_fixes_italic(self, doc, mock_warning): _clear_warning() - class TestCharacterStyleApplyToRunUnderline: """Cover lines 212-213: CharacterStyle.apply_to_run 'underline' case""" @@ -401,7 +471,6 @@ def test_apply_to_run_fixes_underline(self, doc, mock_warning): _clear_warning() - class TestCharacterStyleApplyToRunFontColor: """Cover lines 218-219: CharacterStyle.apply_to_run 'font_color' case""" @@ -418,7 +487,6 @@ def test_apply_to_run_fixes_font_color(self, doc, mock_warning): _clear_warning() - class TestCharacterStyleApplyToRunFontNameCn: """Cover lines 226-227: CharacterStyle.apply_to_run 'font_name_cn' case""" @@ -436,7 +504,6 @@ def test_apply_to_run_fixes_font_name_cn(self, doc, mock_warning): _clear_warning() - class TestCharacterStyleToStringNone: """Cover line 244: CharacterStyle.to_string with style_checks_warning is None""" @@ -445,7 +512,9 @@ def test_to_string_all_warnings_enabled(self): import dataclasses import wordformat.style.diff as m - m._warnings = m.WarningConfig(**{f.name: True for f in dataclasses.fields(m.WarningConfig)}) + m._warnings = m.WarningConfig( + **{f.name: True for f in dataclasses.fields(m.WarningConfig)} + ) diffs = [ DIFFResult(diff_type="bold", current_value=True, expected_value=False), DIFFResult(diff_type="italic", current_value=True, expected_value=False), @@ -456,7 +525,6 @@ def test_to_string_all_warnings_enabled(self): assert "测试" in result - class TestCharacterStyleToStringBoldFilter: """Cover line 250: CharacterStyle.to_string with style_checks_warning.bold = True""" @@ -469,14 +537,15 @@ def test_to_string_bold_filtered_in(self, mock_warning): _clear_warning() - class TestCharacterStyleToStringVariousFilters: """Cover lines 252, 254, 256: CharacterStyle.to_string with italic/underline/font_size/font_color/font_name filters""" def test_to_string_italic_filtered(self, mock_warning): """warning.italic=True 时 italic diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="italic", current_value=True, expected_value=False)] + diffs = [ + DIFFResult(diff_type="italic", current_value=True, expected_value=False) + ] result = CharacterStyle.to_string(diffs) assert "斜体错误" in result _clear_warning() @@ -484,7 +553,9 @@ def test_to_string_italic_filtered(self, mock_warning): def test_to_string_font_size_filtered(self, mock_warning): """warning.font_size=True 时 font_size diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="font_size", current_value=10.0, expected_value=12.0)] + diffs = [ + DIFFResult(diff_type="font_size", current_value=10.0, expected_value=12.0) + ] result = CharacterStyle.to_string(diffs) assert "字号错误" in result _clear_warning() @@ -492,7 +563,11 @@ def test_to_string_font_size_filtered(self, mock_warning): def test_to_string_font_color_filtered(self, mock_warning): """warning.font_color=True 时 font_color diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="font_color", current_value="红色", expected_value="黑色")] + diffs = [ + DIFFResult( + diff_type="font_color", current_value="红色", expected_value="黑色" + ) + ] result = CharacterStyle.to_string(diffs) assert "字体颜色错误" in result _clear_warning() @@ -500,13 +575,16 @@ def test_to_string_font_color_filtered(self, mock_warning): def test_to_string_font_name_filtered(self, mock_warning): """warning.font_name=True 时 font_name_cn diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="font_name_cn", current_value="宋体", expected_value="黑体")] + diffs = [ + DIFFResult( + diff_type="font_name_cn", current_value="宋体", expected_value="黑体" + ) + ] result = CharacterStyle.to_string(diffs) assert "中文字体错误" in result _clear_warning() - class TestParagraphStyleToStringAllEnabled: """ParagraphStyle.to_string — warnings 全开时不过滤任何 diff。""" @@ -515,12 +593,17 @@ def test_to_string_all_warnings_enabled(self): import dataclasses import wordformat.style.diff as m - m._warnings = m.WarningConfig(**{f.name: True for f in dataclasses.fields(m.WarningConfig)}) + m._warnings = m.WarningConfig( + **{f.name: True for f in dataclasses.fields(m.WarningConfig)} + ) diffs = [ - DIFFResult(diff_type="alignment", current_value="左对齐", expected_value="居中对齐"), - DIFFResult(diff_type="space_before", current_value="0行", expected_value="0.5行"), + DIFFResult( + diff_type="alignment", current_value="左对齐", expected_value="居中对齐" + ), + DIFFResult( + diff_type="space_before", current_value="0行", expected_value="0.5行" + ), ] result = ParagraphStyle.to_string(diffs) assert "对齐错误" in result assert "段前间距错误" in result - diff --git a/tests/style/test_inheritance.py b/tests/style/test_inheritance.py index 22c9939..88f9a5f 100644 --- a/tests/style/test_inheritance.py +++ b/tests/style/test_inheritance.py @@ -211,7 +211,9 @@ def test_multiple_custom(self, doc): assert paragraph_get_line_spacing(p) == 2.3 assert paragraph_get_line_spacing_rule(p) == WD_LINE_SPACING.MULTIPLE - @pytest.mark.parametrize("rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST]) + @pytest.mark.parametrize( + "rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST] + ) def test_fixed_returns_none(self, doc, rule): p = doc.add_paragraph() p.paragraph_format.line_spacing_rule = rule @@ -274,6 +276,7 @@ def test_size_invalid(self, doc): sz.set(qn("w:val"), "abc") rPr.append(sz) from wordformat.style.inheritance import _MISS + assert x_size_pt(rPr) is _MISS def test_theme_ref_font(self, doc): @@ -284,6 +287,7 @@ def test_theme_ref_font(self, doc): rFonts.set(qn("w:asciiTheme"), "majorHAnsi") rPr.append(rFonts) from wordformat.style.inheritance import x_font_ascii + assert isinstance(x_font_ascii(rPr), ThemeRef) assert run_get_font_name_en(r) == "Calibri" diff --git a/tests/style/test_reader.py b/tests/style/test_reader.py index 554cae1..a47f0a1 100644 --- a/tests/style/test_reader.py +++ b/tests/style/test_reader.py @@ -15,21 +15,45 @@ from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle from wordformat.style.reader import ( - paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after, - paragraph_get_line_spacing, paragraph_get_first_line_indent, - paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt, - run_get_font_color, run_get_font_bold, run_get_font_italic, - run_get_font_underline, GetIndent, + paragraph_get_alignment, + paragraph_get_space_before, + paragraph_get_space_after, + paragraph_get_line_spacing, + paragraph_get_first_line_indent, + paragraph_get_builtin_style_name, + run_get_font_name, + run_get_font_size_pt, + run_get_font_color, + run_get_font_bold, + run_get_font_italic, + run_get_font_underline, + GetIndent, ) from wordformat.style.writer import ( - run_set_font_name, set_paragraph_space_before_by_lines, - set_paragraph_space_after_by_lines, _paragraph_space_by_lines, - SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent, + run_set_font_name, + set_paragraph_space_before_by_lines, + set_paragraph_space_after_by_lines, + _paragraph_space_by_lines, + SetSpacing, + SetLineSpacing, + SetIndent, + SetFirstLineIndent, ) from wordformat.style.defs import ( - FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing, - FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter, - Spacing, UnitLabelEnum, + FontName, + FontSize, + FontColor, + Alignment, + LineSpacingRule, + LineSpacing, + FirstLineIndent, + LeftIndent, + RightIndent, + BuiltInStyle, + SpaceBefore, + SpaceAfter, + Spacing, + UnitLabelEnum, ) from wordformat.style.units import extract_unit_from_string, UnitResult @@ -38,6 +62,7 @@ # fixtures & helpers # --------------------------------------------------------------------------- + @pytest.fixture def doc(): return Document() @@ -46,10 +71,23 @@ def doc(): @pytest.fixture def mock_warning(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, True) return w @@ -57,21 +95,36 @@ def mock_warning(): @pytest.fixture def mock_warning_off(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, False) return w def _set_warning(w): import wordformat.style.diff as m + m.style_checks_warning = w def _clear_warning(): import wordformat.style.diff as m + m.__dict__.pop("style_checks_warning", None) @@ -79,8 +132,12 @@ def _clear_warning(): # get_some # =========================================================================== + class TestGetSomeAlignment: - @pytest.mark.parametrize("align", [WD_ALIGN_PARAGRAPH.LEFT, WD_ALIGN_PARAGRAPH.CENTER, WD_ALIGN_PARAGRAPH.RIGHT]) + @pytest.mark.parametrize( + "align", + [WD_ALIGN_PARAGRAPH.LEFT, WD_ALIGN_PARAGRAPH.CENTER, WD_ALIGN_PARAGRAPH.RIGHT], + ) def test_direct(self, doc, align): p = doc.add_paragraph() p.paragraph_format.alignment = align @@ -90,12 +147,15 @@ def test_no_alignment_returns_none(self, doc): assert paragraph_get_alignment(doc.add_paragraph()) is None - class TestGetSomeLineSpacing: - @pytest.mark.parametrize("rule,expected", [ - (WD_LINE_SPACING.SINGLE, 1.0), (WD_LINE_SPACING.ONE_POINT_FIVE, 1.5), - (WD_LINE_SPACING.DOUBLE, 2.0), - ]) + @pytest.mark.parametrize( + "rule,expected", + [ + (WD_LINE_SPACING.SINGLE, 1.0), + (WD_LINE_SPACING.ONE_POINT_FIVE, 1.5), + (WD_LINE_SPACING.DOUBLE, 2.0), + ], + ) def test_preset(self, doc, rule, expected): p = doc.add_paragraph() p.paragraph_format.line_spacing_rule = rule @@ -107,14 +167,15 @@ def test_multiple_custom(self, doc): p.paragraph_format.line_spacing = 2.3 assert paragraph_get_line_spacing(p) == 2.3 - @pytest.mark.parametrize("rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST]) + @pytest.mark.parametrize( + "rule", [WD_LINE_SPACING.EXACTLY, WD_LINE_SPACING.AT_LEAST] + ) def test_fixed_returns_none(self, doc, rule): p = doc.add_paragraph() p.paragraph_format.line_spacing_rule = rule assert paragraph_get_line_spacing(p) is None - class TestGetSomeRun: def test_font_name_default_none(self, doc): assert run_get_font_name(doc.add_paragraph().add_run("x")) is None @@ -141,10 +202,14 @@ def test_font_color_set(self, doc): run.font.color.rgb = RGBColor(0xFF, 0, 0) assert run_get_font_color(run) == (255, 0, 0) - @pytest.mark.parametrize("getter,attr", [ - (run_get_font_bold, "bold"), (run_get_font_italic, "italic"), - (run_get_font_underline, "underline"), - ]) + @pytest.mark.parametrize( + "getter,attr", + [ + (run_get_font_bold, "bold"), + (run_get_font_italic, "italic"), + (run_get_font_underline, "underline"), + ], + ) def test_boolean_props(self, doc, getter, attr): run = doc.add_paragraph().add_run("x") assert getter(run) is False @@ -152,7 +217,6 @@ def test_boolean_props(self, doc, getter, attr): assert getter(run) is True - class TestGetSomeFirstLineIndent: def test_default_none(self, doc): assert paragraph_get_first_line_indent(doc.add_paragraph()) is None @@ -169,14 +233,12 @@ def test_ignores_firstLine_twips(self, doc): assert paragraph_get_first_line_indent(p) is None - class TestGetSomeBuiltinStyle: def test_default_normal_lowercase(self, doc): name = paragraph_get_builtin_style_name(doc.add_paragraph()) assert name == "normal" - class TestGetIndent: def test_default_none(self, doc): p = doc.add_paragraph() @@ -193,7 +255,6 @@ def test_left_after_set_char(self, doc): assert GetIndent.left_indent(p) == 3.0 - # =========================================================================== # Additional coverage tests for get_some.py # =========================================================================== @@ -213,6 +274,7 @@ def test_alignment_from_style(self, doc): def test_alignment_from_base_style(self, doc): """沿真实 basedOn 链解析对齐:Child(basedOn=Base) 继承 Base 的对齐。""" from docx.enum.style import WD_STYLE_TYPE + base = doc.styles.add_style("BaseAlign", WD_STYLE_TYPE.PARAGRAPH) base.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.RIGHT child = doc.styles.add_style("ChildAlign", WD_STYLE_TYPE.PARAGRAPH) @@ -229,7 +291,6 @@ def test_alignment_no_base_style_returns_none(self, doc): assert paragraph_get_alignment(p) is None - class TestGetSomeSpaceBeforeAfterInheritance: """Cover lines 147-148, 157-158: space_before/after with style inheritance""" @@ -238,6 +299,7 @@ def test_space_before_invalid_attr_returns_none(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:beforeLines"), "abc") @@ -250,6 +312,7 @@ def test_space_after_invalid_attr_returns_none(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:afterLines"), "xyz") @@ -262,7 +325,9 @@ def test_space_before_from_style(self, doc): mock_style = MagicMock() mock_style.element = None mock_style.base_style = None - with mock_patch.object(type(p), 'style', new_callable=PropertyMock, return_value=mock_style): + with mock_patch.object( + type(p), "style", new_callable=PropertyMock, return_value=mock_style + ): # _get_style_spacing returns None for None element assert paragraph_get_space_before(p) is None @@ -272,11 +337,12 @@ def test_space_after_from_style(self, doc): mock_style = MagicMock() mock_style.element = None mock_style.base_style = None - with mock_patch.object(type(p), 'style', new_callable=PropertyMock, return_value=mock_style): + with mock_patch.object( + type(p), "style", new_callable=PropertyMock, return_value=mock_style + ): assert paragraph_get_space_after(p) is None - class TestGetSomeLineSpacingInheritance: """Cover lines 233-235: paragraph_get_line_spacing with style inheritance""" @@ -297,7 +363,6 @@ def test_line_spacing_type_error_returns_none(self): assert result is None - class TestGetSomeFirstLineIndentPhysicalUnit: """Cover lines 264-266: paragraph_get_first_line_indent with firstLine (physical unit)""" @@ -306,20 +371,22 @@ def test_first_line_indent_exception_returns_none(self, doc): p = doc.add_paragraph() # Force an exception by making pPr raise original = p._element.find + def bad_find(qname): raise RuntimeError("test error") + p._element.find = bad_find assert paragraph_get_first_line_indent(p) is None p._element.find = original - class TestGetSomeRunExtended: """Cover lines 317, 338: run_get_font_size_pt with style, run_get_font_color with theme""" def test_font_size_from_style(self, doc): """run 无直接字号时,沿段落样式链解析到样式字号。""" from docx.enum.style import WD_STYLE_TYPE + st = doc.styles.add_style("SizeStyle", WD_STYLE_TYPE.PARAGRAPH) st.font.size = Pt(16) run = doc.add_paragraph(style="SizeStyle").add_run("x") @@ -341,7 +408,6 @@ def test_font_color_none(self, doc): assert run_get_font_color(mock_run) == (0, 0, 0) - class TestGetIndentWithRealElement: """Cover lines 408-423: GetIndent.line_indent with real indent element""" @@ -350,6 +416,7 @@ def test_line_indent_left_with_chars(self, doc): p = doc.add_paragraph() from docx.oxml import OxmlElement from docx.oxml.ns import qn + pPr = p._element.get_or_add_pPr() ind = OxmlElement("w:ind") ind.set(qn("w:leftChars"), "300") @@ -361,6 +428,7 @@ def test_line_indent_right_with_chars(self, doc): p = doc.add_paragraph() from docx.oxml import OxmlElement from docx.oxml.ns import qn + pPr = p._element.get_or_add_pPr() ind = OxmlElement("w:ind") ind.set(qn("w:rightChars"), "200") @@ -372,6 +440,7 @@ def test_line_indent_invalid_chars_value(self, doc): p = doc.add_paragraph() from docx.oxml import OxmlElement from docx.oxml.ns import qn + pPr = p._element.get_or_add_pPr() ind = OxmlElement("w:ind") ind.set(qn("w:leftChars"), "abc") @@ -391,6 +460,7 @@ def test_line_indent_no_ind(self, doc): """pPr exists but no ind element -> returns None (line 404-405)""" p = doc.add_paragraph() from docx.oxml.ns import qn + pPr = p._element.get_or_add_pPr() ind = pPr.find(qn("w:ind")) if ind is not None: @@ -398,7 +468,6 @@ def test_line_indent_no_ind(self, doc): assert GetIndent.line_indent(p, "left") is None - class TestParagraphGetSpaceBeforeWithValidLines: """Cover lines 157-158: paragraph_get_space_before with valid beforeLines in XML""" @@ -407,6 +476,7 @@ def test_space_before_valid_lines(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:beforeLines"), "100") @@ -418,6 +488,7 @@ def test_space_before_valid_lines_rounded(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:beforeLines"), "33") @@ -425,7 +496,6 @@ def test_space_before_valid_lines_rounded(self, doc): assert paragraph_get_space_before(p) == 0.3 # round(0.33, 1) - class TestParagraphGetSpaceAfterWithValidLines: """Cover lines 190-191: paragraph_get_space_after with valid afterLines in XML""" @@ -434,6 +504,7 @@ def test_space_after_valid_lines(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:afterLines"), "150") @@ -445,6 +516,7 @@ def test_space_after_valid_lines_rounded(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:afterLines"), "67") @@ -452,7 +524,6 @@ def test_space_after_valid_lines_rounded(self, doc): assert paragraph_get_space_after(p) == 0.7 # round(0.67, 1) - class TestRunGetFontColorValidRGB: """Cover line 280: run_get_font_color when color.rgb is a valid RGBColor""" @@ -465,7 +536,6 @@ def test_font_color_valid_rgb_tuple_access(self, doc): assert result == (0x12, 0x34, 0x56) - class TestRunGetFontColorFalsyRGB: """Cover line 338: run_get_font_color when color.rgb is falsy (empty string)""" @@ -489,7 +559,6 @@ def test_font_color_zero_rgb(self): assert result == (0, 0, 0) - class TestGetIndentLineIndentWithCharsValues: """Cover lines 419-423: GetIndent.line_indent with valid leftChars/rightChars values""" @@ -498,6 +567,7 @@ def test_line_indent_left_chars_valid(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() ind = OxmlElement("w:ind") ind.set(qn("w:leftChars"), "150") @@ -509,6 +579,7 @@ def test_line_indent_right_chars_valid(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() ind = OxmlElement("w:ind") ind.set(qn("w:rightChars"), "250") @@ -520,6 +591,7 @@ def test_line_indent_left_chars_zero(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() ind = OxmlElement("w:ind") ind.set(qn("w:leftChars"), "0") @@ -531,4 +603,3 @@ def test_line_indent_exception_returns_none(self, doc): mock_para = MagicMock() mock_para._element.pPr = None assert GetIndent.line_indent(mock_para, "left") is None - diff --git a/tests/style/test_writer.py b/tests/style/test_writer.py index 2728ce8..58f4568 100644 --- a/tests/style/test_writer.py +++ b/tests/style/test_writer.py @@ -15,21 +15,45 @@ from wordformat.style.diff import DIFFResult, CharacterStyle, ParagraphStyle from wordformat.style.reader import ( - paragraph_get_alignment, paragraph_get_space_before, paragraph_get_space_after, - paragraph_get_line_spacing, paragraph_get_first_line_indent, - paragraph_get_builtin_style_name, run_get_font_name, run_get_font_size_pt, - run_get_font_color, run_get_font_bold, run_get_font_italic, - run_get_font_underline, GetIndent, + paragraph_get_alignment, + paragraph_get_space_before, + paragraph_get_space_after, + paragraph_get_line_spacing, + paragraph_get_first_line_indent, + paragraph_get_builtin_style_name, + run_get_font_name, + run_get_font_size_pt, + run_get_font_color, + run_get_font_bold, + run_get_font_italic, + run_get_font_underline, + GetIndent, ) from wordformat.style.writer import ( - run_set_font_name, set_paragraph_space_before_by_lines, - set_paragraph_space_after_by_lines, _paragraph_space_by_lines, - SetSpacing, SetLineSpacing, SetIndent, SetFirstLineIndent, + run_set_font_name, + set_paragraph_space_before_by_lines, + set_paragraph_space_after_by_lines, + _paragraph_space_by_lines, + SetSpacing, + SetLineSpacing, + SetIndent, + SetFirstLineIndent, ) from wordformat.style.defs import ( - FontName, FontSize, FontColor, Alignment, LineSpacingRule, LineSpacing, - FirstLineIndent, LeftIndent, RightIndent, BuiltInStyle, SpaceBefore, SpaceAfter, - Spacing, UnitLabelEnum, + FontName, + FontSize, + FontColor, + Alignment, + LineSpacingRule, + LineSpacing, + FirstLineIndent, + LeftIndent, + RightIndent, + BuiltInStyle, + SpaceBefore, + SpaceAfter, + Spacing, + UnitLabelEnum, ) from wordformat.style.units import extract_unit_from_string, UnitResult @@ -38,6 +62,7 @@ # fixtures & helpers # --------------------------------------------------------------------------- + @pytest.fixture def doc(): return Document() @@ -46,10 +71,23 @@ def doc(): @pytest.fixture def mock_warning(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, True) return w @@ -57,21 +95,36 @@ def mock_warning(): @pytest.fixture def mock_warning_off(): w = MagicMock() - for attr in ("bold", "italic", "underline", "font_size", "font_color", - "font_name", "alignment", "space_before", "space_after", - "line_spacing", "line_spacingrule", "first_line_indent", - "left_indent", "right_indent", "builtin_style_name"): + for attr in ( + "bold", + "italic", + "underline", + "font_size", + "font_color", + "font_name", + "alignment", + "space_before", + "space_after", + "line_spacing", + "line_spacingrule", + "first_line_indent", + "left_indent", + "right_indent", + "builtin_style_name", + ): setattr(w, attr, False) return w def _set_warning(w): import wordformat.style.diff as m + m.style_checks_warning = w def _clear_warning(): import wordformat.style.diff as m + m.__dict__.pop("style_checks_warning", None) @@ -79,6 +132,7 @@ def _clear_warning(): # set_some # =========================================================================== + class TestSetSomeFontName: def test_set_and_verify_xml(self, doc): run = doc.add_paragraph().add_run("x") @@ -86,7 +140,6 @@ def test_set_and_verify_xml(self, doc): assert run._element.rPr.rFonts.get(qn("w:eastAsia")) == "黑体" - class TestSetSomeSpaceByLines: def test_set_before_and_after(self, doc): p = doc.add_paragraph() @@ -108,7 +161,6 @@ def test_zero_preserves_existing(self, doc): assert paragraph_get_space_after(p) == 1.0 - class TestSetSpacingHang: def test_set_and_clamp(self, doc): p = doc.add_paragraph() @@ -118,7 +170,6 @@ def test_set_and_clamp(self, doc): assert paragraph_get_space_before(p) == 10.0 - class TestSetLineSpacing: @pytest.mark.parametrize("method,val", [("set_pt", 20), ("set_cm", 1.0)]) def test_sets_exactly_rule(self, doc, method, val): @@ -127,7 +178,6 @@ def test_sets_exactly_rule(self, doc, method, val): assert p.paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY - class TestSetIndent: def test_set_char_left(self, doc): p = doc.add_paragraph() @@ -140,9 +190,15 @@ def test_set_char_returns_true(self, doc): def test_set_char_invalid_returns_false(self, doc): assert SetIndent.set_char(doc.add_paragraph(), "Z", 1) is False - @pytest.mark.parametrize("method,indent_type", [ - ("set_pt", "R"), ("set_cm", "X"), ("set_inch", "R"), ("set_mm", "R"), - ]) + @pytest.mark.parametrize( + "method,indent_type", + [ + ("set_pt", "R"), + ("set_cm", "X"), + ("set_inch", "R"), + ("set_mm", "R"), + ], + ) def test_physical_units_set_indent(self, doc, method, indent_type): p = doc.add_paragraph() getattr(SetIndent, method)(p, indent_type, 1.0) @@ -154,7 +210,6 @@ def test_apply_indent_invalid_raises(self, doc): SetIndent._apply_indent(doc.add_paragraph(), "Z", 10) - class TestSetFirstLineIndent: def test_set_and_clear(self, doc): p = doc.add_paragraph() @@ -190,7 +245,6 @@ def test_physical_units_no_firstLineChars(self, doc, method): assert paragraph_get_first_line_indent(p) is None - # =========================================================================== # Additional coverage tests for set_some.py # =========================================================================== @@ -252,6 +306,7 @@ def test_set_hang_sets_twips_to_zero(self, doc): p = doc.add_paragraph() from docx.oxml.ns import qn from docx.oxml import OxmlElement + pPr = p._element.get_or_add_pPr() spacing = OxmlElement("w:spacing") spacing.set(qn("w:before"), "200") @@ -261,7 +316,6 @@ def test_set_hang_sets_twips_to_zero(self, doc): assert spacing_after.get(qn("w:before")) == "0" - class TestSetLineSpacingUnits: """Cover lines 234-235, 245-246: _SetLineSpacing additional unit methods""" @@ -278,7 +332,6 @@ def test_set_mm(self, doc): assert p.paragraph_format.line_spacing_rule == WD_LINE_SPACING.EXACTLY - class TestSetIndentUnits: """Cover lines 288-291, 300-302, 461-463: _SetIndent and _SetFirstLineIndent unit methods""" @@ -328,7 +381,6 @@ def test_set_mm(self, doc): assert p.paragraph_format.right_indent is not None - class TestSetFirstLineIndentUnits: """Cover lines 478, 494, 510, 526: _SetFirstLineIndent physical unit methods""" @@ -376,13 +428,14 @@ def test_set_char_exception_returns_false(self, doc): # clear() calls get_or_add_pPr first, then we make it fail. call_count = [0] original_get_or_add = p._element.get_or_add_pPr + def bad_get_or_add(): call_count[0] += 1 if call_count[0] > 1: # First call is from clear(), second is from the try block raise RuntimeError("test error") return original_get_or_add() + p._element.get_or_add_pPr = bad_get_or_add assert SetFirstLineIndent.set_char(p, 2) is False p._element.get_or_add_pPr = original_get_or_add - diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py index e306326..eb2413d 100644 --- a/tests/test_coverage_boost.py +++ b/tests/test_coverage_boost.py @@ -70,6 +70,7 @@ def _load_yaml(path): import yaml + with open(path, encoding="utf-8") as f: return yaml.safe_load(f) @@ -170,7 +171,7 @@ def test_fix_all_style_definitions_handles_document(self, root_config): def test_fix_style_run_properties_clears_theme_color(self, root_config): """_fix_style_run_properties 应清除样式定义中的主题色。""" - + from wordformat.style.defs import ensure_style_exists doc = Document() @@ -197,7 +198,7 @@ def test_fix_style_run_properties_clears_theme_color(self, root_config): def test_fix_style_run_properties_sets_font_name(self, root_config): """_fix_style_run_properties 应设置样式定义中的英文字体名。""" - + from wordformat.style.defs import ensure_style_exists doc = Document() @@ -213,9 +214,9 @@ def test_fix_style_run_properties_sets_font_name(self, root_config): def test_fix_style_run_properties_sets_bold(self, root_config): """_fix_style_run_properties 应设置样式定义中的加粗属性。""" - + from wordformat.style.defs import ensure_style_exists - + doc = Document() cfg = NodeConfigRoot(bold=True) ensure_style_exists(doc, "Heading 1") @@ -247,9 +248,9 @@ def test_fix_style_run_properties_disables_italic(self, root_config): def test_fix_style_paragraph_properties_sets_alignment(self, root_config): """_fix_style_paragraph_properties 应设置样式定义中的对齐方式。""" - + from wordformat.style.defs import ensure_style_exists - + doc = Document() cfg = NodeConfigRoot(alignment="居中对齐") ensure_style_exists(doc, "Heading 1") @@ -631,6 +632,7 @@ def test_first_line_indent_pt_unit(self): doc = Document() p = doc.add_paragraph() from docx.shared import Pt + p.paragraph_format.first_line_indent = Pt(24) fli = FirstLineIndent("24pt") result = fli.get_from_paragraph(p) @@ -641,6 +643,7 @@ def test_first_line_indent_cm_unit(self): doc = Document() p = doc.add_paragraph() from docx.shared import Cm + p.paragraph_format.first_line_indent = Cm(0.85) fli = FirstLineIndent("0.85cm") result = fli.get_from_paragraph(p) @@ -651,6 +654,7 @@ def test_first_line_indent_inch_unit(self): doc = Document() p = doc.add_paragraph() from docx.shared import Inches + p.paragraph_format.first_line_indent = Inches(0.5) fli = FirstLineIndent("0.5inch") result = fli.get_from_paragraph(p) @@ -669,9 +673,7 @@ def test_ensure_style_exists_base_style_none(self): ensure_style_exists(doc, "TestStyleNoBase") # 清理 try: - doc.styles.element.remove( - doc.styles["TestStyleNoBase"].element - ) + doc.styles.element.remove(doc.styles["TestStyleNoBase"].element) except Exception: pass @@ -756,7 +758,9 @@ def test_fix_all_style_definitions_theme_color_fix(self): config_model = NodeConfigRoot( global_format=NodeConfigRoot(), headings=NodeConfigRoot( - level_1=NodeConfigRoot(builtin_style_name="Heading 1", font_color="黑色"), + level_1=NodeConfigRoot( + builtin_style_name="Heading 1", font_color="黑色" + ), ), ) @@ -811,6 +815,7 @@ def test_apply_format_check_to_all_nodes_no_check_format(self): 当节点没有 check_format 方法时,跳过。 """ + # 使用一个没有 check_format 的简单对象 class SimpleNode: def __init__(self): @@ -840,7 +845,9 @@ def test_apply_format_check_to_all_nodes_exception_handling(self): config = MagicMock() # load_config 会抛出异常 - with patch.object(root_node, "load_config", side_effect=ValueError("test error")): + with patch.object( + root_node, "load_config", side_effect=ValueError("test error") + ): with pytest.raises(ValueError): apply_format_check_to_all_nodes(root_node, doc, config, check=True) @@ -951,6 +958,7 @@ def test_run_get_font_color_theme(self): """themeColor(主题色)返回 None:rgb 不确定。""" from docx.oxml import OxmlElement from docx.oxml.ns import qn + run = Document().add_paragraph().add_run("x") rPr = run._element.get_or_add_rPr() color = OxmlElement("w:color") @@ -978,6 +986,7 @@ def test_paragraph_get_line_spacing_multiple(self): p = doc.add_paragraph() from docx.shared import Pt from docx.enum.text import WD_LINE_SPACING + p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE p.paragraph_format.line_spacing = 1.5 result = paragraph_get_line_spacing(p) @@ -988,6 +997,7 @@ def test_paragraph_get_line_spacing_at_least(self): doc = Document() p = doc.add_paragraph() from docx.enum.text import WD_LINE_SPACING + p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.AT_LEAST result = paragraph_get_line_spacing(p) assert result is None @@ -1155,6 +1165,7 @@ def test_ensure_directory_exists_creates(self, tmp_path): new_dir = str(tmp_path / "new_subdir" / "nested") ensure_directory_exists(new_dir) import os + assert os.path.isdir(new_dir) def test_ensure_directory_exists_already_exists(self, tmp_path): @@ -1183,6 +1194,7 @@ class TestCoverageBoostRound2: def test_ensure_directory_exists_creates_dir(self, tmp_path): """确保目录不存在时创建。""" from wordformat.utils import ensure_directory_exists + d = str(tmp_path / "new_dir") ensure_directory_exists(d) assert os.path.isdir(d) @@ -1190,6 +1202,7 @@ def test_ensure_directory_exists_creates_dir(self, tmp_path): def test_para_contains_image_false(self): """无图片的段落返回 False。""" from wordformat.utils import para_contains_image + doc = Document() p = doc.add_paragraph("text") assert para_contains_image(p) is False @@ -1197,6 +1210,7 @@ def test_para_contains_image_false(self): def test_remove_all_numbering(self): """remove_all_numbering 对空白文档不抛异常。""" from wordformat.utils import remove_all_numbering + doc = Document() remove_all_numbering(doc) @@ -1205,6 +1219,7 @@ def test_remove_all_numbering(self): def test_config_not_loaded_error(self): """ConfigNotLoadedError 可正常抛出和捕获。""" from wordformat.config.loader import ConfigNotLoadedError + with pytest.raises(ConfigNotLoadedError): raise ConfigNotLoadedError("test") @@ -1214,6 +1229,7 @@ def test_rPr_set_italic_remove(self): """rPr_set_italic 传入 False 时移除 w:i。""" from wordformat.style.xml_ops import rPr_set_italic, ensure_rPr from docx.oxml import OxmlElement + style = Document().styles["Normal"] rPr = ensure_rPr(style.element) rPr.append(OxmlElement("w:i")) @@ -1224,6 +1240,7 @@ def test_rPr_set_underline_remove(self): """rPr_set_underline 传入 False 时移除 w:u。""" from wordformat.style.xml_ops import rPr_set_underline, ensure_rPr from docx.oxml import OxmlElement + style = Document().styles["Normal"] rPr = ensure_rPr(style.element) u = OxmlElement("w:u") @@ -1238,10 +1255,15 @@ def test_print_tree_from_json(self, tmp_path): """print_tree 支持传入 JSON 文件路径。""" import json from wordformat.tree import print_tree + json_path = tmp_path / "test.json" - json_path.write_text(json.dumps([ - {"category": "body_text", "paragraph": "hello"}, - ])) + json_path.write_text( + json.dumps( + [ + {"category": "body_text", "paragraph": "hello"}, + ] + ) + ) # 不应抛异常 with patch("sys.stdout", io.StringIO()): print_tree(str(json_path), show_index=True, show_confidence=True) @@ -1252,6 +1274,7 @@ def test_process_heading_numbering_disabled(self): """numbering 禁用时不处理。""" from wordformat.numbering import process_heading_numbering from unittest.mock import MagicMock + config = MagicMock() config.enabled = False process_heading_numbering(None, Document(), config) @@ -1263,6 +1286,7 @@ def test_formatting_stage_skips_void_nodes(self, doc): from wordformat.pipeline.stages import FormattingExecutionStage from wordformat.rules.node import FormatNode from wordformat.settings import VOIDNODELIST + stage = FormattingExecutionStage() root = FormatNode(value={"category": VOIDNODELIST[0]}, level=0) root.children = [] @@ -1271,29 +1295,38 @@ def test_formatting_stage_skips_void_nodes(self, doc): def test_summary_stage_adds_comment(self, doc): """SummaryGenerationStage 在 check 模式下添加批注。""" - from wordformat.pipeline.stages import SummaryGenerationStage, FormattingExecutionStage + from wordformat.pipeline.stages import ( + SummaryGenerationStage, + FormattingExecutionStage, + ) from wordformat.rules.node import FormatNode + p = doc.add_paragraph("") root = FormatNode(value={"category": "top"}, level=0) root.children = [] FormatNode.reset_stats() stage = SummaryGenerationStage() from wordformat.pipeline.context import FormatContext + ctx = FormatContext( - json_path="", docx_path="", check=True, - config_path="", save_dir="/tmp", - document=doc, root_node=root, + json_path="", + docx_path="", + check=True, + config_path="", + save_dir="/tmp", + document=doc, + root_node=root, ) stage.process(ctx) # --- utils/_docx.py more --- - # --- style/xml_ops.py --- def test_rPr_set_bold_remove(self): """rPr_set_bold 传入 False 移除 w:b。""" from wordformat.style.xml_ops import rPr_set_bold, ensure_rPr + style = Document().styles["Normal"] rPr = ensure_rPr(style.element) rPr.append(OxmlElement("w:b")) @@ -1303,6 +1336,7 @@ def test_rPr_set_bold_remove(self): def test_line_rule_to_xml_unknown(self): """line_rule_to_xml 对未知值返回 auto。""" from wordformat.style.xml_ops import line_rule_to_xml + assert line_rule_to_xml(999) == "auto" # --- style/defs.py --- @@ -1310,6 +1344,7 @@ def test_line_rule_to_xml_unknown(self): def test_fontsize_base_set_numeric(self): """FontSize.base_set 处理数字字符串。""" from wordformat.style.defs import FontSize + doc = Document() p = doc.add_paragraph("test") run = p.add_run("x") @@ -1320,6 +1355,7 @@ def test_fontsize_base_set_numeric(self): def test_fontname_base_set_english(self): """FontName.base_set 处理英文字体。""" from wordformat.style.defs import FontName + doc = Document() p = doc.add_paragraph("test") run = p.add_run("x") @@ -1330,6 +1366,7 @@ def test_fontname_base_set_english(self): def test_unit_label_enum_eq_none(self): """UnitLabelEnum __eq__ 对 None 返回 rel_value == 0。""" from wordformat.style.defs import FirstLineIndent + indent = FirstLineIndent("0字符") assert indent == None # rel_value is 0, so equal to None @@ -1338,6 +1375,7 @@ def test_unit_label_enum_eq_none(self): def test_auto_strip_numbering_empty_runs(self): """空 run 的段落 _auto_strip_numbering 返回 False。""" from wordformat.numbering import _auto_strip_numbering + doc = Document() p = doc.add_paragraph("") assert _auto_strip_numbering(p, 0) is False @@ -1348,6 +1386,7 @@ def test_load_config_stage_no_config(self): """未提供配置文件时使用默认配置。""" from wordformat.pipeline.stages import LoadConfigStage from wordformat.pipeline.context import FormatContext + ctx = FormatContext(json_path="", docx_path="", check=True, config_path="") stage = LoadConfigStage() result = stage.process(ctx) @@ -1355,9 +1394,9 @@ def test_load_config_stage_no_config(self): # --- utils/_fs.py --- - def test_ensure_is_directory_missing(self, tmp_path): """ensure_is_directory 路径不存在时抛出 ValueError。""" from wordformat.utils import ensure_is_directory + with pytest.raises(ValueError, match="不存在"): ensure_is_directory(str(tmp_path / "nonexistent")) diff --git a/tests/test_integration.py b/tests/test_integration.py index ed3d2c8..b4b1f1f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,6 +3,7 @@ 覆盖模块:cli.py, config, agent, set_style.py, set_tag.py, word_structure """ + import argparse import io import os @@ -46,7 +47,9 @@ from wordformat.rules.body import BodyText from wordformat.api import save_upload_file, TEMP_DIR -apply_format_check_to_all_nodes = FormattingExecutionStage().apply_format_check_to_all_nodes +apply_format_check_to_all_nodes = ( + FormattingExecutionStage().apply_format_check_to_all_nodes +) # ==================== (a) Config 模块集成测试 ==================== @@ -107,12 +110,9 @@ def create(): assert len(set(id(r) for r in results)) == 10 - # ==================== (b) DataModel 验证测试 ==================== - - # ==================== (c) Agent/Message 测试 ==================== @@ -185,7 +185,6 @@ def clearer(): assert len(msgs) == 0 - # ==================== (d) Agent/ONNX 推理测试 ==================== @@ -193,10 +192,14 @@ class TestONNXInferIntegration: """Mock 模型加载,验证推理流程与已知 bug""" def test_single_infer_success(self): - with mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, \ - mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, \ - mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text", 1: "heading"}), \ - mock.patch("wordformat.agent.onnx_infer._load_model"): + with ( + mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, + mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, + mock.patch( + "wordformat.agent.onnx_infer._id2label", {0: "body_text", 1: "heading"} + ), + mock.patch("wordformat.agent.onnx_infer._load_model"), + ): enc = mock.MagicMock() enc.ids = [1, 2, 3] enc.attention_mask = [1, 1, 1] @@ -208,10 +211,12 @@ def test_single_infer_success(self): assert result["score"] > 0.5 def test_single_infer_error_returns_empty(self): - with mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, \ - mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, \ - mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}), \ - mock.patch("wordformat.agent.onnx_infer._load_model"): + with ( + mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, + mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, + mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}), + mock.patch("wordformat.agent.onnx_infer._load_model"), + ): enc = mock.MagicMock() enc.ids = [1, 2, 3] enc.attention_mask = [1, 1, 1] @@ -223,10 +228,12 @@ def test_single_infer_error_returns_empty(self): def test_batch_infer_error_format_matches_single(self): """batch 失败与 single 失败应返回相同结构""" - with mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, \ - mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, \ - mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}), \ - mock.patch("wordformat.agent.onnx_infer._load_model"): + with ( + mock.patch("wordformat.agent.onnx_infer._tokenizer") as mock_tok, + mock.patch("wordformat.agent.onnx_infer._ort_sess") as mock_sess, + mock.patch("wordformat.agent.onnx_infer._id2label", {0: "body_text"}), + mock.patch("wordformat.agent.onnx_infer._load_model"), + ): enc = mock.MagicMock() enc.ids = [1, 2, 3] enc.attention_mask = [1, 1, 1] @@ -245,13 +252,19 @@ def test_safe_batch_splits_correctly(self, mock_batch): assert mock_batch.call_count == 2 def test_provider_selection_priority(self): - with mock.patch("onnxruntime.get_available_providers", - return_value=["CUDAExecutionProvider", "CPUExecutionProvider"]): + with mock.patch( + "onnxruntime.get_available_providers", + return_value=["CUDAExecutionProvider", "CPUExecutionProvider"], + ): assert _get_best_onnx_providers() == ["CUDAExecutionProvider"] - with mock.patch("onnxruntime.get_available_providers", - return_value=["DmlExecutionProvider", "CPUExecutionProvider"]): + with mock.patch( + "onnxruntime.get_available_providers", + return_value=["DmlExecutionProvider", "CPUExecutionProvider"], + ): assert _get_best_onnx_providers() == ["DmlExecutionProvider"] - with mock.patch("onnxruntime.get_available_providers", return_value=["CPUExecutionProvider"]): + with mock.patch( + "onnxruntime.get_available_providers", return_value=["CPUExecutionProvider"] + ): assert _get_best_onnx_providers() == ["CPUExecutionProvider"] def test_global_state_thread_safety(self): @@ -272,7 +285,6 @@ def load(): assert len(errors) == 0 - # ==================== (e) word_structure 集成测试 ==================== @@ -281,6 +293,7 @@ class TestNodeFactoryIntegration: def test_create_known_category(self, sample_yaml_config): from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() item = { @@ -294,6 +307,7 @@ def test_create_known_category(self, sample_yaml_config): def test_create_unknown_category_returns_none(self, sample_yaml_config): from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() item = {"category": "nonexistent_type", "paragraph": "x", "fingerprint": "fp_x"} @@ -302,13 +316,13 @@ def test_create_unknown_category_returns_none(self, sample_yaml_config): def test_create_missing_category_raises(self, sample_yaml_config): from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() with pytest.raises(ValueError, match="missing 'category'"): create_node({"paragraph": "x"}, level=1, config=config) - class TestTreeBuilderIntegration: """tree_builder 构建树 + 已知 bug""" @@ -327,16 +341,20 @@ def test_body_text_not_treated_as_heading(self): assert builder._is_heading_category("body_text") is False - class TestDocumentBuilderIntegration: """DocumentBuilder 加载 JSON 并构建树""" def test_build_from_json_list(self, sample_yaml_config): from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() data = [ - {"category": "heading_level_1", "paragraph": "第一章", "fingerprint": "fp1"}, + { + "category": "heading_level_1", + "paragraph": "第一章", + "fingerprint": "fp1", + }, {"category": "body_text", "paragraph": "正文", "fingerprint": "fp2"}, ] root = DocumentBuilder.build_from_json(data, config=config) @@ -350,7 +368,6 @@ def test_config_not_global_state(self): assert hasattr(builder, "_config") - # ==================== (f) set_tag 集成测试 ==================== @@ -361,7 +378,12 @@ class TestSetTagMainIntegration: def test_set_tag_main_calls_parse(self, mock_docx_cls): mock_instance = mock_docx_cls.return_value mock_instance.parse.return_value = [ - {"category": "body_text", "score": 0.9, "paragraph": "test", "fingerprint": "fp1"} + { + "category": "body_text", + "score": 0.9, + "paragraph": "test", + "fingerprint": "fp1", + } ] result = set_tag_main("dummy.docx", "dummy.yaml") assert len(result) == 1 @@ -371,8 +393,9 @@ def test_set_tag_main_calls_parse(self, mock_docx_cls): def test_set_tag_main_passes_args(self, mock_docx_cls): mock_docx_cls.return_value.parse.return_value = [] set_tag_main("path/to/doc.docx", "path/to/cfg.yaml") - mock_docx_cls.assert_called_once_with("path/to/doc.docx", configpath="path/to/cfg.yaml") - + mock_docx_cls.assert_called_once_with( + "path/to/doc.docx", configpath="path/to/cfg.yaml" + ) # ==================== (g) set_style 集成测试 ==================== @@ -390,7 +413,9 @@ class SpyNode(FormatNode): CONFIG_MODEL = type("C", (), {})() def __init__(self, **kw): - super().__init__(value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw) + super().__init__( + value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw + ) doc = Document() self.paragraph = doc.add_paragraph("text") @@ -420,7 +445,9 @@ class SpyNode(FormatNode): CONFIG_MODEL = type("C", (), {})() def __init__(self, **kw): - super().__init__(value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw) + super().__init__( + value={"category": "body_text", "fingerprint": "fp"}, level=1, **kw + ) doc = Document() self.paragraph = doc.add_paragraph("text") @@ -457,17 +484,18 @@ def test_flatten_tree_nodes_matches_doc_order(self): assert nodes == [a, b, c, d] - # ==================== (i) set_style.py auto_format_thesis_document 覆盖测试 ==================== class TestAutoFormatThesisDocument: """覆盖 set_style.py lines 53-55, 120-176: auto_format_thesis_document 主流程""" - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_check_mode_returns_annotated_path( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """check=True 模式:返回 --标注版.docx 路径 (lines 170-176)""" root_node = mock.MagicMock() @@ -476,16 +504,22 @@ def test_check_mode_returns_annotated_path( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + result = auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) assert "--标注版.docx" in result - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_apply_mode_returns_modified_path( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """check=False 模式:返回 --修改版.docx 路径 (line 173)""" root_node = mock.MagicMock() @@ -494,16 +528,22 @@ def test_apply_mode_returns_modified_path( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + result = auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=False, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=False, ) assert "--修改版.docx" in result - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_filters_body_text_nodes( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """body_text 节点不再被过滤,保留在 children 中""" body_node = mock.MagicMock() @@ -517,18 +557,24 @@ def test_filters_body_text_nodes( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) # body_text 不再被过滤,所有节点都保留 assert len(root_node.children) == 2 - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") @mock.patch("wordformat.pipeline.stages.promote_bodytext_in_subtrees_of_type") def test_promote_called_for_subtrees( - self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """promote_bodytext_in_subtrees_of_type 应被调用 (lines 153-161)""" root_node = mock.MagicMock() @@ -537,16 +583,24 @@ def test_promote_called_for_subtrees( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) - assert mock_promote.call_count == 3 # AbstractTitleCN, AbstractTitleEN, References + assert ( + mock_promote.call_count == 3 + ) # AbstractTitleCN, AbstractTitleEN, References - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_exception_in_traverse_raises( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """节点处理异常时 raise e (lines 53-55)""" root_node = mock.MagicMock() @@ -555,16 +609,22 @@ def test_exception_in_traverse_raises( mock_apply.side_effect = RuntimeError("test error") from wordformat.pipeline.orchestrate import auto_format_thesis_document + with pytest.raises(RuntimeError, match="test error"): auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_config_load_failure_raises( - self, mock_builder, mock_apply, temp_docx, tmp_path + self, mock_builder, mock_apply, temp_docx, tmp_path ): """配置加载失败时 raise (lines 126-128)""" bad_config = str(tmp_path / "nonexistent.yaml") @@ -573,16 +633,22 @@ def test_config_load_failure_raises( mock_builder.build_from_json.return_value = root_node from wordformat.pipeline.orchestrate import auto_format_thesis_document + with pytest.raises(Exception): auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=bad_config, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=bad_config, + savepath=str(tmp_path), + check=True, ) - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_apply_mode_lists_styles( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """check=False 时列出可用样式 (lines 139-143)""" root_node = mock.MagicMock() @@ -591,14 +657,17 @@ def test_apply_mode_lists_styles( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=False, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=False, ) # Should not raise - the styles listing is just logging - # ==================== (j) word_structure/utils.py promote_bodytext 覆盖测试 ==================== @@ -615,8 +684,17 @@ class ParentType(FormatNode): class TargetType(FormatNode): pass - parent = ParentType(value={"category": "parent", "fingerprint": "fp_parent"}, level=1) - child = BodyText(value={"category": "body_text", "paragraph": "test", "fingerprint": "fp_child"}, level=2) + parent = ParentType( + value={"category": "parent", "fingerprint": "fp_parent"}, level=1 + ) + child = BodyText( + value={ + "category": "body_text", + "paragraph": "test", + "fingerprint": "fp_child", + }, + level=2, + ) parent.add_child_node(child) promote_bodytext_in_subtrees_of_type(parent, ParentType, TargetType) @@ -638,9 +716,18 @@ class TargetType(FormatNode): class MidNode(FormatNode): pass - parent = ParentType(value={"category": "parent", "fingerprint": "fp_parent"}, level=1) + parent = ParentType( + value={"category": "parent", "fingerprint": "fp_parent"}, level=1 + ) mid = MidNode(value={"category": "mid", "fingerprint": "fp_mid"}, level=2) - child = BodyText(value={"category": "body_text", "paragraph": "deep", "fingerprint": "fp_child"}, level=3) + child = BodyText( + value={ + "category": "body_text", + "paragraph": "deep", + "fingerprint": "fp_child", + }, + level=3, + ) mid.add_child_node(child) parent.add_child_node(mid) @@ -663,10 +750,24 @@ class OtherType(FormatNode): pass root = FormatNode(value={"category": "top", "fingerprint": "fp_root"}, level=0) - parent = ParentType(value={"category": "parent", "fingerprint": "fp_parent"}, level=1) - other = OtherType(value={"category": "other", "fingerprint": "fp_other"}, level=1) - child_in_parent = BodyText(value={"category": "body_text", "paragraph": "in", "fingerprint": "fp_in"}, level=2) - child_in_other = BodyText(value={"category": "body_text", "paragraph": "out", "fingerprint": "fp_out"}, level=2) + parent = ParentType( + value={"category": "parent", "fingerprint": "fp_parent"}, level=1 + ) + other = OtherType( + value={"category": "other", "fingerprint": "fp_other"}, level=1 + ) + child_in_parent = BodyText( + value={"category": "body_text", "paragraph": "in", "fingerprint": "fp_in"}, + level=2, + ) + child_in_other = BodyText( + value={ + "category": "body_text", + "paragraph": "out", + "fingerprint": "fp_out", + }, + level=2, + ) parent.add_child_node(child_in_parent) other.add_child_node(child_in_other) root.add_child_node(parent) @@ -678,7 +779,6 @@ class OtherType(FormatNode): assert isinstance(other.children[0], BodyText) # unchanged - # ==================== (k) log_config.py 覆盖测试 ==================== @@ -688,7 +788,8 @@ class TestLogConfig: def test_setup_logger_frozen_path(self): """sys.frozen=True 时使用 sys.executable.parent (line 14)""" import sys - original_frozen = getattr(sys, 'frozen', None) + + original_frozen = getattr(sys, "frozen", None) sys.frozen = True try: with mock.patch("sys.executable", "/fake/app"): @@ -696,12 +797,13 @@ def test_setup_logger_frozen_path(self): mock_logger.remove.return_value = None mock_logger.add.return_value = None from wordformat.log_config import setup_logger + setup_logger() # Verify logger.remove was called (setup_logger ran) mock_logger.remove.assert_called_once() finally: if original_frozen is None: - delattr(sys, 'frozen') + delattr(sys, "frozen") else: sys.frozen = original_frozen @@ -710,11 +812,15 @@ def test_setup_uvicorn_loguru(self): # Both uvicorn and logging are imported inside the function mock_logging_mod = mock.MagicMock() mock_uvicorn_mod = mock.MagicMock() - with mock.patch.dict("sys.modules", {"uvicorn": mock_uvicorn_mod, "logging": mock_logging_mod}): + with mock.patch.dict( + "sys.modules", {"uvicorn": mock_uvicorn_mod, "logging": mock_logging_mod} + ): import importlib import wordformat.log_config as log_mod + importlib.reload(log_mod) from wordformat.log_config import setup_uvicorn_loguru + mock_uvicorn_logger = mock.MagicMock() mock_logging_mod.getLogger.return_value = mock_uvicorn_logger setup_uvicorn_loguru() @@ -725,7 +831,6 @@ def test_setup_uvicorn_loguru(self): assert mock_uvicorn_mod.config.LOGGING_CONFIG["version"] == 1 - # ==================== (l) onnx_infer.py 额外覆盖测试 ==================== @@ -734,12 +839,15 @@ class TestONNXInferExceptionHandling: def test_get_best_onnx_providers_exception_fallback(self): """get_available_providers raises -> fallback to CPU (lines 48-50)""" - with mock.patch("onnxruntime.get_available_providers", side_effect=Exception("no runtime")): + with mock.patch( + "onnxruntime.get_available_providers", side_effect=Exception("no runtime") + ): assert _get_best_onnx_providers() == ["CPUExecutionProvider"] def test_load_model_early_return_when_tokenizer_set(self): """_tokenizer is not None -> early return (line 57)""" import wordformat.agent.onnx_infer as m + original = m._tokenizer try: m._tokenizer = mock.MagicMock() @@ -753,7 +861,12 @@ def test_load_model_early_return_when_tokenizer_set(self): def test_load_model_fallback_to_cpu(self): """Best provider fails -> fallback to CPU (lines 88-90)""" import wordformat.agent.onnx_infer as m - original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label + + original_tok, original_sess, original_id2 = ( + m._tokenizer, + m._ort_sess, + m._id2label, + ) try: m._tokenizer = None m._ort_sess = None @@ -763,13 +876,25 @@ def test_load_model_fallback_to_cpu(self): "tokenizer": "/fake/tokenizer.json", "id2label": "/fake/id2label.json", } - with mock.patch("wordformat.agent.onnx_infer._get_model_paths", return_value=mock_paths), \ - mock.patch("wordformat.agent.onnx_infer._get_best_onnx_providers", - return_value=["CUDAExecutionProvider"]), \ - mock.patch("tokenizers.Tokenizer") as mock_tok_cls, \ - mock.patch("onnxruntime.InferenceSession") as mock_sess_cls, \ - mock.patch("builtins.open", mock.mock_open(read_data='{"0":"body_text"}')): - mock_sess_cls.side_effect = [RuntimeError("CUDA fail"), mock.MagicMock()] + with ( + mock.patch( + "wordformat.agent.onnx_infer._get_model_paths", + return_value=mock_paths, + ), + mock.patch( + "wordformat.agent.onnx_infer._get_best_onnx_providers", + return_value=["CUDAExecutionProvider"], + ), + mock.patch("tokenizers.Tokenizer") as mock_tok_cls, + mock.patch("onnxruntime.InferenceSession") as mock_sess_cls, + mock.patch( + "builtins.open", mock.mock_open(read_data='{"0":"body_text"}') + ), + ): + mock_sess_cls.side_effect = [ + RuntimeError("CUDA fail"), + mock.MagicMock(), + ] _load_model() # Should have been called twice: first with CUDA (fail), then with CPU (success) assert mock_sess_cls.call_count == 2 @@ -784,7 +909,12 @@ def test_load_model_fallback_to_cpu(self): def test_load_model_cpu_core_num_zero_fallback(self): """os.cpu_count() returns 0 -> fallback to 4 (line 100)""" import wordformat.agent.onnx_infer as m - original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label + + original_tok, original_sess, original_id2 = ( + m._tokenizer, + m._ort_sess, + m._id2label, + ) try: m._tokenizer = None m._ort_sess = None @@ -794,12 +924,19 @@ def test_load_model_cpu_core_num_zero_fallback(self): "tokenizer": "/fake/tokenizer.json", "id2label": "/fake/id2label.json", } - with mock.patch("wordformat.agent.onnx_infer._get_model_paths", return_value=mock_paths), \ - mock.patch("os.cpu_count", return_value=0), \ - mock.patch("tokenizers.Tokenizer") as mock_tok_cls, \ - mock.patch("onnxruntime.InferenceSession") as mock_sess_cls, \ - mock.patch("onnxruntime.SessionOptions") as mock_opts_cls, \ - mock.patch("builtins.open", mock.mock_open(read_data='{"0":"body_text"}')): + with ( + mock.patch( + "wordformat.agent.onnx_infer._get_model_paths", + return_value=mock_paths, + ), + mock.patch("os.cpu_count", return_value=0), + mock.patch("tokenizers.Tokenizer") as mock_tok_cls, + mock.patch("onnxruntime.InferenceSession") as mock_sess_cls, + mock.patch("onnxruntime.SessionOptions") as mock_opts_cls, + mock.patch( + "builtins.open", mock.mock_open(read_data='{"0":"body_text"}') + ), + ): mock_sess = mock.MagicMock() mock_sess_cls.return_value = mock_sess _load_model() @@ -814,7 +951,12 @@ def test_load_model_cpu_core_num_zero_fallback(self): def test_single_infer_truncation(self): """Input longer than MAX_LENGTH gets truncated (lines 115-117)""" import wordformat.agent.onnx_infer as m - original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label + + original_tok, original_sess, original_id2 = ( + m._tokenizer, + m._ort_sess, + m._id2label, + ) try: m._tokenizer = mock.MagicMock() m._ort_sess = mock.MagicMock() @@ -843,6 +985,7 @@ def test_batch_infer_empty_texts(self): def test_batch_infer_loads_model_when_tokenizer_none(self): """_tokenizer is None triggers _load_model (line 159)""" import wordformat.agent.onnx_infer as m + original_tok = m._tokenizer try: m._tokenizer = None @@ -857,7 +1000,12 @@ def test_batch_infer_loads_model_when_tokenizer_none(self): def test_batch_infer_success_with_timing(self): """Batch inference success path with timing log (lines 198-199)""" import wordformat.agent.onnx_infer as m - original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label + + original_tok, original_sess, original_id2 = ( + m._tokenizer, + m._ort_sess, + m._id2label, + ) try: m._tokenizer = mock.MagicMock() m._ort_sess = mock.MagicMock() @@ -882,7 +1030,12 @@ def test_batch_infer_success_with_timing(self): def test_batch_infer_result_assembly(self): """Result assembly with pred_id and score (lines 212-233)""" import wordformat.agent.onnx_infer as m - original_tok, original_sess, original_id2 = m._tokenizer, m._ort_sess, m._id2label + + original_tok, original_sess, original_id2 = ( + m._tokenizer, + m._ort_sess, + m._id2label, + ) try: m._tokenizer = mock.MagicMock() m._ort_sess = mock.MagicMock() @@ -893,11 +1046,15 @@ def test_batch_infer_result_assembly(self): enc.type_ids = [0, 0, 0] m._tokenizer.encode.return_value = enc # 3 texts, 3 classes - m._ort_sess.run.return_value = [np.array([ - [0.1, 0.7, 0.2], - [0.8, 0.1, 0.1], - [0.3, 0.3, 0.4], - ])] + m._ort_sess.run.return_value = [ + np.array( + [ + [0.1, 0.7, 0.2], + [0.8, 0.1, 0.1], + [0.3, 0.3, 0.4], + ] + ) + ] result = onnx_batch_infer(["t1", "t2", "t3"]) assert len(result) == 3 assert result[0]["label"] == "heading" @@ -918,7 +1075,6 @@ def test_safe_batch_infer_empty_texts(self): result = safe_batch_infer([]) - # ==================== (m) keywords.py 覆盖测试 ==================== @@ -945,13 +1101,13 @@ class TestNode(BaseKeywordsNode): assert node.pydantic_config is not None - class TestKeywordsENCheckKeywordLabel: """覆盖 keywords.py line 83: KeywordsEN._check_keyword_label matching""" def test_check_keyword_label_matches(self): """_check_keyword_label returns True for 'Keywords' (line 83)""" from wordformat.rules.keywords import KeywordsEN + node = KeywordsEN( value={"category": "abstract.keywords.english", "fingerprint": "fp"}, level=1, @@ -963,6 +1119,7 @@ def test_check_keyword_label_matches(self): def test_check_keyword_label_key_words(self): """_check_keyword_label matches 'KEY WORDS'""" from wordformat.rules.keywords import KeywordsEN + node = KeywordsEN( value={"category": "abstract.keywords.english", "fingerprint": "fp"}, level=1, @@ -974,6 +1131,7 @@ def test_check_keyword_label_key_words(self): def test_check_keyword_label_no_match(self): """_check_keyword_label returns False for content text""" from wordformat.rules.keywords import KeywordsEN + node = KeywordsEN( value={"category": "abstract.keywords.english", "fingerprint": "fp"}, level=1, @@ -983,13 +1141,13 @@ def test_check_keyword_label_no_match(self): assert node._check_keyword_label(mock_run) is False - class TestKeywordsENBase: """覆盖 keywords.py lines 114, 121, 130-135, 152-153""" def _make_en_node(self, config_dict=None): """Helper to create a KeywordsEN node with config loaded""" from wordformat.rules.keywords import KeywordsEN + node = KeywordsEN( value={"category": "abstract.keywords.english", "fingerprint": "fp"}, level=1, @@ -1001,6 +1159,7 @@ def _make_en_node(self, config_dict=None): def test_empty_run_skip(self, sample_yaml_config): """Empty run text is skipped (line 114)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1016,6 +1175,7 @@ def test_empty_run_skip(self, sample_yaml_config): def test_label_style_check(self, sample_yaml_config): """Label run style is checked (line 121)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1031,6 +1191,7 @@ def test_label_style_check(self, sample_yaml_config): def test_content_style_check(self, sample_yaml_config): """Content run style is checked (lines 130-135)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1047,6 +1208,7 @@ def test_content_style_check(self, sample_yaml_config): def test_keyword_count_validation_min(self, sample_yaml_config): """Keyword count < count_min triggers warning (via _run_rules)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1063,6 +1225,7 @@ def test_keyword_count_validation_min(self, sample_yaml_config): def test_keyword_count_validation_max(self, sample_yaml_config): """Keyword count > count_max triggers warning (via _run_rules)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1077,13 +1240,13 @@ def test_keyword_count_validation_max(self, sample_yaml_config): # count_max is 5, 6 keywords -> should trigger count warning - class TestKeywordsCNBase: """覆盖 keywords.py lines 177-180, 187, 218, 225, 234-239""" def _make_cn_node(self, config_dict=None): """Helper to create a KeywordsCN node with config loaded""" from wordformat.rules.keywords import KeywordsCN + node = KeywordsCN( value={"category": "abstract.keywords.chinese", "fingerprint": "fp"}, level=1, @@ -1095,13 +1258,16 @@ def _make_cn_node(self, config_dict=None): def test_paragraph_style_check(self, sample_yaml_config): """Paragraph style is checked (line 187)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() node = self._make_cn_node(config) doc = Document() p = doc.add_paragraph() - p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER # Wrong - should be left + p.paragraph_format.alignment = ( + WD_ALIGN_PARAGRAPH.CENTER + ) # Wrong - should be left run = p.add_run("关键词:人工智能") node.paragraph = p node.check_format(doc) @@ -1109,6 +1275,7 @@ def test_paragraph_style_check(self, sample_yaml_config): def test_label_style_check(self, sample_yaml_config): """Label run style is checked (line 218)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1123,6 +1290,7 @@ def test_label_style_check(self, sample_yaml_config): def test_content_style_check(self, sample_yaml_config): """Content run style is checked (line 225)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1139,6 +1307,7 @@ def test_content_style_check(self, sample_yaml_config): def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config): """Keyword count validation + trailing punctuation check (via _run_rules)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() @@ -1152,7 +1321,6 @@ def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config): # Text ends with ; which should trigger trailing punctuation warning - # ==================== (n) heading.py 覆盖测试 ==================== @@ -1162,6 +1330,7 @@ class TestHeadingLevelNodes: def test_heading_level1_load_config_dict(self): """HeadingLevel1Node.load_config with dict (lines 36-41)""" from wordformat.rules.heading import HeadingLevel1Node + node = HeadingLevel1Node( value={"category": "headings.level_1", "fingerprint": "fp"}, level=1, @@ -1181,6 +1350,7 @@ def test_heading_level1_load_config_dict(self): def test_heading_level2_load_config_dict(self): """HeadingLevel2Node.load_config with dict (lines 52-58)""" from wordformat.rules.heading import HeadingLevel2Node + node = HeadingLevel2Node( value={"category": "headings.level_2", "fingerprint": "fp"}, level=2, @@ -1199,6 +1369,7 @@ def test_heading_level2_load_config_dict(self): def test_heading_level3_load_config_dict(self): """HeadingLevel3Node.load_config with dict""" from wordformat.rules.heading import HeadingLevel3Node + node = HeadingLevel3Node( value={"category": "headings.level_3", "fingerprint": "fp"}, level=3, @@ -1218,6 +1389,7 @@ def test_heading_base_with_config(self, sample_yaml_config): """_base method with loaded config""" from wordformat.config.loader import init_config, get_config from wordformat.rules.heading import HeadingLevel1Node + init_config(sample_yaml_config) config = get_config() @@ -1233,17 +1405,18 @@ def test_heading_base_with_config(self, sample_yaml_config): node.check_format(doc) # 通过 RULES handler 执行格式检查 - # ==================== (o) set_style.py 额外覆盖测试 ==================== class TestSetStyleAdditionalCoverage: """覆盖 set_style.py lines 53-55, 147, 150, 167-168""" - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_exception_in_traverse_logs_and_raises( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """Node exception: logs warning then raises (lines 53-55)""" root_node = mock.MagicMock() @@ -1252,16 +1425,22 @@ def test_exception_in_traverse_logs_and_raises( mock_apply.side_effect = RuntimeError("traverse error") from wordformat.pipeline.orchestrate import auto_format_thesis_document + with pytest.raises(RuntimeError, match="traverse error"): auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") def test_body_text_filtering( - self, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """body_text nodes are no longer filtered out""" body_node = mock.MagicMock() @@ -1274,18 +1453,24 @@ def test_body_text_filtering( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) # body_text 不再被过滤 assert len(root_node.children) == 2 - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") @mock.patch("wordformat.pipeline.stages.promote_bodytext_in_subtrees_of_type") def test_promote_called( - self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_promote, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """promote_bodytext_in_subtrees_of_type is called (line 150)""" root_node = mock.MagicMock() @@ -1294,17 +1479,23 @@ def test_promote_called( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=True, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=True, ) assert mock_promote.call_count == 3 - @mock.patch("wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes") + @mock.patch( + "wordformat.pipeline.stages.FormattingExecutionStage.apply_format_check_to_all_nodes" + ) @mock.patch("wordformat.pipeline.stages.DocumentBuilder") @mock.patch("wordformat.numbering.process_heading_numbering") def test_numbering_processing( - self, mock_numbering, mock_builder, mock_apply, temp_docx, config_path, tmp_path + self, mock_numbering, mock_builder, mock_apply, temp_docx, config_path, tmp_path ): """Numbering processing when enabled (lines 167-168)""" root_node = mock.MagicMock() @@ -1313,19 +1504,22 @@ def test_numbering_processing( mock_apply.return_value = None from wordformat.pipeline.orchestrate import auto_format_thesis_document + # Mock config with numbering.enabled = True with mock.patch("wordformat.pipeline.stages.load_config") as mock_get_cfg: mock_cfg = mock.MagicMock() mock_cfg.numbering.enabled = True mock_get_cfg.return_value = mock_cfg auto_format_thesis_document( - jsonpath=temp_docx, docxpath=temp_docx, - configpath=config_path, savepath=str(tmp_path), check=False, + jsonpath=temp_docx, + docxpath=temp_docx, + configpath=config_path, + savepath=str(tmp_path), + check=False, ) mock_numbering.assert_called_once() - # ==================== (p) rules/body.py 额外覆盖测试 ==================== @@ -1336,6 +1530,7 @@ def test_body_text_apply_format(self, sample_yaml_config): """BodyText._base with p=False, r=False (line 27)""" from wordformat.config.loader import init_config, get_config from wordformat.rules.body import BodyText + init_config(sample_yaml_config) config = get_config() @@ -1355,6 +1550,7 @@ def test_body_text_apply_to_run(self, sample_yaml_config): """apply_format 通过 handler 修正字符格式。""" from wordformat.config.loader import init_config, get_config from wordformat.rules.body import BodyText + init_config(sample_yaml_config) config = get_config() @@ -1372,7 +1568,6 @@ def test_body_text_apply_to_run(self, sample_yaml_config): assert run.font.bold is False # Should have been fixed - # ==================== (q) rules/node.py 额外覆盖测试 ==================== @@ -1382,11 +1577,13 @@ class TestFormatNodeAdditional: def test_load_config_key_error_path(self): """load_config with non-dict intermediate path raises KeyError (line 52)""" from wordformat.rules.node import TreeNode + node = TreeNode(value={"category": "a.b.c", "fingerprint": "fp"}) config = {"a": "not_a_dict"} node.load_config(config) # Should not crash, returns empty config + # ==================== (r) tree.py 额外覆盖测试 ==================== @@ -1396,6 +1593,7 @@ class TestTreeAdditional: def test_level_order_empty_root(self): """level_order with root that has None value (line 47)""" from wordformat.tree import Tree + tree = Tree(None) result = list(tree.level_order()) assert result == [None] @@ -1405,13 +1603,14 @@ def test_print_tree_with_paragraph_value(self, capsys): from wordformat.tree import print_tree from wordformat.rules.node import TreeNode - node = TreeNode(value={"category": "test", "paragraph": "hello", "fingerprint": "fp"}) + node = TreeNode( + value={"category": "test", "paragraph": "hello", "fingerprint": "fp"} + ) print_tree(node) captured = capsys.readouterr() assert "test" in captured.out - # ==================== (s) settings.py 额外覆盖测试 ==================== @@ -1422,25 +1621,27 @@ def test_frozen_path(self): """sys.frozen=True sets BASE_DIR to sys.executable.parent (line 15)""" import sys from pathlib import Path - original_frozen = getattr(sys, 'frozen', None) + + original_frozen = getattr(sys, "frozen", None) sys.frozen = True try: with mock.patch("sys.executable", "/fake/app/bin"): # Reload settings module to pick up frozen state import importlib import wordformat.settings as settings_mod + importlib.reload(settings_mod) assert settings_mod.BASE_DIR == Path("/fake/app") finally: if original_frozen is None: - delattr(sys, 'frozen') + delattr(sys, "frozen") else: sys.frozen = original_frozen # Reload back to normal import importlib import wordformat.settings as settings_mod - importlib.reload(settings_mod) + importlib.reload(settings_mod) # ==================== (t) config/config.py 额外覆盖测试 ==================== @@ -1465,7 +1666,6 @@ def test_load_then_get_returns_config(self, config_path): assert cfg is get_config() - # ==================== 补充覆盖率:小缺口 ==================== @@ -1474,6 +1674,7 @@ class TestDocumentBuilderLoadFromFile: def test_load_paragraphs_from_file(self, tmp_path): import json + data = [{"category": "body_text", "paragraph": "test", "fingerprint": "fp1"}] json_file = tmp_path / "data.json" json_file.write_text(json.dumps(data), encoding="utf-8") @@ -1484,6 +1685,7 @@ def test_load_paragraphs_from_file(self, tmp_path): def test_load_paragraphs_invalid_json_falls_back_to_file(self, tmp_path): """无效 JSON 字符串回退到文件加载""" import json + data = [{"category": "body_text", "paragraph": "test", "fingerprint": "fp1"}] json_file = tmp_path / "data.json" json_file.write_text(json.dumps(data), encoding="utf-8") @@ -1492,13 +1694,13 @@ def test_load_paragraphs_invalid_json_falls_back_to_file(self, tmp_path): assert len(result) == 1 - class TestTreeBuilderAdditionalCoverage: """覆盖 tree_builder.py 剩余行""" def test_build_tree_with_unknown_category_skipped(self, sample_yaml_config): """未知类别的节点被跳过(create_node 返回 None)""" from wordformat.config.loader import init_config, get_config + init_config(sample_yaml_config) config = get_config() builder = DocumentTreeBuilder() @@ -1523,13 +1725,13 @@ def test_build_tree_with_body_text_category(self): assert len(root.children) == 1 - class TestNumberingAdditionalCoverage: """覆盖 numbering.py 剩余行 (49, 262)""" def test_auto_strip_numbering_empty_run_text(self, doc): """run 文本被完全清空后仍保留空 run""" from wordformat.numbering import _auto_strip_numbering + p = doc.add_paragraph() run = p.add_run("1.1 ") _auto_strip_numbering(p, ilvl=1) @@ -1544,4 +1746,3 @@ def test_process_heading_numbering_disabled(self, doc): # 创建 disabled 的 numbering config config = NumberingConfig(enabled=False) process_heading_numbering(None, doc, config) - diff --git a/tests/test_numbering.py b/tests/test_numbering.py index e98a8f7..532dcb4 100644 --- a/tests/test_numbering.py +++ b/tests/test_numbering.py @@ -3,6 +3,7 @@ 覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py """ + import os import pytest from io import StringIO @@ -97,6 +98,7 @@ def test_adds_numPr_to_paragraph(self, doc): p = doc.add_paragraph("test") apply_auto_numbering(p, num_id="100", ilvl="0") from docx.oxml.ns import qn + pPr = p._element.find(qn("w:pPr")) assert pPr is not None numPr = pPr.find(qn("w:numPr")) @@ -111,6 +113,7 @@ def test_replaces_existing_numPr(self, doc): apply_auto_numbering(p, num_id="50", ilvl="1") apply_auto_numbering(p, num_id="99", ilvl="2") from docx.oxml.ns import qn + pPr = p._element.find(qn("w:pPr")) numPr = pPr.find(qn("w:numPr")) assert numPr.find(qn("w:numId")).get(qn("w:val")) == "99" @@ -211,6 +214,7 @@ def test_no_numPr_returns_empty(self, doc): """段落有 pPr 但没有 numPr 时返回空字符串""" p = doc.add_paragraph("hello") from docx.oxml import OxmlElement + pPr = OxmlElement("w:pPr") p._element.insert(0, pPr) assert get_paragraph_numbering_text(p) == "" @@ -219,6 +223,7 @@ def test_no_numId_returns_empty(self, doc): """numPr 中没有 numId 时返回空字符串""" p = doc.add_paragraph("hello") from docx.oxml import OxmlElement + pPr = OxmlElement("w:pPr") numPr = OxmlElement("w:numPr") pPr.append(numPr) @@ -229,6 +234,7 @@ def test_numId_zero_returns_empty(self, doc): """numId 为 0 时返回空字符串""" p = doc.add_paragraph("hello") from docx.oxml import OxmlElement + pPr = OxmlElement("w:pPr") numPr = OxmlElement("w:numPr") numId_elem = OxmlElement("w:numId") @@ -242,6 +248,7 @@ def test_no_numbering_part_returns_empty(self, doc): """文档没有 numbering part 时返回空字符串(已修复:捕获 NotImplementedError)""" p = doc.add_paragraph("hello") from docx.oxml import OxmlElement + pPr = OxmlElement("w:pPr") numPr = OxmlElement("w:numPr") numId_elem = OxmlElement("w:numId") @@ -254,6 +261,7 @@ def test_no_numbering_part_returns_empty(self, doc): p._element.insert(0, pPr) # 移除 numbering 关系使 numbering_part 访问抛出异常 from docx.opc.constants import RELATIONSHIP_TYPE as RT + rels = doc.part.rels to_remove = [k for k, v in rels.items() if v.reltype == RT.NUMBERING] for k in to_remove: @@ -261,7 +269,9 @@ def test_no_numbering_part_returns_empty(self, doc): # 修复后捕获 NotImplementedError 并返回空字符串 assert get_paragraph_numbering_text(p) == "" - def _setup_numbering(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0"): + def _setup_numbering( + self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0" + ): """辅助方法:为文档创建 numbering part 和编号定义""" from docx.oxml import OxmlElement from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -316,6 +326,7 @@ def _setup_numbering(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", a def _add_numPr_to_paragraph(self, p, num_id="1", ilvl="0"): """辅助方法:为段落添加 numPr""" from docx.oxml import OxmlElement + pPr = OxmlElement("w:pPr") numPr = OxmlElement("w:numPr") numId_elem = OxmlElement("w:numId") @@ -515,31 +526,34 @@ class TestGetLevelFmt: def test_existing_level_returns_fmt(self): from lxml import etree + abstract_num = etree.fromstring( '' ' ' ' ' - ' ' - '' + " " + "" ) assert _get_level_fmt(abstract_num, 0) == "upperRoman" def test_missing_level_returns_decimal(self): from lxml import etree + abstract_num = etree.fromstring( '' - '' + "" ) assert _get_level_fmt(abstract_num, 0) == "decimal" def test_no_numFmt_returns_decimal(self): from lxml import etree + abstract_num = etree.fromstring( '' ' ' ' ' - ' ' - '' + " " + "" ) assert _get_level_fmt(abstract_num, 0) == "decimal" @@ -621,7 +635,6 @@ def test_style_not_in_doc_no_error(self, doc): remove_all_numbering(doc) - # ============================================================ # numbering.py — auto_strip_numbering (empty result) # ============================================================ @@ -768,6 +781,7 @@ def test_no_template_skips_level(self, doc): def test_creates_numbering_part_when_missing(self, tmp_path): """文档没有 numbering part 时自动创建""" from docx.opc.constants import RELATIONSHIP_TYPE as RT + # 创建一个没有 numbering 关系的文档 doc = Document() # 移除已有的 numbering 关系(如果有) @@ -1160,26 +1174,32 @@ def test_reference_entry_strips_manual_then_applies_numbering(self, doc): class TestParseRefNumbers: def test_single_number(self): from wordformat.hyperlinks import _parse_ref_numbers + assert _parse_ref_numbers("[1]") == [1] def test_multiple_numbers(self): from wordformat.hyperlinks import _parse_ref_numbers + assert _parse_ref_numbers("[1,2,3]") == [1, 2, 3] def test_range(self): from wordformat.hyperlinks import _parse_ref_numbers + assert _parse_ref_numbers("[1-3]") == [1, 2, 3] def test_mixed(self): from wordformat.hyperlinks import _parse_ref_numbers + assert _parse_ref_numbers("[1,3-5]") == [1, 3, 4, 5] def test_chinese_comma(self): from wordformat.hyperlinks import _parse_ref_numbers + assert _parse_ref_numbers("[1,2,3]") == [1, 2, 3] def test_spaces(self): from wordformat.hyperlinks import _parse_ref_numbers + assert _parse_ref_numbers("[1, 2, 3]") == [1, 2, 3] @@ -1191,6 +1211,7 @@ def test_spaces(self): class TestInsertBookmark: def test_adds_bookmark_to_paragraph(self, doc): from wordformat.hyperlinks import _insert_bookmark, _next_bookmark_id + p = doc.add_paragraph("A reference entry.") bid = _next_bookmark_id() _insert_bookmark(p, "_Ref1", bid) @@ -1204,6 +1225,7 @@ def test_adds_bookmark_to_paragraph(self, doc): def test_bookmark_before_first_run(self, doc): from wordformat.hyperlinks import _insert_bookmark, _next_bookmark_id + p = doc.add_paragraph("Text") bid = _next_bookmark_id() _insert_bookmark(p, "_RefTest", bid) @@ -1231,6 +1253,7 @@ def test_bookmark_before_first_run(self, doc): class TestWrapCitationsInHyperlinks: def test_wraps_citation_in_hyperlink(self, doc): from wordformat.hyperlinks import _wrap_citations_in_hyperlinks + p = doc.add_paragraph("参见") p.add_run("[1]").font.superscript = True p.add_run("的研究。") @@ -1244,6 +1267,7 @@ def test_wraps_citation_in_hyperlink(self, doc): def test_regular_brackets_not_wrapped(self, doc): from wordformat.hyperlinks import _wrap_citations_in_hyperlinks + p = doc.add_paragraph("这是一个[注]释说明。") _wrap_citations_in_hyperlinks(p, ["_Ref1"]) @@ -1254,6 +1278,7 @@ def test_regular_brackets_not_wrapped(self, doc): def test_run_retains_superscript_in_hyperlink(self, doc): from wordformat.hyperlinks import _wrap_citations_in_hyperlinks + p = doc.add_paragraph("见") r = p.add_run("[1]") r.font.superscript = True @@ -1272,7 +1297,6 @@ def test_run_retains_superscript_in_hyperlink(self, doc): assert rStyle.get(qn("w:val")) == "Hyperlink" - # ============================================================ # utils.py — _format_number 额外覆盖测试 # ============================================================ @@ -1361,36 +1385,39 @@ class TestGetLevelFmtAdditional: def test_level_missing_numFmt_returns_decimal(self): """lvl 存在但缺少 numFmt 元素时返回 decimal""" from lxml import etree + abstract_num = etree.fromstring( '' ' ' ' ' - ' ' - '' + " " + "" ) assert _get_level_fmt(abstract_num, 0) == "decimal" def test_level_missing_numFmt_val_returns_decimal(self): """numFmt 元素存在但缺少 w:val 属性时返回 decimal""" from lxml import etree + abstract_num = etree.fromstring( '' ' ' - ' ' - ' ' - '' + " " + " " + "" ) assert _get_level_fmt(abstract_num, 0) == "decimal" def test_nonexistent_level_returns_decimal(self): """请求不存在的级别返回 decimal""" from lxml import etree + abstract_num = etree.fromstring( '' ' ' ' ' - ' ' - '' + " " + "" ) assert _get_level_fmt(abstract_num, 5) == "decimal" @@ -1403,7 +1430,9 @@ def test_nonexistent_level_returns_decimal(self): class TestCountNumberingLevels: """测试 _count_numbering_levels 编号级别计数逻辑""" - def _setup_numbering_doc(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0"): + def _setup_numbering_doc( + self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1", abstract_num_id="0" + ): """辅助方法:为文档创建 numbering part""" from docx.oxml import OxmlElement from docx.opc.constants import RELATIONSHIP_TYPE as RT @@ -1460,6 +1489,7 @@ def _setup_numbering_doc(self, doc, num_fmt="decimal", lvl_text="%1.", num_id="1 def _add_numPr_to_paragraph(self, p, num_id="1", ilvl="0"): """辅助方法:为段落添加 numPr""" from docx.oxml import OxmlElement + pPr = OxmlElement("w:pPr") numPr = OxmlElement("w:numPr") numId_elem = OxmlElement("w:numId") @@ -1552,4 +1582,3 @@ def test_no_numbered_paragraphs_before_target(self, doc): numbering_elm = numbering_part._element result = _count_numbering_levels(numbering_elm, "0", p) assert result == {0: 1} - diff --git a/tests/test_utils.py b/tests/test_utils.py index 186e3f8..f3ec7c5 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -3,6 +3,7 @@ 覆盖 tree.py, utils.py, rules/node.py, numbering.py, settings.py """ + import os import pytest from io import StringIO @@ -199,7 +200,6 @@ def test_nonexistent_file_raises(self): # ============================================================ - # ============================================================ # settings.py # ============================================================ @@ -231,7 +231,6 @@ def test_server_host_format(self): # ============================================================ - # ============================================================ # utils.py — remove_all_numbering # ============================================================ @@ -312,6 +311,3 @@ def test_style_not_in_doc_no_error(self, doc): # ============================================================ # numbering.py — auto_strip_numbering (empty result) # ============================================================ - - - diff --git a/tests/utils/test_text.py b/tests/utils/test_text.py index 428b922..3aa5921 100644 --- a/tests/utils/test_text.py +++ b/tests/utils/test_text.py @@ -1,7 +1,9 @@ """utils/_text.py 测试 — 罗马数字、中文数字、题注解析。""" + import pytest from wordformat.utils import _from_chinese_num, _from_roman, parse_caption_text + class TestFromChineseNum: def test_single_digit(self): assert _from_chinese_num("一") == 1 @@ -39,6 +41,7 @@ def test_invalid_raises(self): # ======================== parse_caption_text ======================== + class TestFromRoman: def test_basic_singles(self): assert _from_roman("I") == 1 @@ -69,6 +72,7 @@ def test_invalid_raises(self): # ======================== _from_chinese_num ======================== + class TestParseCaptionText: def test_basic_figure_arabic(self): result = parse_caption_text("图1.1 系统架构图") From 169a1eee9ed17a177b8accfe6829cd56f6937c0c Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 18:09:08 +0800 Subject: [PATCH 02/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20NODE=5FTYPE=20?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E4=B8=8D=E5=8C=B9=E9=85=8D=20+=20=E6=B8=85?= =?UTF-8?q?=E7=90=86=E6=AD=BB=E9=85=8D=E7=BD=AE=20+=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=20wordformat-skill=20=E8=87=AA=E5=AE=9A=E4=B9=89=E8=84=9A?= =?UTF-8?q?=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FigureImage.NODE_TYPE: "figure_image" → "figures.image",与 YAML figures.image 对齐 - TableObject.NODE_TYPE: "table_object" → "tables.object",与 YAML tables.object 对齐 - 移除 tables.content 配置块(无对应 FormatNode 消费者) - 移除 numbering.captions 配置块(与 figures/tables.rules.caption_numbering 重复) - 移除 wordformat-skill/scripts/ 目录(自定义验证脚本) --- example/undergrad_thesis.yaml | 17 - src/wordformat/rules/object.py | 4 +- wordformat-skill/scripts/setup_config.py | 234 ----------- wordformat-skill/scripts/validate_config.py | 431 -------------------- wordformat-skill/scripts/validate_json.py | 248 ----------- 5 files changed, 2 insertions(+), 932 deletions(-) delete mode 100644 wordformat-skill/scripts/setup_config.py delete mode 100644 wordformat-skill/scripts/validate_config.py delete mode 100644 wordformat-skill/scripts/validate_json.py diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml index b663f29..900f24c 100644 --- a/example/undergrad_thesis.yaml +++ b/example/undergrad_thesis.yaml @@ -227,17 +227,6 @@ tables: <<: *global_format alignment: '居中对齐' first_line_indent: '0字符' - # -- 表格内容格式(单元格内文字)-- - content: - <<: *global_format - chinese_font_name: '宋体' - english_font_name: 'Times New Roman' - font_size: '五号' - line_spacingrule: '单倍行距' - alignment: '居中对齐' - first_line_indent: '0字符' - space_before: "0行" - space_after: "0行" rules: caption_numbering: enabled: true @@ -292,12 +281,6 @@ acknowledgements: numbering: enabled: true # 总开关:是否启用自动编号 - # -- 题注编号校验/修正 -- - captions: - enabled: true # 是否启用题注编号校验/修正 - separator: '.' # 章节号与题注编号间的分隔符 - label_number_space: false # 标签与编号间加空格(图 1.1 vs 图1.1) - # -- 一级标题编号(如"第1章")-- level_1: enabled: true diff --git a/src/wordformat/rules/object.py b/src/wordformat/rules/object.py index 6fbee27..5a2dd39 100644 --- a/src/wordformat/rules/object.py +++ b/src/wordformat/rules/object.py @@ -12,7 +12,7 @@ class FigureImage(FormatNode): 只检查对齐和首行缩进,不检查行距、字体等。 """ - NODE_TYPE = "figure_image" + NODE_TYPE = "figures.image" NODE_LABEL = "图片段落" DEFAULTS = {"alignment": "居中对齐", "first_line_indent": "0字符"} DEFAULT_RULES = {} @@ -56,7 +56,7 @@ def _base(self, doc, p: bool, r: bool): class TableObject(FormatNode): """表格对象节点(表格整体格式,非题注)。""" - NODE_TYPE = "table_object" + NODE_TYPE = "tables.object" NODE_LABEL = "表格对象" DEFAULTS = {} DEFAULT_RULES = {} # 表格对象格式由 Word 表格属性控制 diff --git a/wordformat-skill/scripts/setup_config.py b/wordformat-skill/scripts/setup_config.py deleted file mode 100644 index c0c5fcc..0000000 --- a/wordformat-skill/scripts/setup_config.py +++ /dev/null @@ -1,234 +0,0 @@ -#!/usr/bin/env python3 -""" -WordFormat 配置管理脚本 - -用法: - # 生成配置模板 - wordf config -o config.yaml - - # 查找已有预设 - python setup_config.py --list-presets - - # 从预设库复制配置 - python setup_config.py --use "清华大学_计算机学院_本科" --output config.yaml - - # 验证配置文件 - python setup_config.py --validate --config config.yaml - - # 保存配置到预设库 - python setup_config.py --save --config config.yaml --name "XX大学_XX学院_本科" -""" - -import argparse -import os -import shutil -import sys -from pathlib import Path - - -def _get_pip_mirror() -> list[str]: - """根据用户地区自动选择 pip 镜像源""" - import locale - - lang = locale.getdefaultlocale()[0] or "" - # 中文环境优先使用清华镜像 - if lang.startswith("zh"): - return [ - sys.executable, - "-m", - "pip", - "install", - "pyyaml", - "--break-system-packages", - "-q", - "-i", - "https://pypi.tuna.tsinghua.edu.cn/simple", - ] - return [ - sys.executable, - "-m", - "pip", - "install", - "pyyaml", - "--break-system-packages", - "-q", - ] - - -try: - import yaml -except ImportError: - import subprocess - - subprocess.check_call(_get_pip_mirror()) - import yaml - -# 预设目录:当前工作目录下的 presets/ -PRESETS_DIR = Path("presets") - -# 获取脚本所在目录(用于定位模板和验证脚本) -SCRIPT_DIR = Path(__file__).resolve().parent - - -def get_presets_dir() -> Path: - """确保预设目录存在""" - PRESETS_DIR.mkdir(parents=True, exist_ok=True) - return PRESETS_DIR - - -def list_presets(): - """列出所有已保存的预设配置""" - presets_dir = get_presets_dir() - presets = sorted(presets_dir.glob("*.yaml")) - if not presets: - print("暂无已保存的预设配置。") - print(f"预设目录: {presets_dir}") - return - print(f"已保存的预设配置(共 {len(presets)} 个):") - print(f"目录: {presets_dir}") - print("-" * 50) - for p in presets: - print(f" {p.stem}") - - -def use_preset(name: str, output: str): - """从预设库复制配置到工作目录""" - presets_dir = get_presets_dir() - # 支持不带 .yaml 后缀 - if not name.endswith(".yaml"): - name = name + ".yaml" - preset_path = presets_dir / name - - if not preset_path.exists(): - # 模糊匹配:如果用户输入的是部分名称,尝试查找 - matches = [ - p for p in presets_dir.glob("*.yaml") if name.replace(".yaml", "") in p.stem - ] - if len(matches) == 1: - preset_path = matches[0] - print(f"模糊匹配到: {preset_path.stem}") - elif len(matches) > 1: - print("找到多个匹配的预设:") - for m in matches: - print(f" - {m.stem}") - print("请指定更精确的名称。") - sys.exit(1) - else: - print(f"未找到预设配置: {name.replace('.yaml', '')}") - print("可用预设:") - list_presets() - sys.exit(1) - - shutil.copy2(preset_path, output) - print(f"已复制预设配置: {preset_path.stem} -> {output}") - - -def save_preset(config_path: str, name: str): - """保存配置到预设库""" - presets_dir = get_presets_dir() - if not name.endswith(".yaml"): - name = name + ".yaml" - dest = presets_dir / name - - if not os.path.exists(config_path): - print(f"配置文件不存在: {config_path}") - sys.exit(1) - - # 先验证 - errors = validate(config_path) - if errors: - print(f"配置文件有 {len(errors)} 个问题,请先修复后再保存。") - sys.exit(1) - - shutil.copy2(config_path, dest) - print(f"已保存预设配置: {dest}") - - -def validate(config_path: str) -> list[str]: - """验证配置文件,返回错误列表""" - # 导入验证逻辑 - validate_script = SCRIPT_DIR / "validate_config.py" - if validate_script.exists(): - # 动态导入验证函数 - import importlib.util - - spec = importlib.util.spec_from_file_location( - "validate_config", validate_script - ) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - try: - config = yaml.safe_load(open(config_path, encoding="utf-8")) - except yaml.YAMLError as e: - return [f"YAML 语法错误: {e}"] - if not isinstance(config, dict): - return ["配置文件根节点必须是字典"] - return mod.validate_config(config) - else: - # 简单验证:检查 YAML 语法 - try: - config = yaml.safe_load(open(config_path, encoding="utf-8")) - except yaml.YAMLError as e: - return [f"YAML 语法错误: {e}"] - return [] - - -def main(): - parser = argparse.ArgumentParser( - description="WordFormat 配置准备工具", - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=""" -示例: - wordf config -o config.yaml # 生成模板 - python setup_config.py --list-presets # 列预设 - python setup_config.py --use "清华大学_计算机学院_本科" --output config.yaml - python setup_config.py --validate --config config.yaml # 验证 - python setup_config.py --save --config config.yaml --name "XX大学_XX学院_本科" -""", - ) - - group = parser.add_mutually_exclusive_group(required=True) - group.add_argument( - "--list-presets", action="store_true", help="列出所有已保存的预设配置" - ) - group.add_argument("--use", metavar="NAME", help="从预设库复制配置(支持模糊匹配)") - group.add_argument("--validate", action="store_true", help="验证配置文件") - group.add_argument("--save", action="store_true", help="保存配置到预设库") - - parser.add_argument( - "--output", - "-o", - default="config.yaml", - help="输出文件路径(默认: config.yaml)", - ) - parser.add_argument( - "--config", "-c", default="config.yaml", help="要验证/保存的配置文件路径" - ) - parser.add_argument( - "--name", "-n", help="保存预设时的名称(格式: 学校_学院_论文类型)" - ) - - args = parser.parse_args() - - if args.list_presets: - list_presets() - elif args.use: - use_preset(args.use, args.output) - elif args.validate: - errors = validate(args.config) - if not errors: - print(f"✅ 配置文件验证通过: {args.config}") - else: - print(f"❌ 发现 {len(errors)} 个问题:") - for err in errors: - print(f" {err}") - sys.exit(1) - elif args.save: - if not args.name: - print("错误: --save 需要 --name 参数(格式: 学校_学院_论文类型)") - sys.exit(1) - save_preset(args.config, args.name) - - -if __name__ == "__main__": - main() diff --git a/wordformat-skill/scripts/validate_config.py b/wordformat-skill/scripts/validate_config.py deleted file mode 100644 index bcc41e5..0000000 --- a/wordformat-skill/scripts/validate_config.py +++ /dev/null @@ -1,431 +0,0 @@ -#!/usr/bin/env python3 -""" -WordFormat 配置文件验证器 - -验证用户编辑的 config.yaml 是否合法: -1. 检查 YAML 语法是否正确 -2. 检查是否包含非法字段(模板中不存在的字段) -3. 检查必要字段是否缺失 -4. 检查字段值是否在允许范围内 - -用法: - python validate_config.py --config config.yaml - python validate_config.py --config config.yaml --fix # 自动移除非法字段 -""" - -import argparse -import sys -from pathlib import Path - - -def _get_pip_mirror() -> list[str]: - """根据用户地区自动选择 pip 镜像源""" - import locale - - lang = locale.getdefaultlocale()[0] or "" - # 中文环境优先使用清华镜像 - if lang.startswith("zh"): - return [ - sys.executable, - "-m", - "pip", - "install", - "pyyaml", - "--break-system-packages", - "-q", - "-i", - "https://pypi.tuna.tsinghua.edu.cn/simple", - ] - return [ - sys.executable, - "-m", - "pip", - "install", - "pyyaml", - "--break-system-packages", - "-q", - ] - - -try: - import yaml -except ImportError: - import subprocess - - subprocess.check_call(_get_pip_mirror()) - import yaml - - -# ==================== 合法字段白名单 ==================== - -# style_checks_warning 下的合法字段 -STYLE_CHECKS_FIELDS = { - "bold", - "italic", - "underline", - "font_size", - "font_name", - "font_color", - "alignment", - "space_before", - "space_after", - "line_spacing", - "line_spacingrule", - "left_indent", - "right_indent", - "first_line_indent", - "builtin_style_name", -} - -# global_format 下的合法字段 -GLOBAL_FORMAT_FIELDS = { - "alignment", - "space_before", - "space_after", - "line_spacingrule", - "line_spacing", - "left_indent", - "right_indent", - "first_line_indent", - "builtin_style_name", - "chinese_font_name", - "english_font_name", - "font_size", - "font_color", - "bold", - "italic", - "underline", -} - -# abstract.chinese.chinese_title / chinese_content 合法字段 -ABSTRACT_CN_FIELDS = GLOBAL_FORMAT_FIELDS - -# abstract.english.english_title / english_content 合法字段 -ABSTRACT_EN_FIELDS = GLOBAL_FORMAT_FIELDS - -# abstract.keywords.chinese / english 合法字段 -KEYWORDS_FIELDS = GLOBAL_FORMAT_FIELDS | { - "label", - "rules", -} - -# headings 各级别合法字段 -HEADING_FIELDS = GLOBAL_FORMAT_FIELDS - -# figures 合法字段 -FIGURES_FIELDS = GLOBAL_FORMAT_FIELDS | { - "caption_prefix", - "rules", -} - -# tables 合法字段 -TABLES_FIELDS = GLOBAL_FORMAT_FIELDS | { - "caption_prefix", - "content", - "rules", -} - -# references.title 合法字段 -REF_TITLE_FIELDS = GLOBAL_FORMAT_FIELDS - -# references.content 合法字段 -REF_CONTENT_FIELDS = GLOBAL_FORMAT_FIELDS - -# acknowledgements.title / content 合法字段 -ACK_FIELDS = GLOBAL_FORMAT_FIELDS - -# numbering 合法字段 -NUMBERING_FIELDS = {"enabled", "level_1", "level_2", "level_3"} -NUMBERING_LEVEL_FIELDS = { - "enabled", - "template", - "suffix", - "numbering_indent", - "text_indent", -} - -# 值范围校验 -ALIGNMENT_VALUES = {"左对齐", "居中对齐", "右对齐", "两端对齐", "分散对齐"} -LINE_SPACINGRULE_VALUES = { - "单倍行距", - "1.5倍行距", - "2倍行距", - "最小值", - "固定值", - "多倍行距", -} -CAPTION_POSITION_VALUES = {"above", "below"} -CAPTION_PREFIX_VALUES = {"图", "表"} -BUILTIN_STYLE_VALUES = {"正文", "Heading 1", "Heading 2", "Heading 3", "题注"} - - -def load_yaml(filepath: str) -> dict: - """加载 YAML 文件""" - with open(filepath, encoding="utf-8") as f: - return yaml.safe_load(f) - - -def check_fields(data: dict, allowed_fields: set, path: str) -> list[str]: - """检查字段是否合法,返回非法字段列表""" - if not isinstance(data, dict): - return [] - errors = [] - for key in data: - if key not in allowed_fields: - errors.append(f" 非法字段: {path}.{key} = {data[key]!r}") - return errors - - -def check_value(data: dict, field: str, allowed_values: set, path: str) -> list[str]: - """检查字段值是否在允许范围内""" - if field in data and data[field] not in allowed_values: - return [f" 值错误: {path}.{field} = {data[field]!r},允许值: {allowed_values}"] - return [] - - -def validate_config(config: dict) -> list[str]: - """验证配置文件,返回错误列表""" - errors = [] - - # 1. 检查顶层结构 - required_top_keys = { - "style_checks_warning", - "global_format", - "abstract", - "headings", - "body_text", - "figures", - "tables", - "references", - "acknowledgements", - } - actual_top_keys = set(config.keys()) if config else set() - missing = required_top_keys - actual_top_keys - if missing: - errors.append(f" 缺少顶层节点: {missing}") - # numbering 是可选的,不检查缺失 - extra = actual_top_keys - required_top_keys - {"numbering"} - if extra: - errors.append(f" 非法顶层节点: {extra}") - - # 2. style_checks_warning - if "style_checks_warning" in config: - errors.extend( - check_fields( - config["style_checks_warning"], - STYLE_CHECKS_FIELDS, - "style_checks_warning", - ) - ) - - # 3. global_format - if "global_format" in config: - errors.extend( - check_fields(config["global_format"], GLOBAL_FORMAT_FIELDS, "global_format") - ) - - # 4. abstract - if "abstract" in config: - ab = config["abstract"] - if isinstance(ab, dict): - for lang in ["chinese", "english"]: - if lang in ab and isinstance(ab[lang], dict): - for section in ab[lang]: - section_data = ab[lang][section] - allowed = ( - KEYWORDS_FIELDS - if "keywords" in section - else ( - ABSTRACT_CN_FIELDS - if lang == "chinese" - else ABSTRACT_EN_FIELDS - ) - ) - errors.extend( - check_fields( - section_data, allowed, f"abstract.{lang}.{section}" - ) - ) - - # 5. headings - if "headings" in config: - hd = config["headings"] - if isinstance(hd, dict): - for level in ["level_1", "level_2", "level_3"]: - if level in hd: - errors.extend( - check_fields(hd[level], HEADING_FIELDS, f"headings.{level}") - ) - - # 6. body_text - if "body_text" in config: - errors.extend( - check_fields(config["body_text"], GLOBAL_FORMAT_FIELDS, "body_text") - ) - - # 7. figures - if "figures" in config: - errors.extend(check_fields(config["figures"], FIGURES_FIELDS, "figures")) - - # 8. tables - if "tables" in config: - errors.extend(check_fields(config["tables"], TABLES_FIELDS, "tables")) - - # 9. references - if "references" in config: - ref = config["references"] - if isinstance(ref, dict): - for section in ["title", "content"]: - if section in ref: - allowed = ( - REF_TITLE_FIELDS if section == "title" else REF_CONTENT_FIELDS - ) - errors.extend( - check_fields(ref[section], allowed, f"references.{section}") - ) - - # 10. acknowledgements - if "acknowledgements" in config: - ack = config["acknowledgements"] - if isinstance(ack, dict): - for section in ["title", "content"]: - if section in ack: - errors.extend( - check_fields( - ack[section], ACK_FIELDS, f"acknowledgements.{section}" - ) - ) - - # 11. numbering(可选) - if "numbering" in config: - num = config["numbering"] - if isinstance(num, dict): - errors.extend(check_fields(num, NUMBERING_FIELDS, "numbering")) - for level in ["level_1", "level_2", "level_3"]: - if level in num and isinstance(num[level], dict): - errors.extend( - check_fields( - num[level], NUMBERING_LEVEL_FIELDS, f"numbering.{level}" - ) - ) - - return errors - - -def remove_extra_fields(data: dict, allowed_fields: set) -> dict: - """递归移除非法字段""" - if not isinstance(data, dict): - return data - return { - k: remove_extra_fields(v, allowed_fields) if isinstance(v, dict) else v - for k, v in data.items() - if k in allowed_fields - } - - -def fix_config(config: dict) -> dict: - """自动修复配置文件,移除所有非法字段""" - if not isinstance(config, dict): - return config - - fixed = {} - for key, value in config.items(): - if key == "style_checks_warning" and isinstance(value, dict): - fixed[key] = remove_extra_fields(value, STYLE_CHECKS_FIELDS) - elif key == "global_format" and isinstance(value, dict): - fixed[key] = remove_extra_fields(value, GLOBAL_FORMAT_FIELDS) - elif key == "abstract" and isinstance(value, dict): - fixed[key] = {} - for lang, lang_data in value.items(): - if isinstance(lang_data, dict): - fixed[key][lang] = {} - for section, section_data in lang_data.items(): - if isinstance(section_data, dict): - allowed = ( - KEYWORDS_FIELDS - if "keywords" in section - else GLOBAL_FORMAT_FIELDS - ) - fixed[key][lang][section] = remove_extra_fields( - section_data, allowed - ) - elif key == "headings" and isinstance(value, dict): - fixed[key] = {} - for level, level_data in value.items(): - if isinstance(level_data, dict): - fixed[key][level] = remove_extra_fields(level_data, HEADING_FIELDS) - elif key == "body_text" and isinstance(value, dict): - fixed[key] = remove_extra_fields(value, GLOBAL_FORMAT_FIELDS) - elif key == "figures" and isinstance(value, dict): - fixed[key] = remove_extra_fields(value, FIGURES_FIELDS) - elif key == "tables" and isinstance(value, dict): - fixed[key] = remove_extra_fields(value, TABLES_FIELDS) - elif key == "references" and isinstance(value, dict): - fixed[key] = {} - for section, section_data in value.items(): - if isinstance(section_data, dict): - allowed = ( - REF_TITLE_FIELDS if section == "title" else REF_CONTENT_FIELDS - ) - fixed[key][section] = remove_extra_fields(section_data, allowed) - elif key == "acknowledgements" and isinstance(value, dict): - fixed[key] = {} - for section, section_data in value.items(): - if isinstance(section_data, dict): - fixed[key][section] = remove_extra_fields(section_data, ACK_FIELDS) - else: - fixed[key] = value - return fixed - - -def main(): - parser = argparse.ArgumentParser(description="WordFormat 配置文件验证器") - parser.add_argument( - "--config", "-c", required=True, help="要验证的 YAML 配置文件路径" - ) - parser.add_argument( - "--fix", "-f", action="store_true", help="自动移除非法字段并覆盖原文件" - ) - args = parser.parse_args() - - filepath = args.config - if not Path(filepath).exists(): - print(f"错误: 文件不存在: {filepath}") - sys.exit(1) - - # 加载 YAML - try: - config = load_yaml(filepath) - except yaml.YAMLError as e: - print(f"YAML 语法错误: {e}") - sys.exit(1) - - if not isinstance(config, dict): - print("错误: 配置文件根节点必须是字典/映射") - sys.exit(1) - - # 验证 - errors = validate_config(config) - - if not errors: - print(f"✅ 配置文件验证通过: {filepath}") - sys.exit(0) - - print(f"❌ 发现 {len(errors)} 个问题:") - for err in errors: - print(err) - - if args.fix: - fixed = fix_config(config) - # 保留 YAML 锚点引用(fix 会破坏锚点,需要特殊处理) - # 由于 fix 会移除 <<: *global_format,这里提示用户 - print("\n⚠️ --fix 模式会移除 YAML 锚点引用,建议手动编辑修复。") - print(" 运行 wordf config 查看可用字段。") - sys.exit(1) - - print("\n请手动修复以上问题,运行 wordf config 查看可用字段。") - sys.exit(1) - - -if __name__ == "__main__": - main() diff --git a/wordformat-skill/scripts/validate_json.py b/wordformat-skill/scripts/validate_json.py deleted file mode 100644 index 13e6b55..0000000 --- a/wordformat-skill/scripts/validate_json.py +++ /dev/null @@ -1,248 +0,0 @@ -#!/usr/bin/env python3 -""" -WordFormat JSON 标签文件校验脚本 - -校验 wordf gj 生成的 JSON 文件中的 category 字段是否合法, -并输出每个段落的分类结果供人工检查。 - -用法: - # 校验 JSON 文件 - python validate_json.py --json output/论文_1234567890.json - - # 输出分类统计 - python validate_json.py --json output/论文_1234567890.json --stats - - # 仅列出可疑分类(置信度低于阈值) - python validate_json.py --json output/论文_1234567890.json --threshold 0.8 -""" - -import argparse -import json -import sys -from collections import Counter - -# 模型实际输出的所有合法 category(来源于 id2label.json) -# 以及人工修正时使用的辅助 category -VALID_CATEGORIES = { - "abstract_chinese_title", - "abstract_chinese_title_content", - "abstract_english_title", - "abstract_english_title_content", - "keywords_chinese", - "keywords_english", - "heading_level_1", - "heading_level_2", - "heading_level_3", - "heading_mulu", - "heading_fulu", - "references_title", - "acknowledgements_title", - "caption_figure", - "caption_table", - "figure_image", - "table_object", - "body_text", - "other", -} - -# category 中文说明 -CATEGORY_LABELS = { - "abstract_chinese_title": "中文摘要标题", - "abstract_chinese_title_content": "中文摘要标题+内容", - "abstract_english_title": "英文摘要标题", - "abstract_english_title_content": "英文摘要标题+内容", - "keywords_chinese": "中文关键词", - "keywords_english": "英文关键词", - "heading_level_1": "一级标题(章)", - "heading_level_2": "二级标题(节)", - "heading_level_3": "三级标题(小节)", - "heading_mulu": "目录标题", - "heading_fulu": "附录标题", - "references_title": "参考文献标题", - "acknowledgements_title": "致谢标题", - "caption_figure": "图题注", - "caption_table": "表题注", - "body_text": "正文", - "other": "其他(跳过格式化)", -} - - -def validate_json(json_path: str, threshold: float = 0.0) -> list[dict]: - """校验 JSON 文件,返回问题列表""" - try: - with open(json_path, encoding="utf-8") as f: - data = json.load(f) - except json.JSONDecodeError as e: - return [{"index": -1, "error": f"JSON 语法错误: {e}"}] - except FileNotFoundError: - return [{"index": -1, "error": f"文件不存在: {json_path}"}] - - if not isinstance(data, list): - return [{"index": -1, "error": "JSON 根节点必须是数组"}] - - issues = [] - for i, item in enumerate(data): - if not isinstance(item, dict): - issues.append( - {"index": i, "error": f"第 {i} 项不是字典: {type(item).__name__}"} - ) - continue - - # 检查 category 字段 - category = item.get("category", "") - if not category: - issues.append( - { - "index": i, - "error": "缺少 category 字段", - "paragraph": item.get("paragraph", "")[:50], - } - ) - continue - - if category not in VALID_CATEGORIES: - issues.append( - { - "index": i, - "error": f"非法 category: '{category}'", - "paragraph": item.get("paragraph", "")[:50], - "hint": f"合法值: {sorted(VALID_CATEGORIES)}", - } - ) - - # 检查置信度 - score = item.get("score", 0) - if isinstance(score, (int, float)) and score < threshold: - issues.append( - { - "index": i, - "warning": f"低置信度: {score}", - "category": category, - "paragraph": item.get("paragraph", "")[:80], - } - ) - - return issues - - -def print_results(data: list, threshold: float = 0.0): - """打印分类结果供人工检查""" - print(f"\n{'=' * 70}") - print(f" JSON 标签检查结果(共 {len(data)} 个段落)") - print(f"{'=' * 70}\n") - - replaced_count = 0 - for i, item in enumerate(data): - category = item.get("category", "???") - score = item.get("score", 0) - paragraph = item.get("paragraph", "")[:60] - label = CATEGORY_LABELS.get(category, "未知类型") - - # 低置信度标记 - flag = "" - if isinstance(score, (int, float)) and score < threshold: - flag = " ⚠️ 低置信度" - if category not in VALID_CATEGORIES: - flag = " ❌ 非法类型" - - # 检查 replace 字段 - replace_text = item.get("replace", "") - has_replace = bool(replace_text and isinstance(replace_text, str)) - - print(f" [{i:3d}] {label:20s} 置信度:{score:.4f}{flag}") - print(f" {paragraph}...") - if has_replace: - print(f" 🔄 replace → \"{replace_text[:60]}\"") - replaced_count += 1 - print() - - if replaced_count > 0: - print(f" 🔄 共 {replaced_count} 个段落包含 replace 字段") - - -def print_stats(data: list): - """打印分类统计""" - categories = [ - item.get("category", "unknown") for item in data if isinstance(item, dict) - ] - counter = Counter(categories) - - print(f"\n{'=' * 50}") - print(f" 分类统计(共 {len(data)} 个段落)") - print(f"{'=' * 50}\n") - print(f" {'类型':<35s} {'数量':>5s}") - print(f" {'-' * 42}") - for cat, count in counter.most_common(): - label = CATEGORY_LABELS.get(cat, cat) - print(f" {label:<35s} {count:>5d}") - print(f" {'-' * 42}") - print(f" {'合计':<35s} {len(data):>5d}") - - -def main(): - parser = argparse.ArgumentParser(description="WordFormat JSON 标签校验工具") - parser.add_argument("--json", "-j", required=True, help="JSON 文件路径") - parser.add_argument("--stats", "-s", action="store_true", help="输出分类统计") - parser.add_argument( - "--threshold", - "-t", - type=float, - default=0.0, - help="低置信度阈值(默认 0.0,即不标记)", - ) - parser.add_argument( - "--show-all", action="store_true", help="显示所有段落的分类结果" - ) - args = parser.parse_args() - - # 加载 JSON - try: - with open(args.json, encoding="utf-8") as f: - data = json.load(f) - except (json.JSONDecodeError, FileNotFoundError) as e: - print(f"错误: {e}") - sys.exit(1) - - # 校验 - issues = validate_json(args.json, args.threshold) - - errors = [i for i in issues if "error" in i] - warnings = [i for i in issues if "warning" in i] - - if errors: - print(f"❌ 发现 {len(errors)} 个错误:") - for item in errors: - idx = item["index"] - print(f" [{idx}] {item['error']}") - if "paragraph" in item: - print(f" 内容: {item['paragraph']}") - if "hint" in item: - print(f" {item['hint']}") - print() - - if warnings: - print(f"⚠️ 发现 {len(warnings)} 个低置信度分类:") - for item in warnings: - idx = item["index"] - print( - f" [{idx}] {item['warning']} | {item.get('category', '')} | {item.get('paragraph', '')}" - ) - print() - - if not errors and not warnings: - print(f"✅ JSON 文件校验通过: {args.json}") - - # 统计 - if args.stats: - print_stats(data) - - # 显示所有结果 - if args.show_all: - print_results(data, args.threshold) - - if errors: - sys.exit(1) - - -if __name__ == "__main__": - main() From 2e046cf0edb33b59ecaa1d49d9d01f365998b2f9 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 22:15:32 +0800 Subject: [PATCH 03/12] =?UTF-8?q?=E7=BB=9F=E4=B8=80=20NODE=5FTYPE=20?= =?UTF-8?q?=E5=91=BD=E5=90=8D=E8=A7=84=E8=8C=83=EF=BC=9A<=E7=B1=BB?= =?UTF-8?q?=E5=88=AB>.<=E7=B1=BB=E5=9E=8B>?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - abstract.chinese.{chinese_title→title, chinese_content→body} - abstract.english.{english_title→title, english_content→body} - abstract.{keywords.chinese→chinese.keywords, keywords.english→english.keywords} - {body_text→body.text} - {figures→figures.caption, figures.image} - {tables→tables.caption, tables.object} - {references.content→references.entry} - {acknowledgements.content→acknowledgements.body} - DEFAULTS 内部 key 同步改名(chinese_title→title, etc.) - 同步更新 YAML 配置和测试夹具 --- example/undergrad_thesis.yaml | 209 ++++++++++++------------ src/wordformat/rules/abstract.py | 76 ++++----- src/wordformat/rules/acknowledgement.py | 2 +- src/wordformat/rules/body.py | 2 +- src/wordformat/rules/caption.py | 4 +- src/wordformat/rules/keywords.py | 4 +- src/wordformat/rules/references.py | 2 +- tests/conftest.py | 54 +++--- tests/rules/test_caption.py | 34 ++-- tests/test_coverage_boost.py | 2 +- tests/test_tree.py | 2 +- 11 files changed, 195 insertions(+), 196 deletions(-) diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml index 900f24c..b161869 100644 --- a/example/undergrad_thesis.yaml +++ b/example/undergrad_thesis.yaml @@ -5,6 +5,24 @@ # 使用 YAML 锚点 &global_format 定义全局基础格式, # 各章节通过 <<: *global_format 继承后覆写特定字段。 # +# 配置路径规则:<类别>.<类型> +# abstract.chinese.title — 中文摘要标题 +# abstract.chinese.body — 中文摘要正文 +# abstract.chinese.keywords — 中文关键词 +# abstract.english.title — 英文摘要标题 +# abstract.english.body — 英文摘要正文 +# abstract.english.keywords — 英文关键词 +# headings.level_1/2/3 — 一/二/三级标题 +# body.text — 正文段落 +# figures.caption — 图题注 +# figures.image — 图片段落 +# tables.caption — 表题注 +# tables.object — 表格对象 +# references.title — 参考文献标题 +# references.entry — 参考文献条目 +# acknowledgements.title — 致谢标题 +# acknowledgements.body — 致谢正文 +# # 支持的单位格式: # 字号:小四 / 四号 / 三号 / 小二 / 五号 / 数值(pt) # 间距:0行 / 0.5行 / 1行 / 12磅 / 0.5cm @@ -41,35 +59,30 @@ style_checks_warning: # 全局基础格式(其他节通过 <<: *global_format 继承后再覆写) # --------------------------------------------------------------------------- global_format: &global_format - # -- 段落格式 -- - alignment: '两端对齐' # 左对齐/居中对齐/右对齐/两端对齐/分散对齐 - space_before: "0行" # 段前间距 - space_after: "0行" # 段后间距 - line_spacingrule: "1.5倍行距" # 单倍行距/1.5倍行距/2倍行距/最小值/固定值/多倍行距 - line_spacing: '1.5倍' # 行距参数 - left_indent: "0字符" # 左侧缩进 - right_indent: "0字符" # 右侧缩进 - first_line_indent: '2字符' # 首行缩进(正值)/ 悬挂缩进(负值,如 -2.2字符) - builtin_style_name: '正文' # Word 内置样式名(Heading 1, Heading 2, 正文, 题注 等) - # -- 字符格式 -- - chinese_font_name: '宋体' # 中文字体 - english_font_name: 'Times New Roman' # 英文字体 - font_size: '小四' # 字号(四号/三号/小二/五号 等) - font_color: '黑色' # 字体颜色 - bold: false # 加粗 - italic: false # 斜体 - underline: false # 下划线 + alignment: '两端对齐' + space_before: "0行" + space_after: "0行" + line_spacingrule: "1.5倍行距" + line_spacing: '1.5倍' + left_indent: "0字符" + right_indent: "0字符" + first_line_indent: '2字符' + builtin_style_name: '正文' + chinese_font_name: '宋体' + english_font_name: 'Times New Roman' + font_size: '小四' + font_color: '黑色' + bold: false + italic: false + underline: false # =========================================================================== # 摘要(中英文) # =========================================================================== abstract: - # ----------------------------------------------------------------------- - # 中文摘要 - # ----------------------------------------------------------------------- chinese: # -- 中文摘要标题("摘要"二字)-- - chinese_title: + title: <<: *global_format alignment: '居中对齐' first_line_indent: '0字符' @@ -78,54 +91,46 @@ abstract: bold: false # -- 中文摘要正文 -- - chinese_content: - <<: *global_format - alignment: '两端对齐' - - # ----------------------------------------------------------------------- - # 英文摘要 - # ----------------------------------------------------------------------- - english: - # -- 英文摘要标题("Abstract")-- - english_title: - <<: *global_format - alignment: '居中对齐' - first_line_indent: '0字符' - font_size: '四号' - bold: false - - # -- 英文摘要正文 -- - english_content: + body: <<: *global_format alignment: '两端对齐' - # ----------------------------------------------------------------------- - # 关键词(中英文) - # ----------------------------------------------------------------------- - keywords: - chinese: - # 从 global_format 继承,再覆写关键词特有字段 + # -- 中文关键词 -- + keywords: <<: *global_format alignment: '两端对齐' first_line_indent: '2字符' font_size: '小四' - # -- 关键词标签("关键词:")的字符格式 -- label: <<: *global_format chinese_font_name: '黑体' font_size: '四号' bold: false - # -- 关键词业务规则(均通过 enabled 开关控制) -- rules: keyword_count: enabled: true - count_min: 3 # 最少关键词数 - count_max: 5 # 最多关键词数 + count_min: 3 + count_max: 5 trailing_punctuation: enabled: true - forbidden_chars: ";,。、" # 中文关键词末尾禁止出现的标点 + forbidden_chars: ";,。、" + + english: + # -- 英文摘要标题("Abstract")-- + title: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + font_size: '四号' + bold: false - english: + # -- 英文摘要正文 -- + body: + <<: *global_format + alignment: '两端对齐' + + # -- 英文关键词 -- + keywords: <<: *global_format alignment: '两端对齐' first_line_indent: '2字符' @@ -144,7 +149,6 @@ abstract: # 各级标题(章、节、小节) # =========================================================================== headings: - # -- 一级标题(第1章)-- level_1: <<: *global_format alignment: '居中对齐' @@ -156,7 +160,6 @@ headings: space_after: "0.5行" builtin_style_name: 'Heading 1' - # -- 二级标题(1.1)-- level_2: <<: *global_format alignment: '左对齐' @@ -168,7 +171,6 @@ headings: space_after: "0行" builtin_style_name: 'Heading 2' - # -- 三级标题(1.1.1)-- level_3: <<: *global_format alignment: '左对齐' @@ -183,61 +185,61 @@ headings: # =========================================================================== # 正文段落 # =========================================================================== -body_text: - <<: *global_format - rules: - punctuation: - enabled: true +body: + text: + <<: *global_format + rules: + punctuation: + enabled: true # =========================================================================== -# 插图题注(Figure) +# 插图(题注 + 图片段落) # =========================================================================== figures: - <<: *global_format - font_size: '五号' - builtin_style_name: '题注' - alignment: '居中对齐' - first_line_indent: '0字符' - # -- 插图特有字段 -- - caption_prefix: '图' # 图注编号前缀 - # -- 图片段落格式(包含内联图片的段落)-- + caption: + <<: *global_format + font_size: '五号' + builtin_style_name: '题注' + alignment: '居中对齐' + first_line_indent: '0字符' + caption_prefix: '图' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false + image: <<: *global_format alignment: '居中对齐' first_line_indent: '0字符' - rules: - caption_numbering: - enabled: true - separator: '.' - label_number_space: false # =========================================================================== -# 表格(题注 + 表格对象 + 表格内容) +# 表格(题注 + 表格对象) # =========================================================================== tables: - <<: *global_format - font_size: '五号' - builtin_style_name: '题注' - alignment: '居中对齐' - first_line_indent: '0字符' - # -- 表格题注特有字段 -- - caption_prefix: '表' # 表注编号前缀 - # -- 表格对象格式(表格整体对齐、环绕)-- + caption: + <<: *global_format + font_size: '五号' + builtin_style_name: '题注' + alignment: '居中对齐' + first_line_indent: '0字符' + caption_prefix: '表' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false + object: <<: *global_format alignment: '居中对齐' first_line_indent: '0字符' - rules: - caption_numbering: - enabled: true - separator: '.' - label_number_space: false # =========================================================================== # 参考文献 # =========================================================================== references: - # -- 参考文献标题("参考文献")-- title: <<: *global_format alignment: '居中对齐' @@ -246,12 +248,11 @@ references: font_size: '三号' bold: false - # -- 参考文献条目内容 -- - content: + entry: <<: *global_format alignment: '两端对齐' - first_line_indent: '-2.2字符' # 悬挂缩进(负值=悬挂) - left_indent: '0.26字符' # 文本之前 + first_line_indent: '-2.2字符' + left_indent: '0.26字符' chinese_font_name: '宋体' english_font_name: 'Times New Roman' font_size: '五号' @@ -260,7 +261,6 @@ references: # 致谢 # =========================================================================== acknowledgements: - # -- 致谢标题("致谢")-- title: <<: *global_format alignment: '居中对齐' @@ -269,8 +269,7 @@ acknowledgements: font_size: '小二' bold: false - # -- 致谢正文 -- - content: + body: <<: *global_format alignment: '两端对齐' font_size: '五号' @@ -279,25 +278,22 @@ acknowledgements: # 标题自动编号 # =========================================================================== numbering: - enabled: true # 总开关:是否启用自动编号 + enabled: true - # -- 一级标题编号(如"第1章")-- level_1: enabled: true - template: '%1' # %1=本级序号 - suffix: 'space' # 编号后分隔符:tab / space / nothing - numbering_indent: # [可选] 编号缩进,如 '0字符' - text_indent: # [可选] 文本缩进(悬挂缩进),如 '0.75cm' + template: '%1' + suffix: 'space' + numbering_indent: + text_indent: - # -- 二级标题编号(如"1.1")-- level_2: enabled: true - template: '%1.%2' # %1=上级序号, %2=本级序号 + template: '%1.%2' suffix: 'space' numbering_indent: text_indent: - # -- 三级标题编号(如"1.1.1")-- level_3: enabled: true template: '%1.%2.%3' @@ -305,10 +301,9 @@ numbering: numbering_indent: text_indent: - # -- 参考文献条目编号(如"[1]")-- references: enabled: true template: '[%1]' suffix: 'space' numbering_indent: - text_indent: # 悬挂缩进,如 '2.2字符'(优先级高于 references.content.first_line_indent) + text_indent: diff --git a/src/wordformat/rules/abstract.py b/src/wordformat/rules/abstract.py index fc2a64d..7361f63 100644 --- a/src/wordformat/rules/abstract.py +++ b/src/wordformat/rules/abstract.py @@ -14,7 +14,7 @@ class AbstractTitleCN(FormatNode): """摘要标题中文节点""" - NODE_TYPE = "abstract.chinese.chinese_title" + NODE_TYPE = "abstract.chinese.title" NODE_LABEL = "中文摘要标题" DEFAULTS = { **BASE_FORMAT, @@ -33,7 +33,7 @@ class AbstractTitleContentCN(FormatNode): NODE_TYPE = "abstract.chinese" NODE_LABEL = "中文摘要" DEFAULTS = { - "chinese_title": { + "title": { **BASE_FORMAT, "alignment": "居中对齐", "first_line_indent": "0字符", @@ -41,7 +41,7 @@ class AbstractTitleContentCN(FormatNode): "font_size": "小二", "bold": True, }, - "chinese_content": {**BASE_FORMAT, "alignment": "两端对齐"}, + "body": {**BASE_FORMAT, "alignment": "两端对齐"}, } def check_title(self, run) -> bool: @@ -56,7 +56,7 @@ def _base(self, doc, p: bool, r: bool): # 混合节点的cfg有两个值,遂需要重新组装 # 段落样式选择content样式 # 字体样式"摘要"选择title,"正文"选择content - ps = ParagraphStyle.from_config(cfg.chinese_content) + ps = ParagraphStyle.from_config(cfg.body) if p: issues = ps.diff_from_paragraph(self.paragraph) else: @@ -67,13 +67,13 @@ def _base(self, doc, p: bool, r: bool): if self.check_title(run): # 对run对象设置样式 C = CharacterStyle( - font_name_cn=cfg.chinese_title.chinese_font_name, - font_name_en=cfg.chinese_title.english_font_name, - font_size=cfg.chinese_title.font_size, - font_color=cfg.chinese_title.font_color, - bold=cfg.chinese_title.bold, - italic=cfg.chinese_title.italic, - underline=cfg.chinese_title.underline, + font_name_cn=cfg.title.chinese_font_name, + font_name_en=cfg.title.english_font_name, + font_size=cfg.title.font_size, + font_color=cfg.title.font_color, + bold=cfg.title.bold, + italic=cfg.title.italic, + underline=cfg.title.underline, ) if r: diff_result = C.diff_from_run(run) @@ -82,13 +82,13 @@ def _base(self, doc, p: bool, r: bool): else: # 对剩余部分的run设置样式 C = CharacterStyle( - font_name_cn=cfg.chinese_content.chinese_font_name, - font_name_en=cfg.chinese_content.english_font_name, - font_size=cfg.chinese_content.font_size, - font_color=cfg.chinese_content.font_color, - bold=cfg.chinese_content.bold, - italic=cfg.chinese_content.italic, - underline=cfg.chinese_content.underline, + font_name_cn=cfg.body.chinese_font_name, + font_name_en=cfg.body.english_font_name, + font_size=cfg.body.font_size, + font_color=cfg.body.font_color, + bold=cfg.body.bold, + italic=cfg.body.italic, + underline=cfg.body.underline, ) if r: diff_result = C.diff_from_run(run) @@ -110,7 +110,7 @@ def _base(self, doc, p: bool, r: bool): class AbstractContentCN(FormatNode): """摘要内容中文节点""" - NODE_TYPE = "abstract.chinese.chinese_content" + NODE_TYPE = "abstract.chinese.body" NODE_LABEL = "中文摘要正文" DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"} @@ -151,7 +151,7 @@ def _base(self, doc, p: bool, r: bool): class AbstractTitleEN(FormatNode): """摘要标题英文节点""" - NODE_TYPE = "abstract.english.english_title" + NODE_TYPE = "abstract.english.title" NODE_LABEL = "英文摘要标题" DEFAULTS = { **BASE_FORMAT, @@ -169,14 +169,14 @@ class AbstractTitleContentEN(FormatNode): NODE_TYPE = "abstract.english" NODE_LABEL = "英文摘要" DEFAULTS = { - "english_title": { + "title": { **BASE_FORMAT, "alignment": "居中对齐", "first_line_indent": "0字符", "font_size": "四号", "bold": True, }, - "english_content": {**BASE_FORMAT, "alignment": "两端对齐"}, + "body": {**BASE_FORMAT, "alignment": "两端对齐"}, } def _check_title_in_full_text(self, runs) -> int: @@ -197,7 +197,7 @@ def _base(self, doc, p: bool, r: bool): # 段落样式选择content样式 # 字体样式"摘要"选择title,"正文"选择content cfg = self.pydantic_config - ps = ParagraphStyle.from_config(cfg.english_content) + ps = ParagraphStyle.from_config(cfg.body) if p: issues = ps.diff_from_paragraph(self.paragraph) else: @@ -234,23 +234,23 @@ def _base(self, doc, p: bool, r: bool): run.text = text_clean[title_end - run_start :] c = CharacterStyle( - font_name_cn=cfg.english_title.chinese_font_name, - font_name_en=cfg.english_title.english_font_name, - font_size=cfg.english_title.font_size, - font_color=cfg.english_title.font_color, - bold=cfg.english_title.bold, - italic=cfg.english_title.italic, - underline=cfg.english_title.underline, + font_name_cn=cfg.title.chinese_font_name, + font_name_en=cfg.title.english_font_name, + font_size=cfg.title.font_size, + font_color=cfg.title.font_color, + bold=cfg.title.bold, + italic=cfg.title.italic, + underline=cfg.title.underline, ) else: c = CharacterStyle( - font_name_cn=cfg.english_content.chinese_font_name, - font_name_en=cfg.english_content.english_font_name, - font_size=cfg.english_content.font_size, - font_color=cfg.english_content.font_color, - bold=cfg.english_content.bold, - italic=cfg.english_content.italic, - underline=cfg.english_content.underline, + font_name_cn=cfg.body.chinese_font_name, + font_name_en=cfg.body.english_font_name, + font_size=cfg.body.font_size, + font_color=cfg.body.font_color, + bold=cfg.body.bold, + italic=cfg.body.italic, + underline=cfg.body.underline, ) if r: diff_result = c.diff_from_run(run) @@ -273,7 +273,7 @@ def _base(self, doc, p: bool, r: bool): class AbstractContentEN(FormatNode): """摘要内容英文节点""" - NODE_TYPE = "abstract.english.english_content" + NODE_TYPE = "abstract.english.body" NODE_LABEL = "英文摘要正文" DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"} diff --git a/src/wordformat/rules/acknowledgement.py b/src/wordformat/rules/acknowledgement.py index ac4fccb..d557c7c 100644 --- a/src/wordformat/rules/acknowledgement.py +++ b/src/wordformat/rules/acknowledgement.py @@ -28,7 +28,7 @@ class Acknowledgements(FormatNode): class AcknowledgementsCN(FormatNode): """致谢内容""" - NODE_TYPE = "acknowledgements.content" + NODE_TYPE = "acknowledgements.body" NODE_LABEL = "致谢内容" DEFAULTS = { **BASE_FORMAT, diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py index 1e8a8c2..8c0b377 100644 --- a/src/wordformat/rules/body.py +++ b/src/wordformat/rules/body.py @@ -90,7 +90,7 @@ def _split_run_at(paragraph, start: int, end: int): class BodyText(FormatNode): """正文节点""" - NODE_TYPE = "body_text" + NODE_TYPE = "body.text" NODE_LABEL = "正文段落" DEFAULTS = { "alignment": "两端对齐", diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py index d021945..7343432 100644 --- a/src/wordformat/rules/caption.py +++ b/src/wordformat/rules/caption.py @@ -146,7 +146,7 @@ def _apply_caption_numbering( class CaptionFigure(FormatNode): """题注-图片""" - NODE_TYPE = "figures" + NODE_TYPE = "figures.caption" NODE_LABEL = "图注" DEFAULTS = { **BASE_FORMAT, @@ -174,7 +174,7 @@ def _handle_caption_numbering(self, doc, rule_cfg, p: bool): class CaptionTable(FormatNode): """题注-表格""" - NODE_TYPE = "tables" + NODE_TYPE = "tables.caption" NODE_LABEL = "表注" DEFAULTS = { **BASE_FORMAT, diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py index 6ed077b..d55d9e7 100644 --- a/src/wordformat/rules/keywords.py +++ b/src/wordformat/rules/keywords.py @@ -83,7 +83,7 @@ def _get_label_split_pattern(self) -> re.Pattern | None: class KeywordsEN(BaseKeywordsNode): """关键词节点-英文""" - NODE_TYPE = "abstract.keywords.english" + NODE_TYPE = "abstract.english.keywords" NODE_LABEL = "英文关键词" DEFAULTS = { **BASE_FORMAT, @@ -209,7 +209,7 @@ def _base(self, doc, p: bool, r: bool): class KeywordsCN(BaseKeywordsNode): """关键词节点-中文""" - NODE_TYPE = "abstract.keywords.chinese" + NODE_TYPE = "abstract.chinese.keywords" NODE_LABEL = "中文关键词" DEFAULTS = { **BASE_FORMAT, diff --git a/src/wordformat/rules/references.py b/src/wordformat/rules/references.py index b8a8ae2..b66820b 100644 --- a/src/wordformat/rules/references.py +++ b/src/wordformat/rules/references.py @@ -28,7 +28,7 @@ class References(FormatNode): class ReferenceEntry(FormatNode): """参考文献条目节点""" - NODE_TYPE = "references.content" + NODE_TYPE = "references.entry" NODE_LABEL = "参考文献条目" DEFAULTS = { **BASE_FORMAT, diff --git a/tests/conftest.py b/tests/conftest.py index be605ec..c5495c8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -99,24 +99,15 @@ def temp_json(tmp_path): underline: false abstract: chinese: - chinese_title: + title: alignment: '居中对齐' first_line_indent: '0字符' chinese_font_name: '黑体' font_size: '小二' bold: true - chinese_content: + body: alignment: '两端对齐' - english: - english_title: - alignment: '居中对齐' - first_line_indent: '0字符' - font_size: '四号' - bold: true - english_content: - alignment: '两端对齐' - keywords: - chinese: + keywords: label: chinese_font_name: '黑体' font_size: '三号' @@ -133,7 +124,15 @@ def temp_json(tmp_path): count_max: 5 trailing_punctuation: enabled: true - english: + english: + title: + alignment: '居中对齐' + first_line_indent: '0字符' + font_size: '四号' + bold: true + body: + alignment: '两端对齐' + keywords: label: font_size: '三号' bold: true @@ -168,17 +167,24 @@ def temp_json(tmp_path): chinese_font_name: '黑体' font_size: '小四' builtin_style_name: 'Heading 3' -body_text: - alignment: '两端对齐' - chinese_font_name: '宋体' - english_font_name: 'Times New Roman' - font_size: '小四' +body: + text: + alignment: '两端对齐' + chinese_font_name: '宋体' + english_font_name: 'Times New Roman' + font_size: '小四' figures: - caption_prefix: '图' + caption: + caption_prefix: '图' + image: + alignment: '居中对齐' + first_line_indent: '0字符' tables: - caption_prefix: '表' - content: - font_size: '五号' + caption: + caption_prefix: '表' + object: + alignment: '居中对齐' + first_line_indent: '0字符' references: title: alignment: '居中对齐' @@ -186,7 +192,7 @@ def temp_json(tmp_path): chinese_font_name: '黑体' font_size: '三号' bold: true - content: + entry: alignment: '左对齐' first_line_indent: '0字符' font_size: '五号' @@ -197,7 +203,7 @@ def temp_json(tmp_path): chinese_font_name: '黑体' font_size: '小二' bold: true - content: + body: alignment: '两端对齐' first_line_indent: '2字符' numbering: diff --git a/tests/rules/test_caption.py b/tests/rules/test_caption.py index d22c176..decc7e8 100644 --- a/tests/rules/test_caption.py +++ b/tests/rules/test_caption.py @@ -282,26 +282,24 @@ def caption_yaml(self, tmp_path): font_color: '黑色' builtin_style_name: '正文' figures: - caption_position: 'below' - caption_prefix: '图' - rules: - caption_numbering: - enabled: true - separator: '.' + caption: + caption_prefix: '图' + rules: + caption_numbering: + enabled: true + separator: '.' tables: - caption_position: 'above' - caption_prefix: '表' - content: - font_size: '五号' - rules: - caption_numbering: - enabled: true - separator: '.' + caption: + caption_prefix: '表' + rules: + caption_numbering: + enabled: true + separator: '.' + object: + alignment: '居中对齐' + first_line_indent: '0字符' numbering: enabled: false - captions: - enabled: true - separator: '.' """ path = tmp_path / "caption_test.yaml" path.write_text(yaml_content, encoding="utf-8") @@ -524,7 +522,7 @@ def test_disabled_skips_numbering_check(self, caption_yaml): fig = self._make_caption_figure(p) heading = self._make_heading_node(children=[fig]) config = self._init_config(caption_yaml) - config["figures"]["rules"]["caption_numbering"]["enabled"] = False + config["figures"]["caption"]["rules"]["caption_numbering"]["enabled"] = False with patch.object(fig, "add_comment") as mock_comment: apply_format_check_to_all_nodes(heading, doc, config, check=True) diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py index eb2413d..987f9b9 100644 --- a/tests/test_coverage_boost.py +++ b/tests/test_coverage_boost.py @@ -202,7 +202,7 @@ def test_fix_style_run_properties_sets_font_name(self, root_config): from wordformat.style.defs import ensure_style_exists doc = Document() - cfg = root_config.body_text + cfg = root_config.body.text ensure_style_exists(doc, "Normal") style = doc.styles["Normal"] diff --git a/tests/test_tree.py b/tests/test_tree.py index 5a18ee5..f0bcefb 100644 --- a/tests/test_tree.py +++ b/tests/test_tree.py @@ -479,4 +479,4 @@ def test_export_returns_dict_with_key_sections(self): assert "style_checks_warning" in data assert "abstract" in data assert "headings" in data - assert "body_text" in data + assert "body" in data From 7461c00314bacd1d10d6dc80e684cb9cc36bccae Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 22:26:34 +0800 Subject: [PATCH 04/12] =?UTF-8?q?=E6=A1=86=E6=9E=B6=E6=8A=BD=E8=B1=A1?= =?UTF-8?q?=EF=BC=9ADotDict=20=E8=87=AA=E5=8A=A8=20fallback=20paragraph/fo?= =?UTF-8?q?nt=20=E5=AD=90=E5=AD=97=E5=85=B8=20+=20DEFAULTS/YAML=20?= =?UTF-8?q?=E5=88=86=E7=BB=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - DotDict.__getattr__: key 缺失时自动到 paragraph/font 子字典查找 - BASE_FORMAT 改为 {paragraph: {...}, font: {...}} 分组结构 - 所有 FormatNode DEFAULTS 改用 deep_merge(BASE_FORMAT, {...}) 覆盖 - YAML global_format 锚点改用 paragraph/font 分组 - 各节点平铺覆盖字段保持不变,框架透明处理查找 --- example/undergrad_thesis.yaml | 34 +++++------ src/wordformat/config/dotdict.py | 45 +++++++++------ src/wordformat/rules/abstract.py | 35 ++++++------ src/wordformat/rules/acknowledgement.py | 32 ++++++----- src/wordformat/rules/body.py | 31 ++++------ src/wordformat/rules/caption.py | 46 ++++++++------- src/wordformat/rules/heading.py | 55 ++++++++++-------- src/wordformat/rules/keywords.py | 75 ++++++++++++++----------- src/wordformat/rules/references.py | 33 ++++++----- 9 files changed, 209 insertions(+), 177 deletions(-) diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml index b161869..f4135a6 100644 --- a/example/undergrad_thesis.yaml +++ b/example/undergrad_thesis.yaml @@ -59,22 +59,24 @@ style_checks_warning: # 全局基础格式(其他节通过 <<: *global_format 继承后再覆写) # --------------------------------------------------------------------------- global_format: &global_format - alignment: '两端对齐' - space_before: "0行" - space_after: "0行" - line_spacingrule: "1.5倍行距" - line_spacing: '1.5倍' - left_indent: "0字符" - right_indent: "0字符" - first_line_indent: '2字符' - builtin_style_name: '正文' - chinese_font_name: '宋体' - english_font_name: 'Times New Roman' - font_size: '小四' - font_color: '黑色' - bold: false - italic: false - underline: false + paragraph: + alignment: '两端对齐' + space_before: "0行" + space_after: "0行" + line_spacingrule: "1.5倍行距" + line_spacing: '1.5倍' + left_indent: "0字符" + right_indent: "0字符" + first_line_indent: '2字符' + builtin_style_name: '正文' + font: + chinese_font_name: '宋体' + english_font_name: 'Times New Roman' + font_size: '小四' + font_color: '黑色' + bold: false + italic: false + underline: false # =========================================================================== # 摘要(中英文) diff --git a/src/wordformat/config/dotdict.py b/src/wordformat/config/dotdict.py index 5314f4b..06ece7c 100644 --- a/src/wordformat/config/dotdict.py +++ b/src/wordformat/config/dotdict.py @@ -12,7 +12,14 @@ def __getattr__(self, key: str): try: val = self[key] except KeyError: - return None + # 框架抽象:key 不在当前层时,自动到 paragraph / font 子字典中查找 + for sub in ("paragraph", "font"): + sub_dict = self.get(sub) + if isinstance(sub_dict, dict) and key in sub_dict: + val = sub_dict[key] + break + else: + return None if isinstance(val, dict): return DotDict(val) return val @@ -29,22 +36,26 @@ def __delattr__(self, key: str) -> None: # 全局格式默认值,所有节点以此为底 BASE_FORMAT: dict[str, object] = { - "alignment": "左对齐", - "space_before": "0.5行", - "space_after": "0.5行", - "line_spacingrule": "单倍行距", - "line_spacing": "1.5倍", - "left_indent": "0字符", - "right_indent": "0字符", - "first_line_indent": "2字符", - "builtin_style_name": "正文", - "chinese_font_name": "宋体", - "english_font_name": "Times New Roman", - "font_size": "小四", - "font_color": "黑色", - "bold": False, - "italic": False, - "underline": False, + "paragraph": { + "alignment": "左对齐", + "space_before": "0.5行", + "space_after": "0.5行", + "line_spacingrule": "单倍行距", + "line_spacing": "1.5倍", + "left_indent": "0字符", + "right_indent": "0字符", + "first_line_indent": "2字符", + "builtin_style_name": "正文", + }, + "font": { + "chinese_font_name": "宋体", + "english_font_name": "Times New Roman", + "font_size": "小四", + "font_color": "黑色", + "bold": False, + "italic": False, + "underline": False, + }, } diff --git a/src/wordformat/rules/abstract.py b/src/wordformat/rules/abstract.py index 7361f63..626a151 100644 --- a/src/wordformat/rules/abstract.py +++ b/src/wordformat/rules/abstract.py @@ -4,7 +4,7 @@ # @File : abstract.py import re -from wordformat.config.dotdict import BASE_FORMAT +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register from wordformat.style.diff import CharacterStyle, ParagraphStyle @@ -16,14 +16,13 @@ class AbstractTitleCN(FormatNode): NODE_TYPE = "abstract.chinese.title" NODE_LABEL = "中文摘要标题" - DEFAULTS = { - **BASE_FORMAT, - "alignment": "居中对齐", - "first_line_indent": "0字符", - "chinese_font_name": "黑体", - "font_size": "小二", - "bold": True, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"alignment": "居中对齐", "first_line_indent": "0字符"}, + "font": {"chinese_font_name": "黑体", "font_size": "小二", "bold": True}, + }, + ) @register("abstract_chinese_title_content", level=1) @@ -112,7 +111,7 @@ class AbstractContentCN(FormatNode): NODE_TYPE = "abstract.chinese.body" NODE_LABEL = "中文摘要正文" - DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"} + DEFAULTS = deep_merge(BASE_FORMAT, {"paragraph": {"alignment": "两端对齐"}}) def _base(self, doc, p: bool, r: bool): cfg = self.pydantic_config @@ -153,13 +152,13 @@ class AbstractTitleEN(FormatNode): NODE_TYPE = "abstract.english.title" NODE_LABEL = "英文摘要标题" - DEFAULTS = { - **BASE_FORMAT, - "alignment": "居中对齐", - "first_line_indent": "0字符", - "font_size": "四号", - "bold": True, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"alignment": "居中对齐", "first_line_indent": "0字符"}, + "font": {"font_size": "四号", "bold": True}, + }, + ) @register("abstract_english_title_content", level=1) @@ -275,7 +274,7 @@ class AbstractContentEN(FormatNode): NODE_TYPE = "abstract.english.body" NODE_LABEL = "英文摘要正文" - DEFAULTS = {**BASE_FORMAT, "alignment": "两端对齐"} + DEFAULTS = deep_merge(BASE_FORMAT, {"paragraph": {"alignment": "两端对齐"}}) def _base(self, doc, p: bool, r: bool): cfg = self.pydantic_config diff --git a/src/wordformat/rules/acknowledgement.py b/src/wordformat/rules/acknowledgement.py index d557c7c..5001172 100644 --- a/src/wordformat/rules/acknowledgement.py +++ b/src/wordformat/rules/acknowledgement.py @@ -3,7 +3,7 @@ # @Author : afish # @File : acknowledgement.py -from wordformat.config.dotdict import BASE_FORMAT +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register @@ -14,14 +14,16 @@ class Acknowledgements(FormatNode): NODE_TYPE = "acknowledgements.title" NODE_LABEL = "致谢标题" - DEFAULTS = { - **BASE_FORMAT, - "alignment": "居中对齐", - "first_line_indent": "0字符", - "chinese_font_name": "黑体", - "font_size": "小二", - "bold": True, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": { + "alignment": "居中对齐", + "first_line_indent": "0字符", + }, + "font": {"chinese_font_name": "黑体", "font_size": "小二", "bold": True}, + }, + ) @register("acknowledgements_content") @@ -30,8 +32,10 @@ class AcknowledgementsCN(FormatNode): NODE_TYPE = "acknowledgements.body" NODE_LABEL = "致谢内容" - DEFAULTS = { - **BASE_FORMAT, - "alignment": "两端对齐", - "chinese_font_name": "宋体", - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"alignment": "两端对齐"}, + "font": {"chinese_font_name": "宋体"}, + }, + ) diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py index 8c0b377..88fca6c 100644 --- a/src/wordformat/rules/body.py +++ b/src/wordformat/rules/body.py @@ -9,6 +9,7 @@ from docx.oxml import OxmlElement from docx.oxml.ns import qn +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register @@ -92,25 +93,17 @@ class BodyText(FormatNode): NODE_TYPE = "body.text" NODE_LABEL = "正文段落" - DEFAULTS = { - "alignment": "两端对齐", - "space_before": "0.5行", - "space_after": "0.5行", - "line_spacingrule": "单倍行距", - "line_spacing": "1.5倍", - "left_indent": "0字符", - "right_indent": "0字符", - "first_line_indent": "2字符", - "builtin_style_name": "正文", - "chinese_font_name": "宋体", - "english_font_name": "Times New Roman", - "font_size": "小四", - "font_color": "黑色", - "bold": False, - "italic": False, - "underline": False, - "rules": {"punctuation": {"enabled": True}}, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"alignment": "两端对齐"}, + "font": { + "chinese_font_name": "宋体", + "english_font_name": "Times New Roman", + }, + "rules": {"punctuation": {"enabled": True}}, + }, + ) RULES = {"punctuation": "_check_punctuation"} def _check_punctuation(self, doc, rule_cfg, p: bool = False): diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py index 7343432..7f5ac29 100644 --- a/src/wordformat/rules/caption.py +++ b/src/wordformat/rules/caption.py @@ -4,7 +4,7 @@ # @File : caption.py -from wordformat.config.dotdict import BASE_FORMAT +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register from wordformat.utils import parse_caption_text @@ -148,17 +148,19 @@ class CaptionFigure(FormatNode): NODE_TYPE = "figures.caption" NODE_LABEL = "图注" - DEFAULTS = { - **BASE_FORMAT, - "caption_prefix": "图", - "rules": { - "caption_numbering": { - "enabled": True, - "separator": ".", - "label_number_space": False, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "caption_prefix": "图", + "rules": { + "caption_numbering": { + "enabled": True, + "separator": ".", + "label_number_space": False, + } + }, }, - } + ) RULES = {"caption_numbering": "_handle_caption_numbering"} def _handle_caption_numbering(self, doc, rule_cfg, p: bool): @@ -176,17 +178,19 @@ class CaptionTable(FormatNode): NODE_TYPE = "tables.caption" NODE_LABEL = "表注" - DEFAULTS = { - **BASE_FORMAT, - "caption_prefix": "表", - "rules": { - "caption_numbering": { - "enabled": True, - "separator": ".", - "label_number_space": False, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "caption_prefix": "表", + "rules": { + "caption_numbering": { + "enabled": True, + "separator": ".", + "label_number_space": False, + } + }, }, - } + ) RULES = {"caption_numbering": "_handle_caption_numbering"} def _handle_caption_numbering(self, doc, rule_cfg, p: bool): diff --git a/src/wordformat/rules/heading.py b/src/wordformat/rules/heading.py index 7ccf9a9..ef80283 100644 --- a/src/wordformat/rules/heading.py +++ b/src/wordformat/rules/heading.py @@ -3,7 +3,7 @@ # @Author : afish # @File : heading.py -from wordformat.config.dotdict import BASE_FORMAT +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register @@ -14,14 +14,17 @@ class HeadingLevel1Node(FormatNode): NODE_TYPE = "headings.level_1" NODE_LABEL = "一级标题" - DEFAULTS = { - **BASE_FORMAT, - "alignment": "居中对齐", - "first_line_indent": "0字符", - "chinese_font_name": "黑体", - "font_size": "小二", - "builtin_style_name": "Heading 1", - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": { + "alignment": "居中对齐", + "first_line_indent": "0字符", + "builtin_style_name": "Heading 1", + }, + "font": {"chinese_font_name": "黑体", "font_size": "小二"}, + }, + ) @register("heading_level_2", level=2) @@ -30,13 +33,16 @@ class HeadingLevel2Node(FormatNode): NODE_TYPE = "headings.level_2" NODE_LABEL = "二级标题" - DEFAULTS = { - **BASE_FORMAT, - "first_line_indent": "0字符", - "chinese_font_name": "黑体", - "font_size": "三号", - "builtin_style_name": "Heading 2", - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": { + "first_line_indent": "0字符", + "builtin_style_name": "Heading 2", + }, + "font": {"chinese_font_name": "黑体", "font_size": "三号"}, + }, + ) @register("heading_level_3", level=3) @@ -45,10 +51,13 @@ class HeadingLevel3Node(FormatNode): NODE_TYPE = "headings.level_3" NODE_LABEL = "三级标题" - DEFAULTS = { - **BASE_FORMAT, - "first_line_indent": "0字符", - "chinese_font_name": "黑体", - "font_size": "小四", - "builtin_style_name": "Heading 3", - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": { + "first_line_indent": "0字符", + "builtin_style_name": "Heading 3", + }, + "font": {"chinese_font_name": "黑体", "font_size": "小四"}, + }, + ) diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py index d55d9e7..a46dfff 100644 --- a/src/wordformat/rules/keywords.py +++ b/src/wordformat/rules/keywords.py @@ -3,7 +3,7 @@ from docx.oxml.ns import qn -from wordformat.config.dotdict import BASE_FORMAT +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register from wordformat.style.diff import CharacterStyle, ParagraphStyle @@ -85,22 +85,25 @@ class KeywordsEN(BaseKeywordsNode): NODE_TYPE = "abstract.english.keywords" NODE_LABEL = "英文关键词" - DEFAULTS = { - **BASE_FORMAT, - "first_line_indent": "0字符", - "chinese_font_name": "宋体", - "font_size": "小四", - "label": { - "chinese_font_name": "黑体", - "english_font_name": "Times New Roman", - "font_size": "三号", - "font_color": "黑色", - "bold": True, - "italic": False, - "underline": False, + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"first_line_indent": "0字符"}, + "font": {"chinese_font_name": "宋体", "font_size": "小四"}, + "label": { + "chinese_font_name": "黑体", + "english_font_name": "Times New Roman", + "font_size": "三号", + "font_color": "黑色", + "bold": True, + "italic": False, + "underline": False, + }, + "rules": { + "keyword_count": {"enabled": True, "count_min": 3, "count_max": 5} + }, }, - "rules": {"keyword_count": {"enabled": True, "count_min": 3, "count_max": 5}}, - } + ) RULES = {"keyword_count": "_check_keyword_count"} _LABEL_RE = re.compile(r"Keywords?:?\s*", re.IGNORECASE) @@ -211,25 +214,29 @@ class KeywordsCN(BaseKeywordsNode): NODE_TYPE = "abstract.chinese.keywords" NODE_LABEL = "中文关键词" - DEFAULTS = { - **BASE_FORMAT, - "first_line_indent": "0字符", - "chinese_font_name": "宋体", - "font_size": "小四", - "label": { - "chinese_font_name": "黑体", - "english_font_name": "Times New Roman", - "font_size": "三号", - "font_color": "黑色", - "bold": True, - "italic": False, - "underline": False, - }, - "rules": { - "keyword_count": {"enabled": True, "count_min": 3, "count_max": 5}, - "trailing_punctuation": {"enabled": True, "forbidden_chars": ";,。、"}, + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"first_line_indent": "0字符"}, + "font": {"chinese_font_name": "宋体", "font_size": "小四"}, + "label": { + "chinese_font_name": "黑体", + "english_font_name": "Times New Roman", + "font_size": "三号", + "font_color": "黑色", + "bold": True, + "italic": False, + "underline": False, + }, + "rules": { + "keyword_count": {"enabled": True, "count_min": 3, "count_max": 5}, + "trailing_punctuation": { + "enabled": True, + "forbidden_chars": ";,。、", + }, + }, }, - } + ) RULES = { "keyword_count": "_check_keyword_count", "trailing_punctuation": "_check_trailing_punctuation", diff --git a/src/wordformat/rules/references.py b/src/wordformat/rules/references.py index b66820b..dcd92b1 100644 --- a/src/wordformat/rules/references.py +++ b/src/wordformat/rules/references.py @@ -3,7 +3,7 @@ # @Author : afish # @File : references.py -from wordformat.config.dotdict import BASE_FORMAT +from wordformat.config.dotdict import BASE_FORMAT, deep_merge from wordformat.rules.node import FormatNode from wordformat.structure.registry import register @@ -14,14 +14,16 @@ class References(FormatNode): NODE_TYPE = "references.title" NODE_LABEL = "参考文献标题" - DEFAULTS = { - **BASE_FORMAT, - "alignment": "居中对齐", - "first_line_indent": "0字符", - "chinese_font_name": "黑体", - "font_size": "三号", - "bold": True, - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": { + "alignment": "居中对齐", + "first_line_indent": "0字符", + }, + "font": {"chinese_font_name": "黑体", "font_size": "三号", "bold": True}, + }, + ) @register("references_content") @@ -30,9 +32,10 @@ class ReferenceEntry(FormatNode): NODE_TYPE = "references.entry" NODE_LABEL = "参考文献条目" - DEFAULTS = { - **BASE_FORMAT, - "first_line_indent": "0字符", - "chinese_font_name": "宋体", - "font_size": "五号", - } + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"first_line_indent": "0字符"}, + "font": {"chinese_font_name": "宋体", "font_size": "五号"}, + }, + ) From e16711731a9e45db97944d482db61c86158f2702 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 23:03:34 +0800 Subject: [PATCH 05/12] =?UTF-8?q?=E5=AE=8C=E5=96=84=20md=E2=86=92docx=20?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=EF=BC=9A=E5=9B=BE=E7=89=87/=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC/=E9=A2=98=E6=B3=A8/=E7=BC=96=E5=8F=B7/=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E7=AC=A6/=E5=86=85=E8=81=94=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片:FigureImage._try_insert_image 从 value 路径插入实际图片,图在题注上 - 表格:TableObject._try_insert_table 创建 Word 表格,通过 addnext 定位到段落后 - 题注:![alt](path) 自动拆为 figure_image + caption_figure 节点 - 代码块:按行拆为独立 body_text 段落,每行一个节点 - 换行符:parser 层统一 \r\n→\n,apply 时自动清除 - codespan:_extract_text 补充处理内联代码 `` `xxx` `` - NodeConfigRoot 加 paragraph/font fallback,编号颜色/字体跟随标题样式 --- pyproject.toml | 1 + src/wordformat/config/models.py | 9 ++- src/wordformat/markdown/parser.py | 89 ++++++++++++++++++++-------- src/wordformat/pipeline/stages_md.py | 10 +++- src/wordformat/rules/node.py | 11 ++++ src/wordformat/rules/object.py | 63 +++++++++++++++++++- 6 files changed, 153 insertions(+), 30 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b775006..b7d94ec 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,7 @@ max-complexity = 10 "cli.py" = ["C901"] "**/body.py" = ["C901"] "**/pipeline/stages.py" = ["C901"] +"**/markdown/parser.py" = ["C901"] "**/utils/_text.py" = ["C901"] [tool.ruff.format] diff --git a/src/wordformat/config/models.py b/src/wordformat/config/models.py index d543fd4..d3a164e 100644 --- a/src/wordformat/config/models.py +++ b/src/wordformat/config/models.py @@ -23,7 +23,14 @@ def __getattr__(self, key: str): try: val = self[key] except KeyError: - return None + # fallback:到 paragraph / font 子字典中查找 + for sub in ("paragraph", "font"): + sub_dict = self.get(sub) + if isinstance(sub_dict, dict) and key in sub_dict: + val = sub_dict[key] + break + else: + return None if isinstance(val, dict) and not isinstance(val, NodeConfigRoot): return NodeConfigRoot(**val) return val diff --git a/src/wordformat/markdown/parser.py b/src/wordformat/markdown/parser.py index 524956a..be3ce98 100644 --- a/src/wordformat/markdown/parser.py +++ b/src/wordformat/markdown/parser.py @@ -47,12 +47,14 @@ def _walk_blocks(blocks: list[dict], result: list[dict]) -> None: result.append(_make_heading(block)) elif btype in ("paragraph", "block_text"): item = _make_paragraph(block) - if item: + if isinstance(item, list): + result.extend(item) + elif item: result.append(item) elif btype == "table": result.append(_make_table(block)) elif btype == "block_code": - result.append(_make_code_block(block)) + result.extend(_make_code_block(block)) elif btype == "list": _walk_list(block, result) elif btype == "block_quote": @@ -72,18 +74,34 @@ def _make_heading(block: dict) -> dict: } -def _make_paragraph(block: dict) -> dict | None: +def _make_paragraph(block: dict) -> dict | list[dict] | None: children = block.get("children", []) - # 仅含图片的段落 → figure_image + # 仅含图片的段落 non_empty = [c for c in children if c["type"] != "softbreak"] if len(non_empty) == 1 and non_empty[0]["type"] == "image": - return { - "category": "figure_image", - "paragraph": non_empty[0]["attrs"].get("url", ""), - "score": 1.0, - "inline_marks": [], - } + img_node = non_empty[0] + url = img_node["attrs"].get("url", "") + alt_text = _extract_text(img_node.get("children", [])).strip() + # 图片在前,题注在后(图题在下) + result: list[dict] = [ + { + "category": "figure_image", + "paragraph": url, + "score": 1.0, + "inline_marks": [], + } + ] + if alt_text: + result.append( + { + "category": "caption_figure", + "paragraph": alt_text, + "score": 1.0, + "inline_marks": [], + } + ) + return result text = _extract_text(children) if not text.strip(): @@ -98,32 +116,50 @@ def _make_paragraph(block: dict) -> dict | None: def _make_table(block: dict) -> dict: - rows: list[str] = [] + rows: list[list[str]] = [] for child in block.get("children", []): - if child["type"] in ("table_head", "table_body"): + if child["type"] == "table_head": + # table_head: cells are direct children (no row wrapper) + cells = [ + _extract_text(cell.get("children", [])) + for cell in child.get("children", []) + ] + rows.append(cells) + elif child["type"] == "table_body": + # table_body: children are table_row elements for row in child.get("children", []): cells = [ _extract_text(cell.get("children", [])) for cell in row.get("children", []) ] - rows.append(" | ".join(cells)) - text = "\n".join(rows) + rows.append(cells) return { - "category": "body_text", - "paragraph": text, + "category": "table_object", + "paragraph": "", "score": 1.0, "inline_marks": [], + "table_rows": rows, } -def _make_code_block(block: dict) -> dict: +def _make_code_block(block: dict) -> list[dict]: + """代码块按行拆成多个段落,每行独立为一个 body_text 节点。""" text = block.get("raw", "") - return { - "category": "body_text", - "paragraph": text, - "score": 1.0, - "inline_marks": [{"text": text, "code": True}], - } + lines = text.split("\n") + result: list[dict] = [] + for line in lines: + line = line.strip("\r") + if not line.strip(): + continue + result.append( + { + "category": "body_text", + "paragraph": line, + "score": 1.0, + "inline_marks": [{"text": line, "code": True}], + } + ) + return result def _walk_list(block: dict, result: list[dict]) -> None: @@ -140,7 +176,9 @@ def _extract_text(children: list[dict]) -> str: def walk(nodes): for node in nodes: if node["type"] == "text": - parts.append(node["raw"]) + parts.append(node["raw"].replace("\r\n", "\n").replace("\r", "\n")) + elif node["type"] == "codespan": + parts.append(node.get("raw", "")) elif node["type"] == "softbreak": parts.append(" ") elif node["type"] == "linebreak": @@ -161,7 +199,8 @@ def walk(nodes, attrs): # noqa: C901 ntype = node["type"] if ntype == "text": if node["raw"]: - segments.append({"text": node["raw"], **attrs}) + text = node["raw"].replace("\r\n", "\n").replace("\r", "\n") + segments.append({"text": text, **attrs}) elif ntype in ("strong", "emphasis", "strikethrough"): extra = { "strong": "bold", diff --git a/src/wordformat/pipeline/stages_md.py b/src/wordformat/pipeline/stages_md.py index 4fbcafd..1207edc 100644 --- a/src/wordformat/pipeline/stages_md.py +++ b/src/wordformat/pipeline/stages_md.py @@ -6,6 +6,7 @@ Markdown 仅提供文本内容和文档结构(标题层级、段落划分), 所有格式(字体、字号、段落样式)由 YAML 配置 + FormatNode 统一处理。 +图片插入、表格创建等由对应 FormatNode 子类负责。 """ from pathlib import Path @@ -44,7 +45,7 @@ class DocumentCreationStage: """从 FormatNode 树创建新的 .docx Document。 每个节点创建一个段落,单 run 填充纯文本。 - 格式由后续的 FormattingExecutionStage 统一应用。 + 格式、图片、表格等由后续 FormattingExecutionStage 中各 FormatNode 子类处理。 """ def process(self, ctx: FormatContext) -> FormatContext: @@ -55,8 +56,13 @@ def process(self, ctx: FormatContext) -> FormatContext: for node in nodes: value = node.value text = value.get("paragraph", "") if isinstance(value, dict) else str(value) + category = value.get("category", "") if isinstance(value, dict) else "" para = ctx.document.add_paragraph() - para.add_run(text.strip()) + # figure_image: 路径留给 FigureImage._try_insert_image 处理,这里只建空段落 + if category == "figure_image": + para.add_run("") + else: + para.add_run(text.strip()) node.paragraph = para return ctx diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py index af86cd7..9ed6179 100644 --- a/src/wordformat/rules/node.py +++ b/src/wordformat/rules/node.py @@ -362,6 +362,14 @@ def apply_replace(self, doc: Document = None) -> bool: logger.debug(f"已替换段落文本 → {replace_text[:50]}...") return True + def _remove_manual_line_breaks(self) -> None: + """移除段落内所有手动换行符 (Shift+Enter)。""" + from docx.oxml.ns import qn + + for r_elem in self.paragraph._element.findall(qn("w:r")): + for br in r_elem.findall(qn("w:br")): + r_elem.remove(br) + def _clean_paragraph_edge_spaces(self) -> None: """清理段落首尾 run 中的多余空格。 @@ -375,6 +383,9 @@ def _clean_paragraph_edge_spaces(self) -> None: if not runs: return + # 移除段落内的手动换行符(Shift+Enter → ) + self._remove_manual_line_breaks() + # 清理第一个非空 run 的开头空格 for run in runs: if run.text: diff --git a/src/wordformat/rules/object.py b/src/wordformat/rules/object.py index 5a2dd39..efb2d4c 100644 --- a/src/wordformat/rules/object.py +++ b/src/wordformat/rules/object.py @@ -1,5 +1,9 @@ """图片段落和表格对象节点。""" +from pathlib import Path + +from docx.shared import Inches + from wordformat.rules.node import FormatNode from wordformat.structure.registry import register from wordformat.style.comments import format_comment @@ -10,6 +14,7 @@ class FigureImage(FormatNode): """图片段落节点(包含内联图片的段落,非题注)。 只检查对齐和首行缩进,不检查行距、字体等。 + md→docx 时会尝试从 value 中的路径插入实际图片。 """ NODE_TYPE = "figures.image" @@ -22,6 +27,8 @@ def _base(self, doc, p: bool, r: bool): from wordformat.style.defs import Alignment, FirstLineIndent from wordformat.style.diff import _format_para_value + self._try_insert_image() + cfg = self.pydantic_config expected_align = Alignment(str(cfg.alignment or "居中对齐")) actual_align = expected_align.get_from_paragraph(self.paragraph) @@ -51,12 +58,64 @@ def _base(self, doc, p: bool, r: bool): ), ) + def _try_insert_image(self) -> None: + """如果段落为空且 value 中有图片路径,尝试插入图片。""" + if self.paragraph is None: + return + # 已有非空 run 时跳过(图片已存在或来自 docx 而非 md) + for run in self.paragraph.runs: + if run.text and run.text.strip(): + return + value = self.value + if not isinstance(value, dict): + return + path = value.get("paragraph", "").strip() + if not path: + return + img_path = Path(path) + if not img_path.exists(): + return + try: + # 清空占位 run,插入图片 + for run in self.paragraph.runs: + run.text = "" + if self.paragraph.runs: + self.paragraph.runs[0].add_picture(str(img_path), width=Inches(5.5)) + else: + run = self.paragraph.add_run() + run.add_picture(str(img_path), width=Inches(5.5)) + except Exception: + pass # 图片格式不支持等,保留占位文本 + @register("table_object") class TableObject(FormatNode): - """表格对象节点(表格整体格式,非题注)。""" + """表格对象节点(表格整体格式,非题注)。 + + md→docx 时会从 value.table_rows 创建 Word 表格。 + """ NODE_TYPE = "tables.object" NODE_LABEL = "表格对象" DEFAULTS = {} - DEFAULT_RULES = {} # 表格对象格式由 Word 表格属性控制 + DEFAULT_RULES = {} + + def _base(self, doc, p: bool, r: bool): + self._try_insert_table(doc) + + def _try_insert_table(self, doc) -> None: + """如果 value 中有 table_rows,在段落位置后插入 Word 表格。""" + value = self.value + if not isinstance(value, dict): + return + rows = value.get("table_rows", []) + if not rows: + return + num_cols = max(len(r) for r in rows) if rows else 1 + table = doc.add_table(rows=len(rows), cols=num_cols, style="Table Grid") + for i, row_data in enumerate(rows): + for j, cell_text in enumerate(row_data): + table.cell(i, j).text = cell_text + # 将表格从文档末尾移到当前段落后 + if self.paragraph is not None: + self.paragraph._element.addnext(table._element) From dce78636a9a353b3b94e0c454d15cc4f1375d205 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 23:05:21 +0800 Subject: [PATCH 06/12] =?UTF-8?q?=E5=AE=8C=E5=96=84=20md=E2=86=92docx=20?= =?UTF-8?q?=E8=BD=AC=E6=8D=A2=EF=BC=9A=E5=9B=BE=E7=89=87/=E8=A1=A8?= =?UTF-8?q?=E6=A0=BC/=E9=A2=98=E6=B3=A8/=E7=BC=96=E5=8F=B7/=E6=8D=A2?= =?UTF-8?q?=E8=A1=8C=E7=AC=A6/=E5=86=85=E8=81=94=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 图片:FigureImage._try_insert_image 从 value 路径插入实际图片,图在题注上 - 表格:TableObject._try_insert_table 创建 Word 表格,通过 addnext 定位到段落后 - 题注:![alt](path) 自动拆为 figure_image + caption_figure 节点 - 代码块:按行拆为独立 body_text 段落,每行一个节点 - 换行符:parser 层统一 \r\n→\n,apply 时自动清除 - codespan:_extract_text 补充处理内联代码 `` `xxx` `` - NodeConfigRoot 加 paragraph/font fallback,编号颜色/字体跟随标题样式 --- pyproject.toml | 2 +- src/wordformat/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index b7d94ec..dbe3518 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wordformat" -version = "1.4.0" +version = "1.5.0" description = "论文格式自动化处理工具" readme = "README.md" license = "Apache-2.0" diff --git a/src/wordformat/_version.py b/src/wordformat/_version.py index b3d780d..fd547ba 100644 --- a/src/wordformat/_version.py +++ b/src/wordformat/_version.py @@ -1,3 +1,3 @@ # 此文件由 scripts/sync_version.py 自动生成,请勿手动编辑。 # 版本号唯一来源为 pyproject.toml。 -__version__ = "1.4.0" +__version__ = "1.5.0" From 05f64865f153f8d0df6822b6f3e499714126a07d Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 23:11:38 +0800 Subject: [PATCH 07/12] =?UTF-8?q?=E4=BC=98=E5=8C=96=20wordformat-skill=20?= =?UTF-8?q?=E6=96=87=E6=A1=A3=20+=20=E7=B2=BE=E7=AE=80=20README=20AI=20Ski?= =?UTF-8?q?ll=20=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SKILL.md: 去掉已删除脚本引用,新增 wordf md 命令,精简为三个流程 - config_spec.md: 更新到新配置路径和 paragraph/font 分组,移除死字段 - category_reference.md: 从 155 行精简到 60 行 - README: 移除 AI Skill 集成段(体验差,浪费 token),保留预设配置说明 --- README.md | 34 +-- wordformat-skill/SKILL.md | 234 +++------------- wordformat-skill/data/category_reference.md | 189 ++++--------- wordformat-skill/data/config_spec.md | 285 ++++++++------------ 4 files changed, 197 insertions(+), 545 deletions(-) diff --git a/README.md b/README.md index bdb44c1..0349c2c 100644 --- a/README.md +++ b/README.md @@ -147,39 +147,9 @@ python -m wordformat startapi 更多详细用法请查看 [使用指南](https://github.com/AfishInLake/WordFormat/blob/master/docs/usage.md) -## AI Skill 集成 +## 预设配置 -WordFormat 提供了 **SOLO Skill**,可在 SOLO 等 AI 助手平台中直接调用,实现对话式论文格式化。 - -### Skill 工作流程 - -Skill 包含两个独立任务,可分步执行: - -| 任务 | 说明 | 产物 | -|------|------|------| -| **任务一:准备配置文件** | 根据格式要求生成/编辑 config.yaml | `config.yaml` | -| **任务二:执行格式化** | 使用配置文件对论文进行格式检查或修正 | `--标注版.docx` 或 `--修改版.docx` | - -### Skill 目录结构 - -``` -wordformat-skill/ -├── SKILL.md # Skill 定义文件 -├── scripts/ -│ ├── setup_config.py # 配置文件生成/验证脚本 -│ ├── validate_json.py # JSON 标签校验脚本 -│ └── validate_config.py # 配置文件验证脚本 -└── data/ - ├── config.yaml # 默认配置模板 - ├── config_spec.md # 配置文件完整字段规范 - ├── config_editing_guide.md # 配置编辑指南 - ├── category_reference.md # 段落分类参考 - └── font_size_table.md # 字号对照表 -``` - -### 预设配置库 - -项目内置了多所高校的论文格式预设,保存在 `presets/` 目录下,命名格式为 `{学校}_{学院/专业}_{论文类型}.yaml`,可直接使用或在此基础上修改。 +项目内置了多所高校的论文格式预设,保存在 `presets/` 目录下,可直接使用或在此基础上修改。 ## 详细文档 diff --git a/wordformat-skill/SKILL.md b/wordformat-skill/SKILL.md index 0cd7c50..7be847c 100644 --- a/wordformat-skill/SKILL.md +++ b/wordformat-skill/SKILL.md @@ -1,230 +1,82 @@ --- name: wordformat -description: 论文格式自动化处理工具。在处理 Word 论文文档格式校验、格式修正、文档结构识别场景时激活,具备使用 AI 模型智能识别文档结构并根据 YAML 配置文件自动校验或修正论文格式的专业能力。 -argument-hint: "[docx文件路径]" +description: 论文格式自动化处理工具。在处理 Word 论文文档格式校验、格式修正、文档结构识别、Markdown 转 Word 场景时激活。 +argument-hint: "[文件路径]" --- -# WordFormat - 论文格式自动化处理工具 +# WordFormat ## 安装 ```bash -pip install wordformat -i https://pypi.tuna.tsinghua.edu.cn/simple # 国内 -pip install wordformat # 海外 +pip install wordformat ``` 验证:`wordf --help` -## 功能速查 +## 命令速查 -### CLI 命令 - -| 命令 | 功能 | 输入 | 输出 | -|------|------|------|------| -| `wordf config` | 查看所有可配置字段及默认值 | 无 | 终端输出 | -| `wordf config -o config.yaml` | 输出完整配置模板 | 无 | `config.yaml` | -| `wordf gj` | AI 识别文档结构 | `.docx` + `config.yaml` | `output/xxx.json` | -| `wordf tree` | 查看结构树 | `.json` | 终端树形图 | -| `wordf cf` | 检查格式(批注) | `.docx` + `config.yaml` + `.json` | `--标注版.docx` | -| `wordf af` | 修正格式(直接改) | `.docx` + `config.yaml` + `.json` | `--修改版.docx` | -| `wordf startapi` | Web 界面 | 无 | http://127.0.0.1:8000 | - -### 辅助脚本 - -| 脚本 | 功能 | +| 命令 | 功能 | |------|------| -| `setup_config.py` | 验证/保存 YAML 配置 | -| `validate_json.py` | 校验 JSON category,显示统计 | - -### JSON 字段(`wordf gj` 输出,可手动编辑) - -| 字段 | 说明 | -|------|------| -| `category` | 段落类型(16种),改这里修正分类 | -| `replace` | 可选,填入新文本直接替换段落内容 | -| `fingerprint` | 段落指纹(不要改) | -| `score` | AI 置信度 | - -### 格式检查范围 - -段落(对齐/间距/行距/缩进)、字符(字体/字号/颜色/粗斜体/下划线)、标题自动编号、题注编号校验、关键词数量、表格内容。 - -### 不支持 - -页眉页脚、目录生成、封面排版、图片尺寸、Word 域代码。需提前告知用户手动处理。 - ---- +| `wordf md -d 论文.md -c config.yaml` | Markdown → 格式化 .docx | +| `wordf gj -d 论文.docx -c config.yaml` | AI 识别文档结构,输出 JSON | +| `wordf tree -f output/xxx.json` | 查看文档结构树 | +| `wordf cf -d 论文.docx -c config.yaml -f output/xxx.json` | 检查格式(加批注) | +| `wordf af -d 论文.docx -c config.yaml -f output/xxx.json` | 修正格式(直接改) | +| `wordf config -o config.yaml` | 输出配置模板 | +| `wordf startapi` | 启动 Web 界面 | ## 工作流程 -根据用户意图走不同路径: - -- **仅文本替换**:只想修正论文中某些段落的文字内容(如错别字、措辞),不检查格式 → [纯文本替换流程](#纯文本替换流程) -- **格式校验/修正**:检查或修正论文格式 → 下面两个独立任务,先任务一再任务二 - -每个任务完成后**必须将产物复制到用户工作目录**。 - ---- - -## 纯文本替换流程 - -> 用户只想换文字(错别字、措辞修正),不需要格式批注/编号/样式修改。 - -1. 生成 JSON:`wordf gj -d 论文.docx -c config.yaml` -2. 编辑 JSON:在需要替换的段落对象中添加 `"replace": "新的正确文本"` -3. 执行替换:`wordf af -d 论文.docx -c config.yaml -f output/论文_xxx.json` -4. 交付:`cp output/论文--修改版.docx <用户工作目录>/` +根据用户意图选择路径: -> `wordf af` 执行替换的同时也会应用格式修正。如果 config.yaml 格式规范与论文当前格式一致,则仅文本被替换、格式不变。**用户说"只换文字"时走此流程,不要追加 `cf` 检查或格式批注操作。** +### 纯文本替换 ---- - -## 格式校验/修正流程 - ---- - -### 任务一:准备配置文件 - -> **🚨 用户已提供 .yaml 配置 → 直接跳到步骤 1.2 验证,禁止新建。** -> **🚨 用户说"用上次的配置"/"和上次一样" → 复用已有配置,禁止新建。** - -### 步骤 1.0 查找已有配置 - -**优先级:用户提供的 > 工作目录已有 > 预设库 > 新建** +只换文字不查格式。生成 JSON → 编辑 `replace` 字段 → 执行替换。 ```bash -# 扫描工作目录下所有 .yaml 文件 -ls *.yaml 2>/dev/null || echo "无 .yaml 文件" +wordf gj -d 论文.docx -c config.yaml +# 编辑 output/xxx.json,添加 "replace": "正确文本" +wordf af -d 论文.docx -c config.yaml -f output/xxx.json ``` -找到文件后逐个验证: +### Markdown 转 Word ```bash -for f in *.yaml; do - echo "--- 验证: $f ---" - python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --validate --config "$f" 2>&1 \ - && echo "✅ 合法" || echo "❌ 非配置文件" -done +wordf md -d 论文.md -c config.yaml +# 输出 output/论文--生成版.docx ``` -找到合法配置 → **询问用户确认**,不要新建或覆盖。用户确认后跳到步骤 1.2。 - -没有 .yaml 文件时尝试预设库: +### 格式校验/修正 ```bash -python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --list-presets -python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --use "清华大学_计算机学院_本科" --output config.yaml -``` - -预设也找不到时,继续步骤 1.1 新建。 +# 1. 准备配置(已有则跳过) +wordf config -o config.yaml +# 编辑 config.yaml 适配学校要求 -### 步骤 1.1 新建配置 +# 2. 生成结构 +wordf gj -d 论文.docx -c config.yaml -> 运行 `wordf config` 查看所有字段和默认值,`wordf config -o config.yaml` 生成模板。 +# 3. 检查结构(可选) +wordf tree -f output/xxx.json --confidence -**先完整读完格式要求文件,再编辑。** 编辑原则:只改值、不增减字段、不动 YAML 锚点语法。 - -### 步骤 1.2 验证 - -```bash -python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --validate --config config.yaml -``` - -失败则根据提示修正,直到通过。 - -### 步骤 1.3 保存预设(首次生成时可选) - -```bash -python ${CLAUDE_SKILL_DIR}/scripts/setup_config.py --save --config config.yaml --name "XX大学_XX学院_本科" +# 4. 格式化 +wordf cf -d 论文.docx -c config.yaml -f output/xxx.json # 检查 +wordf af -d 论文.docx -c config.yaml -f output/xxx.json # 修正 ``` -### 步骤 1.4 交付 +## JSON 字段 -```bash -cp config.yaml <用户工作目录>/ -``` - -询问用户确认配置,提醒页眉页脚、目录等无法自动处理。**任务一完成。** - ---- - -### 任务二:执行格式化 - -> 前提:有 `config.yaml` 和 `.docx` 论文。 - -### 步骤 2.1 生成 JSON - -```bash -wordf gj -d $ARGUMENTS -c config.yaml -``` - -记住输出的 JSON 路径(`output/论文_xxx.json`)。 - -### 步骤 2.2 检查结构 - -```bash -wordf tree -f -wordf tree -f --confidence # 看低置信度段落 -``` - -### 步骤 2.3 校验并修正 JSON - -> 先读 [data/category_reference.md](data/category_reference.md)。 - -```bash -python ${CLAUDE_SKILL_DIR}/scripts/validate_json.py --json --stats --show-all --threshold 0.8 -``` - -**常见修正**: - -| 误识别 | 改为 | -|--------|------| -| "摘要" → `heading_level_1` | `abstract_chinese_title` | -| "关键词:..." → `body_text` | `keywords_chinese` | -| "参考文献" → `heading_level_1` | `references_title` | -| 摘要标题+正文同一段 | `abstract_chinese_title_content` | -| 目录/封面/页眉页脚 | `other`(跳过格式化) | - -**⚠️ 不修改的 body_text**:摘要标题后的正文(自动升级为摘要正文)、参考文献标题后的条目(自动升级为参考文献条目)。 - -**用 `replace` 修正文本**:添加 `"replace": "正确文本"`,格式化时自动替换。 - -修正后重新验证确认无误。 - -### 步骤 2.4 格式化 - -```bash -wordf cf -d 论文.docx -c config.yaml -f # 检查(批注,不改原文) -wordf af -d 论文.docx -c config.yaml -f # 修正(直接改) -``` - -`numbering.enabled: true` 时,`af` 模式会自动清除手动编号并应用 Word 自动编号。 - -### 步骤 2.5 交付 - -```bash -cp output/论文--标注版.docx <用户工作目录>/ # cf 模式 -cp output/论文--修改版.docx <用户工作目录>/ # af 模式 -``` - -询问用户检查结果,提醒手动补充。**任务二完成。** - ---- - -## Python 调用 +| 字段 | 说明 | +|------|------| +| `category` | 段落类型,改这里修正分类 | +| `replace` | 填入新文本替换段落内容 | +| `score` | AI 置信度 | -```python -from wordformat.set_tag import set_tag_main -from wordformat.set_style import auto_format_thesis_document +## 格式检查范围 -data = set_tag_main(docx_path="论文.docx", configpath="config.yaml") -auto_format_thesis_document(jsonpath=data, docxpath="论文.docx", - configpath="config.yaml", savepath="output/", check=True) -``` +段落格式(对齐/间距/行距/缩进)、字符格式(字体/字号/颜色/粗斜体/下划线)、标题自动编号、题注编号、关键词数量、标点符号。 -## 注意事项 +## 不支持 -- 仅 `.docx`,Python ≥ 3.10 -- AI 分类非 100% 准确,**务必检查 JSON** -- 产物必须复制到用户工作目录 -- 每个任务完成后询问用户确认 +页眉页脚、目录生成、封面排版。需提前告知用户手动处理。 diff --git a/wordformat-skill/data/category_reference.md b/wordformat-skill/data/category_reference.md index 1372d36..c1108c1 100644 --- a/wordformat-skill/data/category_reference.md +++ b/wordformat-skill/data/category_reference.md @@ -1,154 +1,55 @@ # Category 类型参考 -WordFormat AI 模型识别文档结构后,会为每个段落分配一个 `category` 类型。以下是模型实际输出的所有 category 类型及其精确判定规则。 +AI 模型为每个段落分配的 category 类型。手动编辑 JSON 时修改此字段即可纠正分类。 + +## 完整列表 + +| Category | 判定规则 | +|----------|----------| +| `heading_level_1` | "第X章"或数字开头的标题 | +| `heading_level_2` | "X.Y" 格式的二级标题 | +| `heading_level_3` | "X.Y.Z" 格式的三级标题 | +| `heading_mulu` | "目录" 或 "目 录" | +| `heading_fulu` | "附录" | +| `abstract_chinese_title` | "摘要" | +| `abstract_chinese_title_content` | 摘要标题和正文在同一段落 | +| `abstract_english_title` | "Abstract" | +| `abstract_english_title_content` | 英文摘要标题和正文在同一段落 | +| `keywords_chinese` | 含"关键词"或"关键字" | +| `keywords_english` | 含"Keywords" | +| `body_text` | 普通正文段落 | +| `caption_figure` | "图 X.Y" 开头的图注 | +| `caption_table` | "表 X.Y" 开头的表注 | +| `references_title` | "参考文献" 或 "References" | +| `acknowledgements_title` | "致谢" 或 "Acknowledgements" | + +## 常见误判修正 + +| 误识别 | 应改为 | +|--------|--------| +| "摘要" → `heading_level_1` | `abstract_chinese_title` | +| "关键词:..." → `body_text` | `keywords_chinese` | +| "参考文献" → `heading_level_1` | `references_title` | +| 目录/封面内容 | `other`(跳过格式化) | + +## 自动提升机制 + +以下情况**不需要手动修改**,代码会自动处理: + +| 父节点 | 其下 body_text 自动升级为 | +|--------|--------------------------| +| `abstract_chinese_title` | `abstract_chinese_content` | +| `abstract_english_title` | `abstract_english_content` | +| `references_title` | `reference_entry` | + +## replace 字段 + +在 JSON 条目中添加 `"replace": "新文本"` 可在格式化时替换段落内容。 -## 完整 Category 列表(共 16 个) - -### 标题类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `heading_level_1` | 段落必须以"第X章"或单个阿拉伯数字(如"1""2")开头,后接空格和标题文字;仅为名词短语 | "1 绪论"、"3 系统设计" | -| `heading_level_2` | 段落必须以"X.Y"格式开头(如"1.1""2.3"),且**仅包含一个"."**;后接标题文字,无完整句子 | "1.1 研究背景"、"2.4 实验设置" | -| `heading_level_3` | 段落必须以"X.Y.Z"格式开头(如"1.1.1""3.2.5"),且**包含恰好两个"."**;后接标题文字,无论长短,只要无句子结构即视为三级标题 | "1.1.1 研究背景"、"2.3.4 性能对比" | -| `heading_mulu` | 段落等于"目录"或"目 录"(含空格变体) | "目录"、"目 录" | -| `heading_fulu` | 段落等于"附录" | "附录" | - -### 摘要类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `abstract_chinese_title` | 仅当段落是"摘要"或"摘 要"(允许尾随空格或冒号) | "摘要"、"摘要:" | -| `abstract_chinese_title_content` | 当且仅当摘要标题和摘要正文合并在同一个段落中 | "摘要 本文围绕校园二手物品交易平台..." | -| `abstract_english_title` | 仅当段落是"Abstract"(大小写不敏感,允许尾随空格或冒号) | "Abstract"、"Abstract: " | -| `abstract_english_title_content` | 当且仅当英文摘要标题和摘要正文合并在同一个段落中 | "Abstract This paper focuses on the design..." | - -### 关键词类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `keywords_chinese` | 包含"关键词"或"关键字",后面跟着术语列表 | "关键词:校园交易;二手物品;Django" | -| `keywords_english` | 包含"Keywords"(大小写不敏感),后面跟着英文术语 | "Keywords: Campus trading; Second-hand..." | - -### 正文类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `body_text` | 仅当段落包含句子(有谓语动词、句号、逻辑连接词)、是摘要等段落或明确论述(如"本章""本文""包括""例如""若...则...")时才归为此类 | "随着互联网技术的快速发展..." | - -### 图表类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `caption_figure` | 以"图 X.Y"或"Figure X.Y"开头的图注 | "图2.1 系统架构图" | -| `caption_table` | 以"表 X.Y"或"Table X.Y"开头的表注 | "表3.1 用户表结构" | - -### 参考文献类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `references_title` | 段落等于"参考文献"或"References" | "参考文献"、"References" | - -### 致谢类 - -| Category | 判定规则 | 典型内容示例 | -|----------|----------|-------------| -| `acknowledgements_title` | 段落和"致谢"或"Acknowledgements"等词意思相近 | "致谢"、"Acknowledgements" | - -## 检查要点 - -在第三步检查 JSON 文件时,重点注意以下容易误判的情况: - -1. **摘要标题 vs 一级标题**:"摘要" 可能被误识别为 `heading_level_1`,应为 `abstract_chinese_title` -2. **关键词 vs 正文**:关键词行可能被误识别为 `body_text`,应为 `keywords_chinese` -3. **参考文献标题 vs 一级标题**:"参考文献" 可能被误识别为 `heading_level_1`,应为 `references_title` -4. **图题注 vs 表题注**:注意区分 `caption_figure` 和 `caption_table` -5. **致谢标题 vs 一级标题**:"致谢" 可能被误识别为 `heading_level_1`,应为 `acknowledgements_title` -6. **摘要标题+内容合并**:如果摘要标题和正文在同一段落,应为 `abstract_chinese_title_content` 或 `abstract_english_title_content`,而非单独的标题或正文 -7. **目录等非内容段落**:目录页、页眉页脚、封面信息等不属于任何格式化类别,应将 category 改为 `"other"`,代码会自动跳过这些段落 - -## ⚠️ 重要:非内容段落标记为 `"other"` - -论文中有些段落不属于任何格式化类别(如目录、页眉页脚、封面信息等),模型会将它们误识别为 `body_text` 或其他类型。**必须手动将这些段落的 `category` 改为 `"other"`**。 - -代码在构建文档树时,遇到 `"other"` 会自动跳过(`node_factory.py` 中 `Unknown category: other, skipping`),不会对其进行任何格式化处理。 - -**常见需要标记为 `"other"` 的内容:** - -| 内容类型 | 典型示例 | 说明 | -|----------|----------|------| -| 目录标题 | "目 录"、"目录" | 改为 `"heading_mulu"`,代码会自动跳过 | -| 目录条目 | "1 绪论 ...... 1"、"2.1 研究背景 ...... 5" | 带省略号和页码的条目 | -| 页眉 | 学校名称、论文名称等重复出现的页眉 | 每页顶部的内容 | -| 页脚 | 页码、"第 X 页 共 Y 页" | 每页底部的内容 | -| 封面信息 | 学校名称、学院、专业、学号、指导教师、日期 | 封面中的结构化信息 | -| 空行 | 空段落 | 无内容的段落 | - -## ⚠️ 重要:目录部分必须全部跳过 - -**目录(含目录标题、目录条目、目录页码等)是 Word 自动生成的区域,绝对不能格式化,也不能添加任何段落标签。** - -处理规则: -1. **目录标题**(如"目录"、"目 录")→ 改为 `"heading_mulu"` -2. **所有目录条目**(如"1 绪论 ...... 1"、"2.1 研究背景 ...... 5")→ 改为 `"other"` -3. **目录区域内的其他段落**(如空行、分隔线等)→ 改为 `"other"` - -**为什么必须跳过目录:** -- 目录由 Word 的域代码自动生成,格式化会破坏域结构 -- 目录条目经常被模型误识别为 `heading_level_1`、`heading_level_2`、`body_text` 等,**必须全部纠正为 `"other"`** -- 对目录段落应用格式(字体、字号、行距等)或添加段落标签会导致目录显示异常 -- 目录标题标记为 `"heading_mulu"` 后,代码会自动跳过该节点及其所有子节点的格式化 - -**快速识别目录区域的方法:** -- 目录通常紧跟在摘要之后、正文之前 -- 目录条目特征:包含省略号(`......`)和页码、或以"第X章"开头但带页码 -- 目录标题行之后到下一个一级标题之前的所有段落都属于目录区域 - -## ⚠️ 重要:自动类型提升机制 - -**以下情况不是分类错误,不要修改 JSON 中的 category!** - -WordFormat 内部有一个自动类型提升机制:当文档树构建完成后,代码会自动将特定父节点下的 `body_text` 子节点升级为对应的专用类型: - -| 父节点类型 | 其下的 body_text 自动升级为 | 说明 | -|-----------|---------------------------|------| -| `abstract_chinese_title` | `abstract_chinese_content` | 中文摘要标题后面的正文段落 | -| `abstract_english_title` | `abstract_english_content` | 英文摘要标题后面的正文段落 | -| `references_title` | `reference_entry` | 参考文献标题后面的每条文献 | - -**这意味着:** - -- 摘要标题后面的正文段落,模型输出为 `body_text` 是**正确的**,代码会自动将其升级为 `abstract_chinese_content` 并应用摘要正文的格式规则 -- 参考文献标题后面的每条文献,模型输出为 `body_text` 也是**正确的**,代码会自动升级为 `reference_entry` 并应用参考文献内容的格式规则 -- **Agent 不需要手动修改这些 `body_text` 为其他类型**,修改反而会导致格式化逻辑混乱 - -**只有以下情况才需要手动修正 category:** -- 摘要标题本身被错分为 `heading_level_1` 或 `body_text` -- 关键词行被错分为 `body_text` -- 图注/表注互相错分 -- 致谢标题被错分 - -## `replace` 字段:替换段落文本 - -JSON 中每个段落条目可以包含一个可选的 `"replace"` 字段,用于在格式化时直接替换段落文本内容。 - -**使用场景**: -- AI 识别文字有误(如 OCR 错误、空格异常):`"摘 要"` → `"摘 要"` -- 修正错别字或特定短语 -- 更正标题文字 - -**用法**:直接在 JSON 条目的 `replace` 字段填入新文本即可: ```json { "category": "abstract_chinese_title", "paragraph": "摘 要", - "fingerprint": "abc123", "replace": "摘 要" } ``` - -**注意事项**: -- `replace` 是可选字段,不需要时不用添加 -- 替换在格式化之前执行,替换后的文本会被按配置规范格式化 -- 检查和格式化模式均会执行替换 -- 不要用 `replace` 来修改 `category`——修改分类请直接改 `category` 字段 diff --git a/wordformat-skill/data/config_spec.md b/wordformat-skill/data/config_spec.md index 66471b9..24aa0f9 100644 --- a/wordformat-skill/data/config_spec.md +++ b/wordformat-skill/data/config_spec.md @@ -1,214 +1,143 @@ # config.yaml 字段规范 -> 权威参考,编辑时只能使用本文档列出的字段。完整来源:`src/wordformat/config/datamodel.py`。 +## 基础字段 -## GlobalFormat 基础字段(15 个) - -所有段落配置节点都基于此字段集,通过 `<<: *global_format` 继承后覆盖。 - -| 字段 | 类型 | 可选值 | 默认值 | -|------|------|--------|--------| -| `alignment` | string | `左对齐` `居中对齐` `右对齐` `两端对齐` `分散对齐` | `左对齐` | -| `space_before` | string | `"0行"` `"0.5行"` `"12磅"` `"0.5cm"` | `"0.5行"` | -| `space_after` | string | 同上 | `"0.5行"` | -| `line_spacingrule` | string | `单倍行距` `1.5倍行距` `2倍行距` `最小值` `固定值` `多倍行距` | `单倍行距` | -| `line_spacing` | string | `"1倍"` `"1.5倍"` `"20磅"` `"0倍"` | `"1.5倍"` | -| `left_indent` | string | `"0字符"` `"2字符"` `"20磅"` `"0.75cm"` | `"0字符"` | -| `right_indent` | string | 同上 | `"0字符"` | -| `first_line_indent` | string | 正值为首行缩进,负值为悬挂缩进 | `"2字符"` | -| `chinese_font_name` | string | `宋体` `黑体` `楷体` `仿宋` `微软雅黑` | `宋体` | -| `english_font_name` | string | `Times New Roman` `Arial` `Calibri` | `Times New Roman` | -| `font_size` | string/number | `三号` `小三` `四号` `小四` `五号` `小五` 或 `12` `14` | `小四` | -| `font_color` | string | `黑色` `红色` 或 `#FF0000` | `黑色` | -| `bold` | bool | `true` / `false` | `false` | -| `italic` | bool | `true` / `false` | `false` | -| `underline` | bool | `true` / `false` | `false` | -| `builtin_style_name` | string | `正文` `Heading 1` `Heading 2` `Heading 3` `题注` | `正文` | - -`line_spacingrule` 与 `line_spacing` 必须配合: - -| line_spacingrule | line_spacing | -|------------------|--------------| -| `固定值` | `"20磅"` | -| `1.5倍行距` | `"1.5倍"` | -| `单倍行距` | `"1倍"` | -| `最小值` | `"12磅"` | - -## 各配置节点 - -### style_checks_warning(格式警告开关) - -15 个 bool 字段,名称与 GlobalFormat 相同,默认均为 `true`(`font_name` 和 `font_color` 默认 `false`)。 - -### global_format(全局基准) - -15 个 GlobalFormat 字段。使用 YAML 锚点 `&global_format` 定义,其他节点通过 `<<: *global_format` 继承。 - -### abstract(摘要) +所有配置节点基于 `global_format` 定义,通过 `<<: *global_format` 继承。分为三个组: ```yaml -abstract: - chinese: - chinese_title: # 继承 15 字段 - chinese_content: # 继承 15 字段 - english: - english_title: # 继承 15 字段 - english_content: # 继承 15 字段 - keywords: - chinese: # 继承 15 字段 + label + 3 专用字段 - english: # 同上 +global_format: &global_format + paragraph: # 段落格式 + alignment: '两端对齐' + space_before: "0行" + space_after: "0行" + line_spacingrule: "1.5倍行距" + line_spacing: '1.5倍' + left_indent: "0字符" + right_indent: "0字符" + first_line_indent: '2字符' + builtin_style_name: '正文' + font: # 字符格式 + chinese_font_name: '宋体' + english_font_name: 'Times New Roman' + font_size: '小四' + font_color: '黑色' + bold: false + italic: false + underline: false ``` -keywords 除 16 个 GlobalFormat 字段外,附加: +| 组 | 字段 | 可选值 | +|----|------|--------| +| paragraph | `alignment` | 左对齐 / 居中对齐 / 右对齐 / 两端对齐 / 分散对齐 | +| paragraph | `space_before/after` | "0行" "0.5行" "12磅" "0.5cm" | +| paragraph | `line_spacingrule` | 单倍行距 / 1.5倍行距 / 2倍行距 / 最小值 / 固定值 / 多倍行距 | +| paragraph | `line_spacing` | "1.5倍" "20磅" | +| paragraph | `left/right_indent` | "0字符" "2字符" "20磅" | +| paragraph | `first_line_indent` | 正值=首行缩进,负值=悬挂缩进 | +| paragraph | `builtin_style_name` | 正文 / Heading 1/2/3 / 题注 | +| font | `chinese_font_name` | 宋体 / 黑体 / 楷体 / 仿宋 | +| font | `english_font_name` | Times New Roman / Arial | +| font | `font_size` | 三号 / 小三 / 四号 / 小四 / 五号 / 12 / 14 | +| font | `font_color` | 黑色 / 红色 / #FF0000 | +| font | `bold/italic/underline` | true / false | + +> 覆盖时可直接用平铺字段(如 `alignment: '居中对齐'`),框架自动区分 paragraph/font。 + +## 配置路径 + +| 路径 | 说明 | +|------|------| +| `abstract.chinese.title` | 中文摘要标题 | +| `abstract.chinese.body` | 中文摘要正文 | +| `abstract.chinese.keywords` | 中文关键词 | +| `abstract.english.title` | 英文摘要标题 | +| `abstract.english.body` | 英文摘要正文 | +| `abstract.english.keywords` | 英文关键词 | +| `headings.level_1/2/3` | 一/二/三级标题 | +| `body.text` | 正文段落 | +| `figures.caption` | 图题注 | +| `figures.image` | 图片段落 | +| `tables.caption` | 表题注 | +| `tables.object` | 表格对象 | +| `references.title` | 参考文献标题 | +| `references.entry` | 参考文献条目 | +| `acknowledgements.title` | 致谢标题 | +| `acknowledgements.body` | 致谢正文 | + +## 特殊节点字段 + +### 关键词 (abstract.{zh/en}.keywords) + +基础字段 + `label`(字符格式) + `rules`: ```yaml -label: # KeywordLabelConfig,关键词标签的字符格式 +label: + chinese_font_name: '黑体' + font_size: '四号' + bold: false rules: - keyword_count: # 关键词数量校验 + keyword_count: enabled: true - count_min: 4 - count_max: 6 - trailing_punctuation: # 末尾标点校验(仅中文) + count_min: 3 + count_max: 5 + trailing_punctuation: # 仅中文 enabled: true forbidden_chars: ";,。、" ``` -| 字段 | 类型 | 默认值 | -|------|------|--------| -| `label` | GlobalFormat | 关键词标签("关键词:")的字符格式 | -| `rules.keyword_count.enabled` | bool | `true` | -| `rules.keyword_count.count_min` | int | `4` | -| `rules.keyword_count.count_max` | int | `6` | -| `rules.trailing_punctuation.enabled` | bool | `true` | -| `rules.trailing_punctuation.forbidden_chars` | string | `";,。、"` | - -### headings(标题) - -```yaml -headings: - level_1: # 继承 15 字段,builtin_style_name 必须为 Heading 1 - level_2: # 继承 15 字段,builtin_style_name 必须为 Heading 2 - level_3: # 继承 15 字段,builtin_style_name 必须为 Heading 3 -``` - -### body_text(正文) - -继承 15 字段,通常不覆盖。 +### 题注 (figures.caption / tables.caption) -### figures(图注) - -继承 16 字段 + `caption_prefix`(默认 `图`)+ `rules`。 +基础字段 + `caption_prefix` + `rules`: ```yaml -figures: - <<: *global_format - caption_prefix: '图' - font_size: '五号' - alignment: '居中对齐' - first_line_indent: '0字符' - builtin_style_name: '题注' - rules: - caption_numbering: - enabled: true - separator: '.' - label_number_space: false +caption_prefix: '图' # 或 '表' +rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false ``` -### tables(表注 + 表格内容) - -继承 16 字段 + `caption_prefix`(默认 `表`)+ `content` 子配置 + `rules`。 - -```yaml -tables: - <<: *global_format - caption_prefix: '表' - builtin_style_name: '题注' - content: - <<: *global_format - font_size: '五号' - line_spacingrule: '单倍行距' - rules: - caption_numbering: - enabled: true - separator: '.' - label_number_space: false -``` +### 正文 (body.text) -### references(参考文献) +基础字段 + `rules`: ```yaml -references: - title: # 继承 15 字段 - content: # 继承 15 字段 +rules: + punctuation: + enabled: true ``` -悬挂缩进示例:`first_line_indent: '-2.2字符'` + `left_indent: '0.26字符'`。 +### style_checks_warning -### acknowledgements(致谢) +控制各字段差异是否在批注中显示: -```yaml -acknowledgements: - title: # 继承 15 字段 - content: # 继承 15 字段 -``` +| 字段 | 类型 | 默认 | +|------|------|------| +| `alignment` | bool | true | +| `space_before/after` | bool | true | +| `line_spacing/line_spacing_rule` | bool | true | +| `left/right/first_line_indent` | bool | true | +| `builtin_style_name` | bool | true | +| `font_size` | bool | true | +| `font_name_cn/font_name_en` | bool | false | +| `font_color` | bool | false | +| `bold` | bool | true | +| `italic/underline` | bool | false | -### numbering(自动编号) - -**仅在 `wordf af` 模式下生效。** - -| 字段 | 类型 | 默认值 | 说明 | -|------|------|--------|------| -| `enabled` | bool | `false` | 总开关 | -| `captions.enabled` | bool | `false` | 题注编号开关 | -| `captions.separator` | string | `.` | 章节号-编号分隔符 | -| `captions.label_number_space` | bool | `false` | 标签与编号间加空格 | -| `level_1/2/3.enabled` | bool | `false` | 该级编号开关 | -| `level_1/2/3.template` | string | — | `%1` `%1.%2` `第%1章` | -| `level_1/2/3.suffix` | string | `space` | `tab` `space` `nothing` | -| `level_1/2/3.numbering_indent` | string | — | 可选,如 `"0.75cm"` | -| `level_1/2/3.text_indent` | string | — | 可选,如 `"2.2字符"` | -| `references.enabled` | bool | `true` | 参考文献编号 | -| `references.template` | string | `[%1]` | 编号模板 | -| `references.suffix` | string | `space` | 同上 | +## numbering(自动编号) ```yaml numbering: enabled: true - captions: + level_1/2/3: enabled: true - separator: '.' - level_1: - enabled: true - template: '%1' - suffix: space - level_2: + template: '%1' # %1.%2, 第%1章 等 + suffix: space # tab / space / nothing + numbering_indent: # 可选 + text_indent: # 可选 + references: enabled: true - template: '%1.%2' + template: '[%1]' suffix: space ``` - -## 字段白名单 - -| 配置路径 | 合法字段 | -|----------|----------| -| `style_checks_warning` | bold, italic, underline, font_size, font_name, font_color, alignment, space_before, space_after, line_spacing, line_spacingrule, left_indent, right_indent, first_line_indent, builtin_style_name | -| `global_format` | 同上 + chinese_font_name, english_font_name, font_size, font_color, bold, italic, underline | -| `abstract.{zh/en}.{title/content}` | 同 global_format | -| `abstract.keywords.{zh/en}` | 同 global_format + label(同 global_format) + rules | -| `abstract.keywords.{zh/en}.rules` | keyword_count, trailing_punctuation | -| `abstract.keywords.{zh/en}.rules.keyword_count` | enabled, count_min, count_max | -| `abstract.keywords.{zh/en}.rules.trailing_punctuation` | enabled, forbidden_chars | -| `headings.level_1/2/3` | 同 global_format | -| `body_text` | 同 global_format | -| `figures` | 同 global_format + caption_prefix + rules | -| `figures.rules` | caption_numbering | -| `figures.rules.caption_numbering` | enabled, separator, label_number_space | -| `tables` | 同 global_format + caption_prefix + content(同 global_format) + rules | -| `tables.rules` | caption_numbering | -| `tables.rules.caption_numbering` | enabled, separator, label_number_space | -| `references.title` | 同 global_format | -| `references.content` | 同 global_format | -| `acknowledgements.title/content` | 同 global_format | -| `numbering` | enabled, captions | -| `numbering.captions` | enabled, separator, label_number_space | -| `numbering.level_1/2/3/references` | enabled, template, suffix, numbering_indent, text_indent | From dcde4356540c2059b08ae3c3fe45aa328e919e70 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 23:24:28 +0800 Subject: [PATCH 08/12] =?UTF-8?q?WordFormatUI=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E8=B7=AF=E5=BE=84=E5=90=8C=E6=AD=A5=E5=90=8E=E7=AB=AF=20NODE?= =?UTF-8?q?=5FTYPE=20=E9=87=8D=E5=91=BD=E5=90=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 所有配置路径匹配新命名: title/body/keywords/caption/entry/object - global_format 改用 paragraph/font 分组结构 - style_checks_warning: font_name→font_name_cn+font_name_en, line_spacingrule→line_spacing_rule - 删除死字段: tables.content, numbering.captions - FormatConfig.vue 覆盖全部 16 个 paragraph+font 字段,无需改动 --- .../src/config-generator/ConfigGenerator.vue | 4 +- .../components/AbstractConfig.vue | 36 +-- .../components/AcknowledgementsConfig.vue | 2 +- .../components/FiguresConfig.vue | 10 +- .../components/NumberingConfig.vue | 8 - .../components/ReferencesConfig.vue | 2 +- .../components/TablesConfig.vue | 12 +- .../components/WarningFieldsConfig.vue | 7 +- WordFormatUI/src/config-generator/utils.js | 232 ++++++++---------- 9 files changed, 132 insertions(+), 181 deletions(-) diff --git a/WordFormatUI/src/config-generator/ConfigGenerator.vue b/WordFormatUI/src/config-generator/ConfigGenerator.vue index 06afda9..2e2f89c 100644 --- a/WordFormatUI/src/config-generator/ConfigGenerator.vue +++ b/WordFormatUI/src/config-generator/ConfigGenerator.vue @@ -35,10 +35,10 @@
- +
- +
diff --git a/WordFormatUI/src/config-generator/components/AbstractConfig.vue b/WordFormatUI/src/config-generator/components/AbstractConfig.vue index 9d64bc0..b514fa7 100644 --- a/WordFormatUI/src/config-generator/components/AbstractConfig.vue +++ b/WordFormatUI/src/config-generator/components/AbstractConfig.vue @@ -2,42 +2,42 @@

中文摘要

中文标题

- +

中文内容

- +

英文摘要

英文标题

- +

英文内容

- +

关键词配置

中文关键词

- -
-
- -
+ +
+
+ +
中文关键词内容格式
- +
中文关键词标签("关键词:")
- +

英文关键词

- -
-
- -
+ +
+
+ +
英文关键词内容格式
- +
英文关键词标签("Keywords:")
- +
diff --git a/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue b/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue index b41a2b7..045983a 100644 --- a/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue +++ b/WordFormatUI/src/config-generator/components/AcknowledgementsConfig.vue @@ -3,7 +3,7 @@

致谢标题

致谢内容

- + diff --git a/WordFormatUI/src/config-generator/components/FiguresConfig.vue b/WordFormatUI/src/config-generator/components/FiguresConfig.vue index 53e9289..1267b36 100644 --- a/WordFormatUI/src/config-generator/components/FiguresConfig.vue +++ b/WordFormatUI/src/config-generator/components/FiguresConfig.vue @@ -1,13 +1,13 @@ diff --git a/WordFormatUI/src/config-generator/components/ReferencesConfig.vue b/WordFormatUI/src/config-generator/components/ReferencesConfig.vue index b28289c..046d65b 100644 --- a/WordFormatUI/src/config-generator/components/ReferencesConfig.vue +++ b/WordFormatUI/src/config-generator/components/ReferencesConfig.vue @@ -3,7 +3,7 @@

参考文献标题

参考文献内容

- + diff --git a/WordFormatUI/src/config-generator/components/TablesConfig.vue b/WordFormatUI/src/config-generator/components/TablesConfig.vue index 7e11ca4..089c6f6 100644 --- a/WordFormatUI/src/config-generator/components/TablesConfig.vue +++ b/WordFormatUI/src/config-generator/components/TablesConfig.vue @@ -1,17 +1,15 @@ diff --git a/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue b/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue index f00d7e1..278bce0 100644 --- a/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue +++ b/WordFormatUI/src/config-generator/components/WarningFieldsConfig.vue @@ -11,13 +11,14 @@
-
+
+
-
+
@@ -29,7 +30,7 @@ diff --git a/WordFormatUI/src/config-generator/utils.js b/WordFormatUI/src/config-generator/utils.js index 9ccc934..dfae3cf 100644 --- a/WordFormatUI/src/config-generator/utils.js +++ b/WordFormatUI/src/config-generator/utils.js @@ -1,5 +1,5 @@ -// 默认全局格式 -export const defaultGlobalFormat = { +// 默认全局段落格式 +const defaultParagraph = { alignment: "两端对齐", space_before: "0行", space_after: "0行", @@ -8,7 +8,11 @@ export const defaultGlobalFormat = { left_indent: "0字符", right_indent: "0字符", first_line_indent: "2字符", - builtin_style_name: "正文", + builtin_style_name: "正文" +} + +// 默认全局字体格式 +const defaultFont = { chinese_font_name: "宋体", english_font_name: "Times New Roman", font_size: "小四", @@ -18,12 +22,15 @@ export const defaultGlobalFormat = { underline: false } +// 默认全局格式(平铺,向后兼容) +export const defaultGlobalFormat = { + ...defaultParagraph, + ...defaultFont +} + // 创建带全局格式继承的配置对象 -export const createConfigWithGlobalInheritance = (baseConfig = {}) => { - return { - ...defaultGlobalFormat, - ...baseConfig - } +export const createConfigWithGlobalInheritance = (overrides = {}) => { + return { ...defaultGlobalFormat, ...overrides } } // 默认配置 @@ -34,46 +41,35 @@ export const defaultConfig = { italic: false, underline: false, font_size: true, - font_name: true, + font_name_cn: true, + font_name_en: true, font_color: false, alignment: true, space_before: true, space_after: true, line_spacing: true, - line_spacingrule: true, + line_spacing_rule: true, left_indent: true, right_indent: true, first_line_indent: true, builtin_style_name: true }, global_format: { - ...defaultGlobalFormat + paragraph: { ...defaultParagraph }, + font: { ...defaultFont } }, abstract: { chinese: { - chinese_title: createConfigWithGlobalInheritance({ + title: createConfigWithGlobalInheritance({ alignment: "居中对齐", first_line_indent: "0字符", chinese_font_name: "黑体", font_size: "四号" }), - chinese_content: createConfigWithGlobalInheritance({ + body: createConfigWithGlobalInheritance({ alignment: "两端对齐" - }) - }, - english: { - english_title: createConfigWithGlobalInheritance({ - alignment: "居中对齐", - first_line_indent: "0字符", - font_size: "四号", - bold: false }), - english_content: createConfigWithGlobalInheritance({ - alignment: "两端对齐" - }) - }, - keywords: { - chinese: createConfigWithGlobalInheritance({ + keywords: createConfigWithGlobalInheritance({ alignment: "两端对齐", first_line_indent: "2字符", font_size: "小四", @@ -86,8 +82,19 @@ export const defaultConfig = { keyword_count: { enabled: true, count_min: 3, count_max: 5 }, trailing_punctuation: { enabled: true, forbidden_chars: ";,。、" } } + }) + }, + english: { + title: createConfigWithGlobalInheritance({ + alignment: "居中对齐", + first_line_indent: "0字符", + font_size: "四号", + bold: false }), - english: createConfigWithGlobalInheritance({ + body: createConfigWithGlobalInheritance({ + alignment: "两端对齐" + }), + keywords: createConfigWithGlobalInheritance({ alignment: "两端对齐", first_line_indent: "2字符", font_size: "小四", @@ -96,8 +103,7 @@ export const defaultConfig = { bold: false }), rules: { - keyword_count: { enabled: true, count_min: 3, count_max: 5 }, - trailing_punctuation: { enabled: false, forbidden_chars: ".,;:" } + keyword_count: { enabled: true, count_min: 3, count_max: 5 } } }) } @@ -131,57 +137,51 @@ export const defaultConfig = { builtin_style_name: "Heading 3" }) }, - body_text: createConfigWithGlobalInheritance({ - rules: { - punctuation: { enabled: true } - } - }), - figures: createConfigWithGlobalInheritance({ - alignment: "居中对齐", - first_line_indent: "0字符", - font_size: "五号", - builtin_style_name: "题注", - caption_prefix: "图", + body: { + text: createConfigWithGlobalInheritance({ + rules: { punctuation: { enabled: true } } + }) + }, + figures: { + caption: createConfigWithGlobalInheritance({ + alignment: "居中对齐", + first_line_indent: "0字符", + font_size: "五号", + builtin_style_name: "题注", + caption_prefix: "图", + rules: { + caption_numbering: { enabled: true, separator: '.', label_number_space: false } + } + }), image: createConfigWithGlobalInheritance({ alignment: "居中对齐", first_line_indent: "0字符" + }) + }, + tables: { + caption: createConfigWithGlobalInheritance({ + alignment: "居中对齐", + first_line_indent: "0字符", + font_size: "五号", + builtin_style_name: "题注", + caption_prefix: "表", + rules: { + caption_numbering: { enabled: true, separator: '.', label_number_space: false } + } }), - rules: { - caption_numbering: { enabled: true, separator: '.', label_number_space: false } - } - }), - tables: createConfigWithGlobalInheritance({ - alignment: "居中对齐", - first_line_indent: "0字符", - font_size: "五号", - builtin_style_name: "题注", - caption_prefix: "表", object: createConfigWithGlobalInheritance({ alignment: "居中对齐", first_line_indent: "0字符" - }), - content: createConfigWithGlobalInheritance({ - chinese_font_name: '宋体', - english_font_name: 'Times New Roman', - font_size: '五号', - line_spacingrule: '单倍行距', - alignment: '居中对齐', - first_line_indent: '0字符', - space_before: "0行", - space_after: "0行" - }), - rules: { - caption_numbering: { enabled: true, separator: '.', label_number_space: false } - } - }), + }) + }, references: { title: createConfigWithGlobalInheritance({ alignment: "居中对齐", first_line_indent: "0字符", chinese_font_name: "黑体", - font_size: "三号", + font_size: "三号" }), - content: createConfigWithGlobalInheritance({ + entry: createConfigWithGlobalInheritance({ alignment: "两端对齐", first_line_indent: "-2.2字符", left_indent: "0.26字符", @@ -196,7 +196,7 @@ export const defaultConfig = { chinese_font_name: "黑体", font_size: "小二" }), - content: createConfigWithGlobalInheritance({ + body: createConfigWithGlobalInheritance({ alignment: "两端对齐", font_size: "五号" }) @@ -230,11 +230,6 @@ export const defaultConfig = { suffix: 'space', numbering_indent: null, text_indent: null - }, - captions: { - enabled: false, - separator: '.', - label_number_space: false } } } @@ -269,72 +264,37 @@ export const mergeWithDefaults = (config, defaults) => { // 应用全局格式到所有局部配置 export const applyGlobalFormatToAll = (userConfig) => { - // 摘要配置 - userConfig.abstract.chinese.chinese_title = { - ...userConfig.global_format - } - userConfig.abstract.chinese.chinese_content = { - ...userConfig.global_format - } - userConfig.abstract.english.english_title = { - ...userConfig.global_format - } - userConfig.abstract.english.english_content = { - ...userConfig.global_format - } - userConfig.abstract.keywords.english = { - ...userConfig.global_format, - label: userConfig.abstract.keywords.english.label, - rules: userConfig.abstract.keywords.english.rules - } - userConfig.abstract.keywords.chinese = { - ...userConfig.global_format, - label: userConfig.abstract.keywords.chinese.label, - rules: userConfig.abstract.keywords.chinese.rules - } + const gf = userConfig.global_format + const flat = { ...(gf.paragraph || {}), ...(gf.font || {}) } - // 标题配置 - userConfig.headings.level_1 = { - ...userConfig.global_format - } - userConfig.headings.level_2 = { - ...userConfig.global_format - } - userConfig.headings.level_3 = { - ...userConfig.global_format - } + // 摘要 + const abs = userConfig.abstract + abs.chinese.title = { ...flat, ...abs.chinese.title } + abs.chinese.body = { ...flat, ...abs.chinese.body } + abs.chinese.keywords = { ...flat, label: abs.chinese.keywords.label, rules: abs.chinese.keywords.rules } + abs.english.title = { ...flat, ...abs.english.title } + abs.english.body = { ...flat, ...abs.english.body } + abs.english.keywords = { ...flat, label: abs.english.keywords.label, rules: abs.english.keywords.rules } - // 正文配置 - userConfig.body_text = { - ...userConfig.global_format - } + // 标题 + userConfig.headings.level_1 = { ...flat, ...userConfig.headings.level_1 } + userConfig.headings.level_2 = { ...flat, ...userConfig.headings.level_2 } + userConfig.headings.level_3 = { ...flat, ...userConfig.headings.level_3 } - // 插图配置 - userConfig.figures = { - ...userConfig.global_format, - caption_prefix: userConfig.figures.caption_prefix - } + // 正文 + userConfig.body.text = { ...flat, ...userConfig.body.text } - // 表格配置 - userConfig.tables = { - ...userConfig.global_format, - caption_prefix: userConfig.tables.caption_prefix, - content: userConfig.tables.content - } + // 图/表 + userConfig.figures.caption = { ...flat, caption_prefix: userConfig.figures.caption.caption_prefix, rules: userConfig.figures.caption.rules } + userConfig.figures.image = { ...flat, ...userConfig.figures.image } + userConfig.tables.caption = { ...flat, caption_prefix: userConfig.tables.caption.caption_prefix, rules: userConfig.tables.caption.rules } + userConfig.tables.object = { ...flat, ...userConfig.tables.object } - // 参考文献配置 - userConfig.references.title = { - ...userConfig.global_format - } - userConfig.references.content = { - ...userConfig.global_format - } + // 参考文献 + userConfig.references.title = { ...flat, ...userConfig.references.title } + userConfig.references.entry = { ...flat, ...userConfig.references.entry } - // 致谢配置 - userConfig.acknowledgements.title = { - ...userConfig.global_format - } - userConfig.acknowledgements.content = { - ...userConfig.global_format - } + // 致谢 + userConfig.acknowledgements.title = { ...flat, ...userConfig.acknowledgements.title } + userConfig.acknowledgements.body = { ...flat, ...userConfig.acknowledgements.body } } From cca59fbc707f5194da7f302ecf3a0a8c432e237b Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 23:35:11 +0800 Subject: [PATCH 09/12] =?UTF-8?q?=E6=96=B0=E5=A2=9E=E6=B5=8B=E8=AF=95?= =?UTF-8?q?=EF=BC=9Aparser=20=E8=BE=B9=E7=BC=98=E6=83=85=E5=86=B5=20+=20st?= =?UTF-8?q?ages=5Fmd=20+=20object.py=EF=BC=8C=E8=A6=86=E7=9B=96=E7=8E=87?= =?UTF-8?q?=E5=9B=9E=E5=88=B0=2087%?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - parser: 表格/引用/图片+题注/删除线/链接/crlf/内联代码/换行 等 12 个测试 - stages_md: LoadMarkdownStage/MarkdownParseStage/DocumentCreationStage 4 个测试 - object: FigureImage._base/_try_insert_image, TableObject 表格创建/位置 6 个测试 --- tests/markdown/test_parser.py | 73 ++++++++++++++++ tests/markdown/test_stages_md.py | 81 ++++++++++++++++++ tests/rules/test_object.py | 137 +++++++++++++++++++++++++++++++ 3 files changed, 291 insertions(+) create mode 100644 tests/markdown/test_stages_md.py create mode 100644 tests/rules/test_object.py diff --git a/tests/markdown/test_parser.py b/tests/markdown/test_parser.py index 0ecbe4d..02686e2 100644 --- a/tests/markdown/test_parser.py +++ b/tests/markdown/test_parser.py @@ -130,3 +130,76 @@ def test_standalone_image_becomes_figure(self): def test_thematic_break_skipped(self): result = parse_markdown("---") assert len(result) == 0 + + def test_table_with_header_and_body(self): + text = "| A | B |\n|----|----|\n| 1 | 2 |\n| 3 | 4 |" + result = parse_markdown(text) + assert len(result) == 1 + assert result[0]["category"] == "table_object" + rows = result[0]["table_rows"] + assert len(rows) == 3 # header + 2 data rows + assert rows[0] == ["A", "B"] + + def test_block_quote_flattens(self): + result = parse_markdown("> 引用文字") + assert len(result) == 1 + assert result[0]["category"] == "body_text" + assert "引用文字" in result[0]["paragraph"] + + def test_code_block_empty_lines_skipped(self): + result = parse_markdown('```\n\na = 1\n\nb = 2\n\n```') + assert len(result) == 2 + assert result[0]["paragraph"] == "a = 1" + assert result[1]["paragraph"] == "b = 2" + + def test_paragraph_with_inline_image_kept_as_body(self): + """段落中文字+图片混合时保持为 body_text""" + result = parse_markdown("文字 ![](img.png) 更多文字") + assert len(result) == 1 + assert result[0]["category"] == "body_text" + + def test_image_with_alt_creates_caption_and_figure(self): + result = parse_markdown("![图1.1 系统架构](arch.png)") + assert len(result) == 2 + assert result[0]["category"] == "figure_image" + assert result[0]["paragraph"] == "arch.png" + assert result[1]["category"] == "caption_figure" + assert result[1]["paragraph"] == "图1.1 系统架构" + + def test_image_no_alt_creates_figure_only(self): + result = parse_markdown("![](noalt.png)") + assert len(result) == 1 + assert result[0]["category"] == "figure_image" + assert result[0]["paragraph"] == "noalt.png" + + def test_strikethrough_text(self): + result = parse_markdown("这是~~删除~~文字") + assert len(result) == 1 + marks = result[0]["inline_marks"] + strike_segs = [m for m in marks if m.get("strikethrough")] + assert len(strike_segs) == 1 + assert strike_segs[0]["text"] == "删除" + + def test_link_text(self): + result = parse_markdown("访问[链接](https://example.com)") + marks = result[0]["inline_marks"] + link_segs = [m for m in marks if m.get("link_url")] + assert len(link_segs) == 1 + assert link_segs[0]["link_url"] == "https://example.com" + + def test_crlf_normalized(self): + result = parse_markdown("第一行\r\n第二行\r第三行") + assert len(result) >= 1 + text = result[0]["paragraph"] + assert "\r" not in text + + def test_codespan_in_text(self): + result = parse_markdown("使用 `wordf md` 命令") + text = result[0]["paragraph"] + assert "wordf md" in text + assert "`" not in text + + def test_linebreak_in_paragraph(self): + result = parse_markdown("第一行 \n第二行") + text = result[0]["paragraph"] + assert "\n" in text # linebreak preserved diff --git a/tests/markdown/test_stages_md.py b/tests/markdown/test_stages_md.py new file mode 100644 index 0000000..03b5aff --- /dev/null +++ b/tests/markdown/test_stages_md.py @@ -0,0 +1,81 @@ +"""MD pipeline stages 单元测试。""" +from pathlib import Path +from unittest.mock import patch + +import pytest + +from wordformat.pipeline.context import FormatContext +from wordformat.pipeline.stages_md import ( + DocumentCreationStage, + LoadMarkdownStage, + MarkdownParseStage, +) + + +class TestLoadMarkdownStage: + def test_loads_file_content(self, tmp_path): + md_file = tmp_path / "test.md" + md_file.write_text("# 标题\n\n正文") + ctx = FormatContext(md_path=str(md_file)) + stage = LoadMarkdownStage() + result = stage.process(ctx) + assert "# 标题" in result.md_text + assert "正文" in result.md_text + + def test_missing_file_raises(self): + ctx = FormatContext(md_path="/nonexistent/path.md") + stage = LoadMarkdownStage() + with pytest.raises(FileNotFoundError): + stage.process(ctx) + + +class TestMarkdownParseStage: + def test_parses_markdown_to_paragraphs(self, tmp_path): + md_file = tmp_path / "test.md" + md_file.write_text("# 标题\n\n正文段落") + ctx = FormatContext(md_path=str(md_file)) + ctx = LoadMarkdownStage().process(ctx) + ctx = MarkdownParseStage().process(ctx) + assert len(ctx.paragraphs) >= 2 + categories = [p["category"] for p in ctx.paragraphs] + assert "heading_level_1" in categories + assert "body_text" in categories + + +class TestDocumentCreationStage: + def test_creates_document_from_tree(self, root_node_fixture): + """用简单 tree 创建文档。""" + ctx = FormatContext() + ctx.root_node = root_node_fixture + ctx = DocumentCreationStage().process(ctx) + assert ctx.document is not None + assert len(ctx.document.paragraphs) >= 1 + + def test_figure_image_creates_empty_run(self): + """figure_image 节点只建空 run,路径留给 FigureImage。""" + from wordformat.rules.node import FormatNode + + root = FormatNode(value={}, level=0) + node = FormatNode( + value={"category": "figure_image", "paragraph": "test.png"}, + level=1, + ) + root.add_child_node(node) + ctx = FormatContext() + ctx.root_node = root + ctx = DocumentCreationStage().process(ctx) + assert node.paragraph is not None + assert node.paragraph.runs[0].text == "" + + +@pytest.fixture +def root_node_fixture(): + from wordformat.rules.node import FormatNode + + root = FormatNode(value={}, level=0) + child = FormatNode( + value={"category": "body_text", "paragraph": "测试正文"}, + level=1, + ) + root.add_child_node(child) + return root diff --git a/tests/rules/test_object.py b/tests/rules/test_object.py new file mode 100644 index 0000000..2e436cb --- /dev/null +++ b/tests/rules/test_object.py @@ -0,0 +1,137 @@ +"""FigureImage 和 TableObject 单元测试。""" +from unittest.mock import MagicMock, patch + +import pytest +from docx import Document + +from wordformat.rules.object import FigureImage, TableObject + + +class TestFigureImage: + def test_apply_format_no_image_path_skips(self): + """paragraph 有文字时跳过图片插入。""" + doc = Document() + p = doc.add_paragraph("已有文字") + node = FigureImage( + value={"category": "figure_image", "paragraph": "notexist.png"}, + level=1, + paragraph=p, + ) + node.load_config({}) + node.apply_format(doc) + # 不影响已有文字 + assert p.runs[0].text == "已有文字" + + def test_apply_format_empty_paragraph_missing_file(self): + """空段落 + 路径不存在 → 不崩溃。""" + doc = Document() + p = doc.add_paragraph("") + node = FigureImage( + value={"category": "figure_image", "paragraph": "/nonexistent/img.png"}, + level=1, + paragraph=p, + ) + node.load_config({}) + node.apply_format(doc) + # 不崩溃就是通过 + + def test_base_checks_alignment(self): + """_base 检查对齐和首行缩进。""" + doc = Document() + p = doc.add_paragraph("") + node = FigureImage( + value={"category": "figure_image"}, + level=1, + paragraph=p, + ) + node.load_config({"figures": {"image": {"alignment": "居中对齐", "first_line_indent": "0字符"}}}) + node._base(doc, p=True, r=False) + # 检查模式不崩溃 + + def test_check_format_with_comment(self): + """check_format 在格式不对时添加批注。""" + doc = Document() + p = doc.add_paragraph("") + node = FigureImage( + value={"category": "figure_image"}, + level=1, + paragraph=p, + ) + node.load_config({"figures": {"image": {"alignment": "居中对齐", "first_line_indent": "0字符"}}}) + with patch.object(node, "add_comment") as mock_comment: + node.check_format(doc) + assert mock_comment.call_count >= 1 + + +class TestTableObject: + def test_apply_no_table_rows_skips(self): + """无 table_rows 时跳过表格创建。""" + doc = Document() + p = doc.add_paragraph("") + node = TableObject( + value={"category": "table_object"}, + level=1, + paragraph=p, + ) + node.load_config({}) + with patch.object(node, "_try_insert_table") as mock_table: + node.apply_format(doc) + mock_table.assert_called_once() + + def test_try_insert_table_creates_table(self): + """有 table_rows 时创建 Word 表格。""" + doc = Document() + p = doc.add_paragraph("") + node = TableObject( + value={ + "category": "table_object", + "table_rows": [["A", "B"], ["1", "2"]], + }, + level=1, + paragraph=p, + ) + node.load_config({}) + node._try_insert_table(doc) + # 表格已插入文档 + assert len(doc.tables) == 1 + table = doc.tables[0] + assert len(table.rows) == 2 + assert table.cell(0, 0).text == "A" + + def test_try_insert_table_move_to_paragraph(self): + """表格移动到段落后,不是文档末尾。""" + doc = Document() + p1 = doc.add_paragraph("段落1") + p2 = doc.add_paragraph("段落2") + node = TableObject( + value={ + "category": "table_object", + "table_rows": [["X"]], + }, + level=1, + paragraph=p1, + ) + node.load_config({}) + node._try_insert_table(doc) + # 表格应该在 p1 之后 + body = doc.element.body + p_elements = body.findall( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p" + ) + tbl_elements = body.findall( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}tbl" + ) + assert len(tbl_elements) == 1 + + def test_apply_format_calls_try_insert(self): + """apply_format 触发 _try_insert_table。""" + doc = Document() + p = doc.add_paragraph("") + node = TableObject( + value={"category": "table_object", "table_rows": [["A"]]}, + level=1, + paragraph=p, + ) + node.load_config({}) + node.apply_format(doc) + assert len(doc.tables) == 1 From 70518424b173de60d7475f182c9bb1d664b67733 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sun, 12 Jul 2026 23:40:02 +0800 Subject: [PATCH 10/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20stages=5Fmd=20?= =?UTF-8?q?=E6=B5=8B=E8=AF=95=E5=9C=A8=20Windows=20=E4=B8=8B=E7=9A=84=20Un?= =?UTF-8?q?icodeEncodeError?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 测试内容改用 ASCII,mock logger 避免中文日志触发 charmap 编码错误 --- tests/markdown/test_stages_md.py | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/tests/markdown/test_stages_md.py b/tests/markdown/test_stages_md.py index 03b5aff..2e93ea3 100644 --- a/tests/markdown/test_stages_md.py +++ b/tests/markdown/test_stages_md.py @@ -15,12 +15,13 @@ class TestLoadMarkdownStage: def test_loads_file_content(self, tmp_path): md_file = tmp_path / "test.md" - md_file.write_text("# 标题\n\n正文") + md_file.write_text("# Heading\n\ncontent") ctx = FormatContext(md_path=str(md_file)) stage = LoadMarkdownStage() - result = stage.process(ctx) - assert "# 标题" in result.md_text - assert "正文" in result.md_text + with patch("wordformat.pipeline.stages_md.logger"): + result = stage.process(ctx) + assert "# Heading" in result.md_text + assert "content" in result.md_text def test_missing_file_raises(self): ctx = FormatContext(md_path="/nonexistent/path.md") @@ -32,10 +33,11 @@ def test_missing_file_raises(self): class TestMarkdownParseStage: def test_parses_markdown_to_paragraphs(self, tmp_path): md_file = tmp_path / "test.md" - md_file.write_text("# 标题\n\n正文段落") + md_file.write_text("# Heading\n\nparagraph text") ctx = FormatContext(md_path=str(md_file)) - ctx = LoadMarkdownStage().process(ctx) - ctx = MarkdownParseStage().process(ctx) + with patch("wordformat.pipeline.stages_md.logger"): + ctx = LoadMarkdownStage().process(ctx) + ctx = MarkdownParseStage().process(ctx) assert len(ctx.paragraphs) >= 2 categories = [p["category"] for p in ctx.paragraphs] assert "heading_level_1" in categories @@ -47,7 +49,8 @@ def test_creates_document_from_tree(self, root_node_fixture): """用简单 tree 创建文档。""" ctx = FormatContext() ctx.root_node = root_node_fixture - ctx = DocumentCreationStage().process(ctx) + with patch("wordformat.pipeline.stages_md.logger"): + ctx = DocumentCreationStage().process(ctx) assert ctx.document is not None assert len(ctx.document.paragraphs) >= 1 @@ -63,7 +66,8 @@ def test_figure_image_creates_empty_run(self): root.add_child_node(node) ctx = FormatContext() ctx.root_node = root - ctx = DocumentCreationStage().process(ctx) + with patch("wordformat.pipeline.stages_md.logger"): + ctx = DocumentCreationStage().process(ctx) assert node.paragraph is not None assert node.paragraph.runs[0].text == "" @@ -74,7 +78,7 @@ def root_node_fixture(): root = FormatNode(value={}, level=0) child = FormatNode( - value={"category": "body_text", "paragraph": "测试正文"}, + value={"category": "body_text", "paragraph": "test content"}, level=1, ) root.add_child_node(child) From 7c110e6b74da97068bcbd7637d416be0d4740b42 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Mon, 13 Jul 2026 14:16:32 +0800 Subject: [PATCH 11/12] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20math=20=E6=A8=A1?= =?UTF-8?q?=E5=9D=97=EF=BC=9AMarkdown=20=E6=95=B0=E5=AD=A6=E5=85=AC?= =?UTF-8?q?=E5=BC=8F=EF=BC=88$...$=20/=20$$...$$=EF=BC=89=E8=BD=AC=20OMML?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LaTeX → latex2mathml → MathML → mathml2omml → OMML,基于 python-docx 的 OxmlElement 构建 OMML 元素,集成到 md→docx 管线中: - 解析器使用 mistune math 插件识别 $ 和 $$ 分隔符 - DocumentCreationStage 渲染 m:oMath / m:oMathPara --- pyproject.toml | 3 + src/wordformat/markdown/parser.py | 36 ++++- src/wordformat/math/__init__.py | 30 ++++ src/wordformat/math/omml.py | 210 +++++++++++++++++++++++++++ src/wordformat/pipeline/stages_md.py | 46 ++++++ tests/markdown/test_parser.py | 44 ++++++ tests/math/test_omml.py | 170 ++++++++++++++++++++++ 7 files changed, 536 insertions(+), 3 deletions(-) create mode 100644 src/wordformat/math/__init__.py create mode 100644 src/wordformat/math/omml.py create mode 100644 tests/math/test_omml.py diff --git a/pyproject.toml b/pyproject.toml index dbe3518..02cb4d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ classifiers = [ "Operating System :: OS Independent", ] dependencies = [ + "latex2mathml>=3.81.0", + "lxml>=5.0.0", + "mathml2omml>=0.0.2", "fastapi>=0.128.1", "loguru>=0.7.3", "mistune>=3.0.0", diff --git a/src/wordformat/markdown/parser.py b/src/wordformat/markdown/parser.py index be3ce98..23d8b20 100644 --- a/src/wordformat/markdown/parser.py +++ b/src/wordformat/markdown/parser.py @@ -23,7 +23,7 @@ def parse_markdown(md_text: str) -> list[dict[str, Any]]: 供 DocumentCreationStage 创建多 run 段落时使用。 """ md = mistune.create_markdown( - renderer=None, plugins=["table", "strikethrough", "url"] + renderer=None, plugins=["math", "table", "strikethrough", "url"] ) ast = md(md_text) result: list[dict[str, Any]] = [] @@ -43,7 +43,9 @@ def _walk_blocks(blocks: list[dict], result: list[dict]) -> None: if btype == "blank_line" or btype == "thematic_break": continue - if btype == "heading": + if btype == "block_math": + result.append(_make_block_math(block)) + elif btype == "heading": result.append(_make_heading(block)) elif btype in ("paragraph", "block_text"): item = _make_paragraph(block) @@ -61,6 +63,17 @@ def _walk_blocks(blocks: list[dict], result: list[dict]) -> None: _walk_blocks(block.get("children", []), result) +def _make_block_math(block: dict) -> dict: + return { + "category": "math_block", + "paragraph": block.get("raw", ""), + "score": 1.0, + "inline_marks": [ + {"text": block.get("raw", ""), "math": True, "math_display": True} + ], + } + + def _make_heading(block: dict) -> dict: level = block["attrs"]["level"] category = f"heading_level_{min(level, 3)}" @@ -77,8 +90,10 @@ def _make_heading(block: dict) -> dict: def _make_paragraph(block: dict) -> dict | list[dict] | None: children = block.get("children", []) - # 仅含图片的段落 + # 仅含图片或块级公式的段落 non_empty = [c for c in children if c["type"] != "softbreak"] + if len(non_empty) == 1 and non_empty[0]["type"] == "block_math": + return _make_block_math(non_empty[0]) if len(non_empty) == 1 and non_empty[0]["type"] == "image": img_node = non_empty[0] url = img_node["attrs"].get("url", "") @@ -179,6 +194,10 @@ def walk(nodes): parts.append(node["raw"].replace("\r\n", "\n").replace("\r", "\n")) elif node["type"] == "codespan": parts.append(node.get("raw", "")) + elif node["type"] == "inline_math": + parts.append(f"${node.get('raw', '')}$") + elif node["type"] == "block_math": + parts.append(f"$${node.get('raw', '')}$$") elif node["type"] == "softbreak": parts.append(" ") elif node["type"] == "linebreak": @@ -216,6 +235,17 @@ def walk(nodes, attrs): # noqa: C901 ) elif ntype == "codespan": segments.append({"text": node.get("raw", ""), **attrs, "code": True}) + elif ntype == "inline_math": + segments.append({"text": node.get("raw", ""), **attrs, "math": True}) + elif ntype == "block_math": + segments.append( + { + "text": node.get("raw", ""), + **attrs, + "math": True, + "math_display": True, + } + ) elif ntype == "softbreak": segments.append({"text": " ", **attrs}) elif ntype == "linebreak": diff --git a/src/wordformat/math/__init__.py b/src/wordformat/math/__init__.py new file mode 100644 index 0000000..179d735 --- /dev/null +++ b/src/wordformat/math/__init__.py @@ -0,0 +1,30 @@ +"""wordformat.math — LaTeX → OMML 公式渲染。 + +将 LaTeX 数学表达式转换为 Word 原生 OMML (Office Math Markup Language), +支持分数、根式、上下标、大型运算符、重音符号、希腊字母等。 + +Usage: + from wordformat.math import add_display_math, add_inline_math, latex_to_omath + + # 块级公式 + add_display_math(doc, r"\frac{1}{2\\pi}\\int_{-\\infty}^{\\infty}e^{-x^2}dx") + + # 内联公式 + add_inline_math(paragraph, "其中 $E=mc^2$ 是质能方程") +""" + +from wordformat.math.omml import ( + add_display_math, + add_inline_math, + latex_to_omath, + latex_to_omath_para, + set_cell_math, +) + +__all__ = [ + "latex_to_omath", + "latex_to_omath_para", + "add_display_math", + "add_inline_math", + "set_cell_math", +] diff --git a/src/wordformat/math/omml.py b/src/wordformat/math/omml.py new file mode 100644 index 0000000..7ee3788 --- /dev/null +++ b/src/wordformat/math/omml.py @@ -0,0 +1,210 @@ +""" +OML Renderer — LaTeX math to OMML (Office Math Markup Language) for .docx files. + +Uses the pure-Python pipeline: + LaTeX -> latex2mathml -> MathML -> mathml2omml -> OMML +""" + +from __future__ import annotations + +import re + +import latex2mathml.converter +import mathml2omml +from docx.oxml import OxmlElement, parse_xml +from docx.oxml.ns import nsmap, qn + +# --------------------------------------------------------------------------- +# Constants +# --------------------------------------------------------------------------- + +MATH_FONT = "Cambria Math" +_DEFAULT_SZ = "22" + +# mathml2omml 输出使用 m: 前缀但未声明命名空间,解析时需包裹 root 声明 +_WRAPPED_MATH_NS = f'{{body}}' + + +# --------------------------------------------------------------------------- +# LaTeX → OMML pipeline +# --------------------------------------------------------------------------- + + +def _latex_to_omml_xml(latex: str) -> str | None: + """Convert a LaTeX math string to OMML XML string via MathML.""" + if not latex.strip(): + return None + try: + mathml = latex2mathml.converter.convert(latex) + except Exception: + return None + if not mathml: + return None + try: + return mathml2omml.convert(mathml) + except Exception: + return None + + +# --------------------------------------------------------------------------- +# Element builders (python-docx OxmlElement) +# --------------------------------------------------------------------------- + + +def _make_wrpr(): + """Build a w:rPr element with math font and size.""" + wrpr = OxmlElement("w:rPr") + rf = OxmlElement("w:rFonts") + rf.set(qn("w:ascii"), MATH_FONT) + rf.set(qn("w:hAnsi"), MATH_FONT) + wrpr.append(rf) + sz = OxmlElement("w:sz") + sz.set(qn("w:val"), _DEFAULT_SZ) + wrpr.append(sz) + return wrpr + + +def _make_ctrl_pr(): + """Build an m:ctrlPr containing w:rPr.""" + cp = OxmlElement("m:ctrlPr") + cp.append(_make_wrpr()) + return cp + + +def _post_process(omath) -> None: + """Fix up mathml2omml output to match Word/WPS expectations.""" + # Add w:rPr to every m:r that lacks it (WPS needs font/size on each run) + for mr in om_elements(omath, "m:r"): + if mr.find(qn("w:rPr")) is None: + wrpr = _make_wrpr() + mrpr = mr.find(qn("m:rPr")) + if mrpr is not None: + idx = list(mr).index(mrpr) + mr.insert(idx + 1, wrpr) + else: + mr.insert(0, wrpr) + + # Fix rad elements: add radPr, degHide, deg, ctrlPr as needed + for rad in om_elements(omath, "m:rad"): + radpr = rad.find(qn("m:radPr")) + if radpr is None: + radpr = OxmlElement("m:radPr") + rad.insert(0, radpr) + if radpr.find(qn("m:degHide")) is None and rad.find(qn("m:deg")) is None: + dg_hide = OxmlElement("m:degHide") + dg_hide.set(qn("m:val"), "1") + radpr.append(dg_hide) + if radpr.find(qn("m:ctrlPr")) is None: + radpr.append(_make_ctrl_pr()) + deg = rad.find(qn("m:deg")) + if deg is None: + deg = OxmlElement("m:deg") + deg.append(_make_ctrl_pr()) + rp_idx = list(rad).index(radpr) + rad.insert(rp_idx + 1, deg) + elif deg.find(qn("m:ctrlPr")) is None: + deg.append(_make_ctrl_pr()) + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def latex_to_omath(latex: str) -> OxmlElement | None: + """Parse a LaTeX math expression into an ``m:oMath`` OMML element. + + Returns ``None`` if the string is empty or parsing fails. + """ + omml_xml = _latex_to_omml_xml(latex) + if not omml_xml: + return None + try: + wrapped = _WRAPPED_MATH_NS.format(body=omml_xml) + tree = parse_xml(wrapped.encode("utf-8")) + omath = tree.find(qn("m:oMath")) + if omath is not None: + _post_process(omath) + return omath + except Exception: + return None + + +def latex_to_omath_para(latex: str) -> OxmlElement | None: + """Parse LaTeX into an ``m:oMathPara`` element (for display / block math).""" + om = latex_to_omath(latex) + if om is None: + return None + omathpara = OxmlElement("m:oMathPara") + omathpara.append(om) + return omathpara + + +def add_display_math(doc, latex: str): + """Add a centered display-math paragraph to a ``python-docx`` Document. + + The *latex* string should **not** contain ``$$`` delimiters. + Returns the new paragraph. + """ + from docx.enum.text import WD_ALIGN_PARAGRAPH + + p = doc.add_paragraph() + p.alignment = WD_ALIGN_PARAGRAPH.CENTER + ppr = p._p.find(qn("w:pPr")) + if ppr is not None: + ppr.append(_make_wrpr()) + om = latex_to_omath(latex) + if om is not None: + omathpara = OxmlElement("m:oMathPara") + omathpara.append(om) + p._p.append(omathpara) + else: + from docx.shared import Pt + + run = p.add_run(latex) + run.font.size = Pt(11) + return p + + +def add_inline_math(paragraph, text: str): + """Render text containing ``$...$`` inline-math segments into *paragraph*. + + Math segments are rendered as proper OMML ``m:oMath`` elements. + """ + parts = re.split(r"(\$[^$\n]+\$)", text) + for part in parts: + if part.startswith("$") and part.endswith("$"): + math = part[1:-1].strip() + om = latex_to_omath(math) + if om is not None: + paragraph._p.append(om) + else: + paragraph.add_run(math) + elif part: + paragraph.add_run(part) + + +def set_cell_math(cell, text: str): + """Render text with ``$...$`` / ``$$...$$`` math into a table cell.""" + p = cell.paragraphs[0] + p.clear() + stripped = text.strip() + if stripped.startswith("$$") and stripped.endswith("$$"): + math = stripped[2:-2].strip() + om = latex_to_omath(math) + if om is not None: + omathpara = OxmlElement("m:oMathPara") + omathpara.append(om) + p._p.append(omathpara) + else: + p.add_run(math) + else: + add_inline_math(p, text) + + +def om_elements(element, tag: str): + """Iterate over descendant elements matching *tag* (e.g. ``m:r``, ``m:rad``). + + Uses pre-resolved qn for performance. + """ + return element.iter(qn(tag)) diff --git a/src/wordformat/pipeline/stages_md.py b/src/wordformat/pipeline/stages_md.py index 1207edc..7d0571b 100644 --- a/src/wordformat/pipeline/stages_md.py +++ b/src/wordformat/pipeline/stages_md.py @@ -15,6 +15,7 @@ from wordformat.log_config import logger from wordformat.markdown.parser import parse_markdown +from wordformat.math.omml import latex_to_omath, latex_to_omath_para from wordformat.rules.node import FormatNode from .context import FormatContext @@ -57,16 +58,61 @@ def process(self, ctx: FormatContext) -> FormatContext: value = node.value text = value.get("paragraph", "") if isinstance(value, dict) else str(value) category = value.get("category", "") if isinstance(value, dict) else "" + inline_marks = ( + value.get("inline_marks", []) if isinstance(value, dict) else [] + ) para = ctx.document.add_paragraph() # figure_image: 路径留给 FigureImage._try_insert_image 处理,这里只建空段落 if category == "figure_image": para.add_run("") + elif category == "math_block": + self._add_math_paragraph(para, text) + elif self._has_math(inline_marks): + self._add_math_runs(para, inline_marks) else: para.add_run(text.strip()) node.paragraph = para return ctx + @staticmethod + def _has_math(inline_marks: list[dict]) -> bool: + return any(m.get("math") for m in inline_marks) + + @staticmethod + def _add_math_paragraph(para, latex: str): + """创建块级公式段落(居中显示)。""" + from docx.enum.text import WD_ALIGN_PARAGRAPH + + para.alignment = WD_ALIGN_PARAGRAPH.CENTER + omp = latex_to_omath_para(latex) + if omp is not None: + para._p.append(omp) + else: + para.add_run(latex) + + @staticmethod + def _add_math_runs(para, inline_marks: list[dict]): + """根据 inline_marks 创建段落,数学片段渲染为 OMML 元素。""" + for seg in inline_marks: + if seg.get("math"): + latex = seg.get("text", "") + if seg.get("math_display"): + # 块级公式在段落中:使用 oMathPara + omp = latex_to_omath_para(latex) + if omp is not None: + para._p.append(omp) + else: + para.add_run(latex) + else: + om = latex_to_omath(latex) + if om is not None: + para._p.append(om) + else: + para.add_run(latex) + else: + para.add_run(seg.get("text", "")) + @staticmethod def _flatten_tree_nodes(root_node: FormatNode) -> list[FormatNode]: result: list[FormatNode] = [] diff --git a/tests/markdown/test_parser.py b/tests/markdown/test_parser.py index 02686e2..95b072f 100644 --- a/tests/markdown/test_parser.py +++ b/tests/markdown/test_parser.py @@ -203,3 +203,47 @@ def test_linebreak_in_paragraph(self): result = parse_markdown("第一行 \n第二行") text = result[0]["paragraph"] assert "\n" in text # linebreak preserved + + +class TestMathParsing: + """测试 Markdown 中数学公式的解析。""" + + def test_inline_math_segment(self): + result = parse_markdown("质能方程 $E=mc^2$ 是著名的") + assert len(result) == 1 + assert result[0]["category"] == "body_text" + marks = result[0]["inline_marks"] + math_segs = [m for m in marks if m.get("math")] + assert len(math_segs) == 1 + assert math_segs[0]["text"] == "E=mc^2" + + def test_inline_math_preserved_in_text(self): + result = parse_markdown("The value $x^2$ is squared.") + text = result[0]["paragraph"] + assert "$x^2$" in text + + def test_standalone_display_math_block(self): + result = parse_markdown("$$\n\\frac{1}{2}\n$$") + assert len(result) == 1 + assert result[0]["category"] == "math_block" + assert result[0]["paragraph"] == "\\frac{1}{2}" + + def test_display_math_in_paragraph_creates_display_segment(self): + """行内 $$...$$ 也应被识别""" + result = parse_markdown("公式 $$a^2 + b^2 = c^2$$ 是勾股定理") + marks = result[0]["inline_marks"] + math_segs = [m for m in marks if m.get("math_display")] + assert len(math_segs) >= 1 + + def test_multiple_inline_math_in_one_paragraph(self): + result = parse_markdown("$x_1$ 和 $x_2$ 都是变量") + marks = result[0]["inline_marks"] + math_segs = [m for m in marks if m.get("math")] + assert len(math_segs) == 2 + + def test_math_with_subscripts(self): + result = parse_markdown("权重 $w_{ij}$ 的计算公式如下。") + marks = result[0]["inline_marks"] + math_segs = [m for m in marks if m.get("math")] + assert len(math_segs) == 1 + assert math_segs[0]["text"] == "w_{ij}" diff --git a/tests/math/test_omml.py b/tests/math/test_omml.py new file mode 100644 index 0000000..7226d87 --- /dev/null +++ b/tests/math/test_omml.py @@ -0,0 +1,170 @@ +#! /usr/bin/env python +"""wordformat.math 模块单元测试。""" + +import pytest + +from wordformat.math import ( + add_display_math, + add_inline_math, + latex_to_omath, + latex_to_omath_para, + set_cell_math, +) + + +class TestLatexToOmath: + """测试 LaTeX → OMML 核心转换。""" + + def test_simple_expression(self): + om = latex_to_omath(r"x^2 + y^2") + assert om is not None + assert om.tag.endswith("}oMath") + + def test_fraction(self): + om = latex_to_omath(r"\frac{1}{2}") + assert om is not None + + def test_sqrt(self): + om = latex_to_omath(r"\sqrt{x^2 + y^2}") + assert om is not None + + def test_sum(self): + om = latex_to_omath(r"\sum_{i=1}^{n} x_i") + assert om is not None + + def test_greek_letters(self): + om = latex_to_omath(r"\alpha + \beta = \gamma") + assert om is not None + + def test_subscript_and_superscript(self): + om = latex_to_omath(r"x_i^2 + w_j^{(k)}") + assert om is not None + + def test_matrix(self): + om = latex_to_omath(r"\begin{matrix} a & b \\ c & d \end{matrix}") + assert om is not None + + def test_empty_string(self): + assert latex_to_omath("") is None + assert latex_to_omath(" ") is None + + def test_invalid_latex(self): + # Invalid LaTeX should return None rather than crash + result = latex_to_omath(r"\invalid_command_xyz{") + # Should not raise, may return None or partial result + assert result is None or result is not None + + def test_aligned_equation(self): + om = latex_to_omath(r"\begin{aligned} x &= 1 \\ y &= 2 \end{aligned}") + # aligned environment may not be supported by all backends + # should not raise, even if result is None + assert om is None or om is not None + + +class TestLatexToOmathPara: + """测试块级公式转换。""" + + def test_wraps_in_omathpara(self): + omp = latex_to_omath_para(r"x = \frac{1}{2}") + assert omp is not None + assert "oMathPara" in omp.tag + + def test_empty_returns_none(self): + assert latex_to_omath_para("") is None + + +class TestAddDisplayMath: + """测试添加块级公式到文档。""" + + def test_adds_paragraph(self): + from docx import Document + + doc = Document() + p = add_display_math(doc, r"x^2 + y^2 = z^2") + assert p is not None + # 段落中应包含 OMML 元素 + omath = p._p.find(".//{http://schemas.openxmlformats.org/officeDocument/2006/math}oMath") + assert omath is not None + + def test_empty_latex_adds_text_run(self): + from docx import Document + + doc = Document() + p = add_display_math(doc, "") + assert p is not None + assert len(p.runs) >= 1 + + +class TestAddInlineMath: + """测试内联公式渲染。""" + + def test_single_inline_math(self): + from docx import Document + + doc = Document() + p = doc.add_paragraph() + add_inline_math(p, "The value is $x^2$ here.") + # 应有 text run 和 oMath 元素 + runs = p.runs + assert len(runs) >= 1 + + def test_multiple_inline_math(self): + from docx import Document + + doc = Document() + p = doc.add_paragraph() + add_inline_math(p, "$a$ and $b$ and $c$") + # Should not crash with multiple formulas + assert p._p is not None + + def test_no_math(self): + from docx import Document + + doc = Document() + p = doc.add_paragraph() + add_inline_math(p, "Plain text no formula.") + assert len(p.runs) == 1 + assert p.runs[0].text == "Plain text no formula." + + def test_invalid_math_falls_back_to_text(self): + from docx import Document + + doc = Document() + p = doc.add_paragraph() + add_inline_math(p, r"$\invalid\command{}$") + # Should not raise + assert p._p is not None + + +class TestSetCellMath: + """测试表格单元格数学公式。""" + + def test_display_math_in_cell(self): + from docx import Document + + doc = Document() + table = doc.add_table(rows=1, cols=1) + cell = table.cell(0, 0) + cell.paragraphs[0].add_run("placeholder") # ensure runs exist + set_cell_math(cell, r"$$x^2 + y^2$$") + assert cell.paragraphs[0]._p is not None + + def test_inline_math_in_cell(self): + from docx import Document + + doc = Document() + table = doc.add_table(rows=1, cols=1) + cell = table.cell(0, 0) + cell.paragraphs[0].add_run("placeholder") + set_cell_math(cell, r"The value is $E=mc^2$.") + assert cell.paragraphs[0]._p is not None + + def test_plain_text_in_cell(self): + from docx import Document + + doc = Document() + table = doc.add_table(rows=1, cols=1) + cell = table.cell(0, 0) + cell.paragraphs[0].add_run("placeholder") + set_cell_math(cell, "No math here.") + assert len(cell.paragraphs[0].runs) >= 1 From 6fe4049b959f0a9ca4c8d80266aca597c3a22625 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Mon, 13 Jul 2026 14:47:31 +0800 Subject: [PATCH 12/12] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=20math=5Fblock=20?= =?UTF-8?q?=E4=B8=A2=E5=A4=B1=20+=20=E6=B8=85=E7=90=86=E4=B8=8D=E5=AD=98?= =?UTF-8?q?=E5=9C=A8=E7=9A=84=20[api]=20extra=20=E5=BC=95=E7=94=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 MathBlock FormatNode (@register("math_block")),避免 node_factory 跳过 - tree_builder 将 math_block 排除在 heading 之外,防止嵌套吸收后续段落 - 移除 README/CLAUDE.md/docs 中所有 wordformat[api] 引用(extra 不存在) --- CLAUDE.md | 5 ++--- README.md | 9 +++------ docs/installation.md | 13 +++---------- docs/usage.md | 5 ++--- src/wordformat/rules/__init__.py | 2 ++ src/wordformat/rules/math.py | 20 ++++++++++++++++++++ src/wordformat/structure/tree_builder.py | 10 +++++++--- 7 files changed, 39 insertions(+), 25 deletions(-) create mode 100644 src/wordformat/rules/math.py diff --git a/CLAUDE.md b/CLAUDE.md index 563bbf6..178fb13 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,10 +57,9 @@ pre-commit run --all-files ## Install variants ```bash -pip install -e "." # core only -pip install -e ".[api]" # core + FastAPI server +pip install -e "." # core (includes FastAPI server) pip install -e ".[test]" # core + pytest plugins -pip install -e ".[dev]" # everything (api, test, pre-commit, ruff, pyinstaller) +pip install -e ".[dev]" # everything (test, pre-commit, ruff, pyinstaller) ``` ## Pre-commit hooks diff --git a/README.md b/README.md index 0349c2c..e6a417b 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,7 @@ pip install pipx # 其他平台 # 安装 WordFormat pipx install wordformat -# 需要 Web 界面时安装 api 版本 -pipx install "wordformat[api]" +pipx install wordformat ``` 安装完成后直接使用 `wordf` 或 `wordformat` 命令。 @@ -64,8 +63,6 @@ pipx install "wordformat[api]" ```bash pip install wordformat -# 或 -pip install "wordformat[api]" ``` > 安装后如果提示"找不到 wordf 命令",说明 Python 脚本目录不在系统 PATH 中。 @@ -123,8 +120,8 @@ wordf md -d 论文.md -c 配置.yaml ### startapi命令行预览(推荐使用) ```bash -# 下载api版 -pip install "wordformat[api]" +# 安装 +pip install wordformat # 启动 Web 可视化界面 wordf startapi # 访问 http://127.0.0.1:8000 diff --git a/docs/installation.md b/docs/installation.md index 43579d0..71057fc 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -19,13 +19,7 @@ pip install wordformat uv pip install wordformat ``` -安装完成后即可使用 `wordf` 和 `wordformat` 命令。 - -如需 API 服务功能,安装 api 可选依赖: - -```bash -pip install wordformat[api] -``` +安装完成后即可使用 `wordf` 和 `wordformat` 命令(含 API 服务)。 ### 方式二:从源码安装(推荐开发者) @@ -72,9 +66,8 @@ wordf --help | 依赖组 | 安装命令 | 包含内容 | |--------|----------|----------| -| `api` | `pip install wordformat[api]` | FastAPI、uvicorn(API 服务) | | `test` | `pip install wordformat[test]` | pytest(测试框架) | -| `dev` | `pip install wordformat[dev]` | 以上全部 + ruff、pre-commit、pyinstaller | +| `dev` | `pip install wordformat[dev]` | test + ruff、pre-commit、pyinstaller | ## 故障排查 @@ -92,5 +85,5 @@ wordf --help ### API 服务启动失败 -- 确保已安装 api 可选依赖:`pip install wordformat[api]` +- 确保已安装 wordformat:`pip install wordformat` - 检查端口未被其他服务占用 diff --git a/docs/usage.md b/docs/usage.md index 3ea632e..b17da11 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -186,8 +186,7 @@ wordf af -d your_document.docx -c example/undergrad_thesis.yaml -f output/论文 ### 启动方式 ```bash -# 使用 pip 安装时带上 api 依赖 -pip install "wordformat[api]" +pip install wordformat # 默认启动(127.0.0.1:8000,仅本机可访问) wordf startapi @@ -351,7 +350,7 @@ auto_format_thesis_document( make server # 或直接使用 uvicorn -pip install -e ".[api]" +pip install -e "." python start_api.py ``` diff --git a/src/wordformat/rules/__init__.py b/src/wordformat/rules/__init__.py index 902c3d9..a2dd6b2 100644 --- a/src/wordformat/rules/__init__.py +++ b/src/wordformat/rules/__init__.py @@ -16,6 +16,7 @@ from .caption import CaptionFigure, CaptionTable from .heading import HeadingLevel1Node, HeadingLevel2Node, HeadingLevel3Node from .keywords import KeywordsCN, KeywordsEN +from .math import MathBlock from .node import FormatNode from .object import FigureImage, TableObject from .references import ReferenceEntry, References @@ -29,6 +30,7 @@ "AbstractContentEN", "KeywordsEN", "KeywordsCN", + "MathBlock", "Acknowledgements", "AcknowledgementsCN", "BodyText", diff --git a/src/wordformat/rules/math.py b/src/wordformat/rules/math.py new file mode 100644 index 0000000..0dd8d40 --- /dev/null +++ b/src/wordformat/rules/math.py @@ -0,0 +1,20 @@ +#! /usr/bin/env python +"""数学公式节点。公式本身由 OMML 渲染,Node 仅占位确保不被跳过。""" + +from wordformat.config.dotdict import BASE_FORMAT, deep_merge +from wordformat.rules.node import FormatNode +from wordformat.structure.registry import register + + +@register("math_block", level=5) +class MathBlock(FormatNode): + """块级公式""" + + NODE_TYPE = "math.block" + NODE_LABEL = "块级公式" + DEFAULTS = deep_merge( + BASE_FORMAT, + { + "paragraph": {"alignment": "居中对齐"}, + }, + ) diff --git a/src/wordformat/structure/tree_builder.py b/src/wordformat/structure/tree_builder.py index 713288b..5f2559b 100644 --- a/src/wordformat/structure/tree_builder.py +++ b/src/wordformat/structure/tree_builder.py @@ -8,13 +8,17 @@ from wordformat.structure.settings import CATEGORY_TO_CLASS, LEVEL_MAP from wordformat.tree import Stack +_NON_HEADING = {"body_text", "math_block", "figure_image", "table_object"} + +_HEADING_CATEGORIES = { + k: v for k, v in CATEGORY_TO_CLASS.items() if k not in _NON_HEADING +} + class DocumentTreeBuilder: """负责将扁平列表构建成层级树结构""" - HEADING_CATEGORIES = { - k: v for k, v in CATEGORY_TO_CLASS.items() if k != "body_text" - } + HEADING_CATEGORIES = _HEADING_CATEGORIES CONFIG = {} def __init__(self):