From a8a13dc7727ea24f6210630e4f52d2094ca29289 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Tue, 16 Jun 2026 15:53:04 +0800 Subject: [PATCH 1/9] =?UTF-8?q?feat:=20=E5=A3=B0=E6=98=8E=E5=BC=8F?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=20=E2=80=94=20=E4=B8=9A?= =?UTF-8?q?=E5=8A=A1=E6=A0=A1=E9=AA=8C=E7=94=B1=E9=85=8D=E7=BD=AE=E9=A9=B1?= =?UTF-8?q?=E5=8A=A8=E3=80=81=E6=A1=86=E6=9E=B6=E8=87=AA=E5=8A=A8=E8=B0=83?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增 FormatNode.RULES + _run_rules() 机制,开发者只需注册 handler 并专注 逻辑判断,是否生效由 YAML 配置的 enabled 字段控制。KeywordsCN/KeywordsEN 的 关键词数量和末尾标点校验、CaptionFigure/CaptionTable 的题注编号校验已接入。 - 新增 BaseRuleConfig 基类、KeywordCountRule、TrailingPunctRule、 CaptionRulesConfig、KeywordsRulesConfig 等规则模型 - FormatNode 新增 RULES 字典和 _run_rules(doc, p) 自动调度方法 - _show_config() 改为从 Pydantic 模型递归生成,新增字段自动反映 - wordf config -o 导出 YAML 自动包含深层 rules 配置 - SKILL.md 新增纯文本替换流程,避免 AI 误将文本替换执行成格式化 - 同步更新 docs/、config_spec.md、validate_config.py 的字段白名单 --- docs/configuration.md | 56 ++++-- example/undergrad_thesis.yaml | 21 ++- src/wordformat/cli.py | 188 +++++++++++--------- src/wordformat/config/datamodel.py | 95 +++++++--- src/wordformat/rules/caption.py | 38 ++-- src/wordformat/rules/keywords.py | 111 ++++++------ src/wordformat/rules/node.py | 48 ++++- src/wordformat/set_style.py | 2 - tests/conftest.py | 16 +- tests/test_caption_numbering.py | 12 +- tests/test_integration.py | 21 +-- tests/test_rules.py | 12 +- wordformat-skill/SKILL.md | 28 ++- wordformat-skill/data/config_spec.md | 49 ++++- wordformat-skill/scripts/validate_config.py | 9 +- 15 files changed, 460 insertions(+), 246 deletions(-) diff --git a/docs/configuration.md b/docs/configuration.md index d1e8df2..76518e0 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -91,9 +91,14 @@ abstract: chinese_font_name: '黑体' font_size: '四号' bold: true - count_min: 3 - count_max: 5 - trailing_punct_forbidden: true + rules: # 业务规则(均通过 enabled 开关控制) + keyword_count: + enabled: true + count_min: 3 + count_max: 5 + trailing_punctuation: + enabled: true + forbidden_chars: ";,。、" english: <<: *global_format alignment: '左对齐' @@ -103,9 +108,11 @@ abstract: <<: *global_format font_size: '四号' bold: true - count_min: 3 - count_max: 5 - trailing_punct_forbidden: true + rules: + keyword_count: + enabled: true + count_min: 3 + count_max: 5 ``` ### 4. 各级标题格式(headings) @@ -151,25 +158,28 @@ body_text: ``` ### 6. 插图格式(figures) -配置图片及其题注的格式,题注默认位于图片下方。 +配置图片及其题注的格式和编号规则。 ```yaml figures: <<: *global_format - caption_position: 'below' caption_prefix: '图' font_size: '五号' builtin_style_name: '题注' alignment: '居中对齐' first_line_indent: '0字符' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false ``` ### 7. 表格格式(tables) -配置表格题注及表格内容(单元格内文字)的格式。题注默认位于表格上方,`content` 子配置控制表格内文字。 +配置表格题注及表格内容(单元格内文字)的格式和编号规则。`content` 子配置控制表格内文字。 ```yaml tables: <<: *global_format - caption_position: 'above' caption_prefix: '表' font_size: '五号' builtin_style_name: '题注' @@ -185,6 +195,11 @@ tables: first_line_indent: '0字符' space_before: "0行" space_after: "0行" + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false ``` ### 8. 参考文献格式(references) @@ -313,19 +328,26 @@ numbering: ### 关键词专用字段 | 配置项 | 说明 | 取值 | |-------|------|--------| -| label | 关键词标签("关键词:" / "Keywords:")的字符格式 | 子配置(继承 global_format 的 15 个字段) | +| label | 关键词标签("关键词:" / "Keywords:")的字符格式 | 子配置(继承 global_format 的 16 个字段) | | label.bold | 标签是否加粗 | true/false | | label.chinese_font_name | 标签中文字体 | 宋体、黑体 等 | | label.font_size | 标签字号 | 四号、小四 等 | -| count_min | 最少关键词数量 | 正整数 | -| count_max | 最多关键词数量 | 正整数 | -| trailing_punct_forbidden | 是否禁止末尾标点 | true/false | - -### 图表专用字段 +| rules.keyword_count | 关键词数量校验规则 | 子配置 | +| rules.keyword_count.enabled | 是否启用数量校验 | true/false | +| rules.keyword_count.count_min | 最少关键词数量 | 正整数 | +| rules.keyword_count.count_max | 最多关键词数量 | 正整数 | +| rules.trailing_punctuation | 末尾标点校验规则(仅中文) | 子配置 | +| rules.trailing_punctuation.enabled | 是否启用末尾标点校验 | true/false | +| rules.trailing_punctuation.forbidden_chars | 禁止出现在末尾的标点 | 字符串 | + +### 题注专用字段 | 配置项 | 说明 | 可选值 | |-------|------|--------| -| caption_position | 题注位置 | above(上方)、below(下方) | | caption_prefix | 题注前缀 | 图、表 | +| rules.caption_numbering | 题注编号校验/修正规则 | 子配置 | +| rules.caption_numbering.enabled | 是否启用编号校验 | true/false | +| rules.caption_numbering.separator | 章节号与编号间分隔符 | . - : — – | +| rules.caption_numbering.label_number_space | 标签与编号间是否加空格 | true/false | ### 参考文献专用字段 | 配置项 | 说明 | 示例 | diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml index d7f321b..0761d84 100644 --- a/example/undergrad_thesis.yaml +++ b/example/undergrad_thesis.yaml @@ -112,10 +112,15 @@ abstract: chinese_font_name: '黑体' font_size: '四号' bold: true - # -- 关键词数量和标点校验 -- - count_min: 3 # 最少关键词数 - count_max: 5 # 最多关键词数 - trailing_punct_forbidden: true # 中文关键词末尾是否禁止标点 + # -- 关键词业务规则(均通过 enabled 开关控制) -- + rules: + keyword_count: + enabled: true + count_min: 3 # 最少关键词数 + count_max: 5 # 最多关键词数 + trailing_punctuation: + enabled: true + forbidden_chars: ";,。、" # 中文关键词末尾禁止出现的标点 english: <<: *global_format @@ -126,9 +131,11 @@ abstract: <<: *global_format font_size: '四号' bold: true - count_min: 3 - count_max: 5 - trailing_punct_forbidden: true + rules: + keyword_count: + enabled: true + count_min: 3 + count_max: 5 # =========================================================================== # 各级标题(章、节、小节) diff --git a/src/wordformat/cli.py b/src/wordformat/cli.py index 201de8e..43fc021 100644 --- a/src/wordformat/cli.py +++ b/src/wordformat/cli.py @@ -36,103 +36,123 @@ def validate_file( def _show_config(): - """输出所有可配置字段及其说明""" - from wordformat.config.datamodel import ( - CaptionNumberingConfig, - GlobalFormatConfig, - KeywordsConfig, - NumberingConfig, - NumberingLevelConfig, - WarningFieldConfig, - ) + """根据 NodeConfigRoot Pydantic 模型自动生成所有可配置字段的参考说明。 - def _fields(cls): - return [(n, i) for n, i in cls.model_fields.items()] + 递归遍历模型结构,无需手动维护字段列表。新增/删除配置字段时自动反映。 + """ + from pydantic import BaseModel - def _desc(info): - return (info.description or "").replace("\n", " ") + from wordformat.config.datamodel import NodeConfigRoot - def _default(info): + def _default_str(info): if info.default is not None: return repr(info.default) if info.default_factory is not None: return "(子配置)" return "—" - def _print_fields(fields, indent=2): - prefix = " " * indent - for name, info in fields: - print(f"{prefix}{name:<28s} {_desc(info):<40s} 默认: {_default(info)}") - - gf = _fields(GlobalFormatConfig) - wf = _fields(WarningFieldConfig) - kw_extra = [ - (n, i) for n, i in _fields(KeywordsConfig) if n not in {f[0] for f in gf} - ] - nc = _fields(NumberingConfig) - ncp = _fields(CaptionNumberingConfig) - nl = _fields(NumberingLevelConfig) - - # 段落节点名列表(从 NodeConfigRoot 直接读) - paragraph_nodes = [ - ("global_format", "全局基础格式"), - ("abstract.chinese.chinese_title", "中文摘要标题"), - ("abstract.chinese.chinese_content", "中文摘要正文"), - ("abstract.english.english_title", "英文摘要标题"), - ("abstract.english.english_content", "英文摘要正文"), - ("abstract.keywords.chinese", "中文关键词"), - ("abstract.keywords.english", "英文关键词"), - ("headings.level_1", "一级标题"), - ("headings.level_2", "二级标题"), - ("headings.level_3", "三级标题"), - ("body_text", "正文"), - ("figures", "图注"), - ("tables", "表注"), - ("references.title", "参考文献标题"), - ("references.content", "参考文献内容"), - ("acknowledgements.title", "致谢标题"), - ("acknowledgements.content", "致谢内容"), - ] - - # 每个节点的额外字段 - extras = { - "figures": [("caption_prefix", "图注编号前缀", "'图'")], - "tables": [ - ("caption_prefix", "表注编号前缀", "'表'"), - ("content", "表格内容格式(子配置,字段同 global_format)", "(子配置)"), - ], - "abstract.keywords.chinese": [(n, _desc(i), _default(i)) for n, i in kw_extra], - "abstract.keywords.english": [(n, _desc(i), _default(i)) for n, i in kw_extra], - } - - print("config.yaml 完整字段参考\n") - - for path, label in paragraph_nodes: - print(f"[{path}] — {label}") - _print_fields(gf) - for name, desc, default in extras.get(path, []): - print(f" {name:<28s} {desc:<40s} 默认: {default}") - print() - - print("[style_checks_warning] — 格式警告开关") - _print_fields(wf) - print() + def _desc(info): + return (info.description or "").replace("\n", " ") - print("[numbering] — 自动编号总开关(仅 wordf af 模式生效)") - for n, i in nc: - if n not in ("level_1", "level_2", "level_3", "references", "captions"): - print(f" {n:<28s} {_desc(i):<40s} 默认: {_default(i)}") - print() + # 已知的基础类型名称,遇到这些就停止递进 + LEAF_TYPES = { + "GlobalFormatConfig", + "BodyTextConfig", + "HeadingLevelConfig", + "AbstractTitleConfig", + "AbstractContentConfig", + "KeywordLabelConfig", + "KeywordsConfig", + "ReferencesTitleConfig", + "ReferencesContentConfig", + "AcknowledgementsTitleConfig", + "AcknowledgementsContentConfig", + "TableContentConfig", + "WarningFieldConfig", + "CaptionNumberingConfig", + "NumberingLevelConfig", + "KeywordCountRule", + "TrailingPunctRule", + "KeywordsRulesConfig", + "CaptionRulesConfig", + } - print("[numbering.captions] — 题注编号") - _print_fields(ncp) + def _resolve_type(annotation): + """解析 typing 注解,返回实际类型。""" + origin = getattr(annotation, "__origin__", None) + if origin is dict: + args = getattr(annotation, "__args__", ()) + if len(args) == 2: + return _resolve_type(args[1]) + return None + args = getattr(annotation, "__args__", None) + if args: + non_none = [a for a in args if a is not type(None)] + if non_none: + return _resolve_type(non_none[0]) + return None + if isinstance(annotation, type): + return annotation + return None + + def _is_basemodel_subclass(t): + return isinstance(t, type) and issubclass(t, BaseModel) + + def _walk(cls, path="", depth=0): + """递归打印模型字段。""" + for name, info in cls.model_fields.items(): + field_path = f"{path}.{name}" if path else name + target = _resolve_type(info.annotation) + desc_text = _desc(info) + + if target is None: + continue + + target_name = getattr(target, "__name__", "") + + if _is_basemodel_subclass(target) and target_name not in LEAF_TYPES: + # 容器模型:打印节标题后递归 + label = desc_text or target_name + print(f"\n{' ' * depth}[{field_path}] — {label}") + _walk(target, field_path, depth + 1) + elif _is_basemodel_subclass(target) and target_name in LEAF_TYPES: + # 叶子配置模型:展开打印字段,不递归 + label = desc_text or target_name + print(f"\n{' ' * depth}[{field_path}] — {label}") + _print_leaf_fields(target, depth + 1) + elif target is str: + print( + f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}" + ) + elif target is bool: + print( + f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}" + ) + elif target is int: + print( + f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}" + ) + elif target is float: + print( + f"{' ' * depth} {name:<30s} {desc_text:<50s} 默认: {_default_str(info)}" + ) + + def _print_leaf_fields(cls, depth): + for name, info in cls.model_fields.items(): + target = _resolve_type(info.annotation) + if _is_basemodel_subclass(target): + # 嵌套的叶子模型(如 label, content) + print(f"{' ' * depth} [{name}] — {_desc(info)}") + _print_leaf_fields(target, depth + 1) + else: + print( + f"{' ' * depth} {name:<28s} {_desc(info):<50s} 默认: {_default_str(info)}" + ) + + print("config.yaml 完整字段参考(自动生成自 NodeConfigRoot 模型)\n") + _walk(NodeConfigRoot) print() - for key in ("level_1", "level_2", "level_3", "references"): - print(f"[numbering.{key}] — 编号配置") - _print_fields(nl) - print() - def main(): from wordformat.log_config import setup_logger diff --git a/src/wordformat/config/datamodel.py b/src/wordformat/config/datamodel.py index c15cb13..751344b 100644 --- a/src/wordformat/config/datamodel.py +++ b/src/wordformat/config/datamodel.py @@ -47,6 +47,15 @@ class WarningFieldConfig(BaseModel): builtin_style_name: bool = Field(default=True, description="内置样式名称") +# -------------------------- 规则引擎基类 -------------------------- + + +class BaseRuleConfig(BaseModel): + """所有业务规则的基类,enabled 控制规则开关。""" + + enabled: bool = Field(default=True, description="是否启用此规则") + + # -------------------------- 基础配置模型 -------------------------- class GlobalFormatConfig(BaseModel): """全局基础格式配置模型""" @@ -99,26 +108,22 @@ class KeywordLabelConfig(GlobalFormatConfig): """关键词标签格式配置("关键词:"或"Keywords:"部分的字符格式)""" -class KeywordsConfig(GlobalFormatConfig): - """关键词配置模型(继承全局格式,用于关键词内容部分)""" +class KeywordCountRule(BaseRuleConfig): + """关键词数量校验规则""" - label: KeywordLabelConfig = Field( - default_factory=KeywordLabelConfig, description="关键词标签的字符格式" - ) count_min: int = Field(default=4, description="最小关键字数") - count_max: int = Field(default=4, description="最大关键字数") - trailing_punct_forbidden: bool = Field(default=True, description="禁止最后有标点") + count_max: int = Field(default=6, description="最大关键字数") @field_validator("count_min", "count_max") @classmethod - def validate_keyword_count(cls, v, info): + def validate_keyword_count(cls, v): """验证关键词数量为正整数""" if v <= 0: raise ValueError(f"关键词数量 {v} 必须大于0") return v @model_validator(mode="after") - def validate_count_range(self) -> "KeywordsConfig": + def validate_count_range(self) -> "KeywordCountRule": """验证 count_min <= count_max""" if self.count_min > self.count_max: raise ValueError( @@ -127,6 +132,32 @@ def validate_count_range(self) -> "KeywordsConfig": return self +class TrailingPunctRule(BaseRuleConfig): + """关键词末尾标点校验规则""" + + forbidden_chars: str = Field( + default=";,。、", description="禁止出现在关键词末尾的标点" + ) + + +class KeywordsRulesConfig(BaseModel): + """关键词业务规则集合 —— 所有规则均可通过 enabled 开关控制""" + + keyword_count: KeywordCountRule = Field(default_factory=KeywordCountRule) + trailing_punctuation: TrailingPunctRule = Field(default_factory=TrailingPunctRule) + + +class KeywordsConfig(GlobalFormatConfig): + """关键词配置模型(继承全局格式,用于关键词内容部分)""" + + label: KeywordLabelConfig = Field( + default_factory=KeywordLabelConfig, description="关键词标签的字符格式" + ) + rules: KeywordsRulesConfig = Field( + default_factory=KeywordsRulesConfig, description="关键词业务规则配置" + ) + + class AbstractTitleConfig(GlobalFormatConfig): """摘要标题配置(继承全局格式)""" @@ -201,11 +232,39 @@ class BodyTextConfig(GlobalFormatConfig): """正文配置(继承全局格式)""" +# -------------------------- 题注编号配置模型 -------------------------- +class CaptionNumberingConfig(BaseRuleConfig): + """题注编号校验/修正配置模型""" + + enabled: bool = Field(default=False, description="是否启用题注编号校验/修正") + separator: str = Field( + default=".", + description="章节号与题注编号间的分隔符,如 . - : — –", + ) + label_number_space: bool = Field( + default=False, + description="标签与编号之间是否加空格(图 1.1 vs 图1.1)", + ) + + +# -------------------------- 题注规则集合 -------------------------- +class CaptionRulesConfig(BaseModel): + """题注业务规则集合""" + + caption_numbering: CaptionNumberingConfig = Field( + default_factory=CaptionNumberingConfig, + description="题注编号校验/修正规则", + ) + + # -------------------------- 插图配置模型 -------------------------- class FiguresConfig(GlobalFormatConfig): """插图配置""" caption_prefix: Optional[str] = Field(default="图", description="图注编号前缀") + rules: CaptionRulesConfig = Field( + default_factory=CaptionRulesConfig, description="题注业务规则配置" + ) # -------------------------- 表格配置模型 -------------------------- @@ -220,6 +279,9 @@ class TablesConfig(GlobalFormatConfig): content: TableContentConfig = Field( default_factory=TableContentConfig, description="表格内容格式" ) + rules: CaptionRulesConfig = Field( + default_factory=CaptionRulesConfig, description="题注业务规则配置" + ) # -------------------------- 参考文献配置模型 -------------------------- @@ -264,21 +326,6 @@ class AcknowledgementsConfig(BaseModel): ) -# -------------------------- 题注编号配置模型 -------------------------- -class CaptionNumberingConfig(BaseModel): - """题注编号校验/修正配置模型""" - - enabled: bool = Field(default=False, description="是否启用题注编号校验/修正") - separator: str = Field( - default=".", - description="章节号与题注编号间的分隔符,如 . - : — –", - ) - label_number_space: bool = Field( - default=False, - description="标签与编号之间是否加空格(图 1.1 vs 图1.1)", - ) - - # -------------------------- 编号配置模型 -------------------------- class NumberingLevelConfig(BaseModel): """单级标题编号配置""" diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py index c7f9294..481c61b 100644 --- a/src/wordformat/rules/caption.py +++ b/src/wordformat/rules/caption.py @@ -103,6 +103,15 @@ class CaptionFigure(FormatNode[FiguresConfig]): NODE_TYPE = "figures" CONFIG_MODEL = FiguresConfig CONFIG_PATH = "figures" + RULES = {"caption_numbering": "_handle_caption_numbering"} + + def _handle_caption_numbering(self, doc, rule_cfg: CaptionNumberingConfig, p: bool): + """题注编号校验/修正""" + prefix = self.pydantic_config.caption_prefix or "图" + if p: + _check_caption_numbering(self, doc, prefix, rule_cfg) + else: + _apply_caption_numbering(self, prefix, rule_cfg) def _base(self, doc, p: bool, r: bool): cfg = self.pydantic_config @@ -143,16 +152,6 @@ def _base(self, doc, p: bool, r: bool): text=ParagraphStyle.to_string(paragraph_issues), ) - # 题注编号校验/修正(仅当 value 为 dict 且有 _numbering_cfg 时才执行) - if isinstance(self.value, dict): - numbering_cfg = self.value.get("_numbering_cfg") - if numbering_cfg and numbering_cfg.enabled: - prefix = cfg.caption_prefix or "图" - if p: - _check_caption_numbering(self, doc, prefix, numbering_cfg) - else: - _apply_caption_numbering(self, prefix, numbering_cfg) - class CaptionTable(FormatNode[TablesConfig]): """题注-表格""" @@ -160,6 +159,15 @@ class CaptionTable(FormatNode[TablesConfig]): NODE_TYPE = "tables" CONFIG_MODEL = TablesConfig CONFIG_PATH = "tables" + RULES = {"caption_numbering": "_handle_caption_numbering"} + + def _handle_caption_numbering(self, doc, rule_cfg: CaptionNumberingConfig, p: bool): + """题注编号校验/修正""" + prefix = self.pydantic_config.caption_prefix or "表" + if p: + _check_caption_numbering(self, doc, prefix, rule_cfg) + else: + _apply_caption_numbering(self, prefix, rule_cfg) def _base(self, doc, p: bool, r: bool): cfg = self.pydantic_config @@ -199,13 +207,3 @@ def _base(self, doc, p: bool, r: bool): runs=self.paragraph.runs, text=ParagraphStyle.to_string(paragraph_issues), ) - - # 题注编号校验/修正(仅当 value 为 dict 且有 _numbering_cfg 时才执行) - if isinstance(self.value, dict): - numbering_cfg = self.value.get("_numbering_cfg") - if numbering_cfg and numbering_cfg.enabled: - prefix = cfg.caption_prefix or "表" - if p: - _check_caption_numbering(self, doc, prefix, numbering_cfg) - else: - _apply_caption_numbering(self, prefix, numbering_cfg) diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py index a86e58f..f0377f1 100644 --- a/src/wordformat/rules/keywords.py +++ b/src/wordformat/rules/keywords.py @@ -4,7 +4,12 @@ from docx.oxml.ns import qn -from wordformat.config.datamodel import KeywordsConfig, NodeConfigRoot +from wordformat.config.datamodel import ( + KeywordCountRule, + KeywordsConfig, + NodeConfigRoot, + TrailingPunctRule, +) from wordformat.rules.node import FormatNode from wordformat.style.check_format import CharacterStyle, ParagraphStyle @@ -114,6 +119,7 @@ class KeywordsEN(BaseKeywordsNode): LANG = "en" NODE_TYPE = "abstract.keywords.english" + RULES = {"keyword_count": "_check_keyword_count"} def _check_keyword_label(self, run) -> bool: """检查run是否包含英文关键词标签(Keywords/KEY WORDS)""" @@ -124,6 +130,20 @@ def _get_label_split_pattern(self) -> re.Pattern | None: """英文标签拆分模式:匹配 'Keywords:' 或 'Keywords ' 及其变体""" return re.compile(r"Keywords?\s*[::]?\s*", re.IGNORECASE) + def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False): + """校验英文关键词数量""" + keyword_text = "".join([run.text for run in self.paragraph.runs]) + keyword_list = re.split( + r"[,;]", re.sub(r"Keywords?:", "", keyword_text, flags=re.IGNORECASE) + ) + keyword_list = [k.strip() for k in keyword_list if k.strip()] + if len(keyword_list) < rule_cfg.count_min: + issue = f"英文关键词数量不足(最少{rule_cfg.count_min}个,当前{len(keyword_list)}个)" + self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) + if len(keyword_list) > rule_cfg.count_max: + issue = f"英文关键词数量超限(最多{rule_cfg.count_max}个,当前{len(keyword_list)}个)" + self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) + def _base(self, doc, p: bool, r: bool): # noqa C901 """ 校验英文关键词格式: @@ -132,7 +152,7 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 """ cfg = self.pydantic_config - # 2. 段落样式检查 + # 1. 段落样式检查 paragraph_issues = self._check_paragraph_style(cfg, p) if paragraph_issues: self.add_comment( @@ -141,11 +161,11 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 text=paragraph_issues, ) - # 3. 拆分标签和内容混合的 run(仅在格式化模式下执行) + # 2. 拆分标签和内容混合的 run(仅在格式化模式下执行) if not p: self._split_mixed_runs() - # 4. 字符样式检查(区分标签/内容) + # 3. 字符样式检查(区分标签/内容) label_cfg = cfg.label label_style = CharacterStyle( font_name_cn=label_cfg.chinese_font_name, @@ -166,13 +186,11 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 underline=cfg.underline, ) - # 4. 遍历run校验 for run in self.paragraph.runs: if not run.text.strip(): continue if self._check_keyword_label(run): - # 检查标签样式 if r: diff = label_style.diff_from_run(run) else: @@ -184,7 +202,6 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 text=CharacterStyle.to_string(diff), ) else: - # 检查内容样式 if r: diff = content_style.diff_from_run(run) else: @@ -196,20 +213,6 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 text=CharacterStyle.to_string(diff), ) - # 5. 校验关键词数量(KeywordsConfig特有配置) - keyword_text = "".join([run.text for run in self.paragraph.runs]) - # 提取英文关键词(按逗号/分号分割) - keyword_list = re.split( - r"[,;]", re.sub(r"Keywords?:", "", keyword_text, flags=re.IGNORECASE) - ) - keyword_list = [k.strip() for k in keyword_list if k.strip()] - if len(keyword_list) < cfg.count_min: - issue = f"英文关键词数量不足(最少{cfg.count_min}个,当前{len(keyword_list)}个)" - self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) - if len(keyword_list) > cfg.count_max: - issue = f"英文关键词数量超限(最多{cfg.count_max}个,当前{len(keyword_list)}个)" - self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) - # 第三步:中文关键词节点(专属逻辑) class KeywordsCN(BaseKeywordsNode): @@ -217,6 +220,10 @@ class KeywordsCN(BaseKeywordsNode): LANG = "cn" NODE_TYPE = "abstract.keywords.chinese" + RULES = { + "keyword_count": "_check_keyword_count", + "trailing_punctuation": "_check_trailing_punctuation", + } def _check_keyword_label(self, run) -> bool: """检查run是否包含中文关键词标签(关键词)""" @@ -229,14 +236,40 @@ def _get_label_split_pattern(self) -> re.Pattern | None: r"关[^a-zA-Z0-9\u4e00-\u9fff]*键[^a-zA-Z0-9\u4e00-\u9fff]*词\s*[::]?\s*" ) + def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False): + """校验中文关键词数量""" + keyword_text = "".join([run.text for run in self.paragraph.runs]) + keyword_list = re.split(r";", re.sub(r"关键词[::]", "", keyword_text)) + keyword_list = [k.strip() for k in keyword_list if k.strip()] + if len(keyword_list) < rule_cfg.count_min: + issue = f"中文关键词数量不足(最少{rule_cfg.count_min}个,当前{len(keyword_list)}个)" + self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) + if len(keyword_list) > rule_cfg.count_max: + issue = f"中文关键词数量超限(最多{rule_cfg.count_max}个,当前{len(keyword_list)}个)" + self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) + + def _check_trailing_punctuation( + self, doc, rule_cfg: TrailingPunctRule, p: bool = False + ): + """校验中文关键词末尾标点""" + keyword_text = "".join([run.text for run in self.paragraph.runs]) + if ( + keyword_text.strip() + and keyword_text.strip()[-1] in rule_cfg.forbidden_chars + ): + self.add_comment( + doc=doc, + runs=self.paragraph.runs, + text="中文关键词末尾禁止出现标点符号", + ) + def _base(self, doc, p: bool, r: bool): # noqa C901 """ 校验中文关键词格式: - 段落整体格式(对齐、行距等) - "关键词:"部分加粗,其余内容不加粗 - - 校验关键词数量、末尾标点 """ - # 1. 空值校验 + # 空值校验 if self.pydantic_config is None: self.add_comment( doc=doc, runs=self.paragraph.runs, text="中文关键词配置未加载,跳过检查" @@ -245,7 +278,7 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 cfg = self.pydantic_config - # 2. 段落样式检查(复用基类方法) + # 段落样式检查 paragraph_issues = self._check_paragraph_style(cfg, p) if paragraph_issues: self.add_comment( @@ -254,11 +287,11 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 text=paragraph_issues, ) - # 3. 拆分标签和内容混合的 run(仅在格式化模式下执行) + # 拆分标签和内容混合的 run(仅在格式化模式下执行) if not p: self._split_mixed_runs() - # 4. 字符样式检查(区分标签/内容) + # 字符样式检查(区分标签/内容) label_cfg = cfg.label label_style = CharacterStyle( font_name_cn=label_cfg.chinese_font_name, @@ -279,13 +312,11 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 underline=cfg.underline, ) - # 4. 遍历run校验 for run in self.paragraph.runs: if not run.text.strip(): continue if self._check_keyword_label(run): - # 检查标签样式 if r: diff = label_style.diff_from_run(run) else: @@ -297,7 +328,6 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 text=CharacterStyle.to_string(diff), ) else: - # 检查内容样式 if r: diff = content_style.diff_from_run(run) else: @@ -308,26 +338,3 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 runs=run, text=CharacterStyle.to_string(diff), ) - - # 5. 专属校验:关键词数量 + 末尾标点(KeywordsConfig特有配置) - keyword_text = "".join([run.text for run in self.paragraph.runs]) - # 提取中文关键词(按分号分割) - keyword_list = re.split(r";", re.sub(r"关键词[::]", "", keyword_text)) - keyword_list = [k.strip() for k in keyword_list if k.strip()] - - # 数量校验 - if len(keyword_list) < cfg.count_min: - issue = f"中文关键词数量不足(最少{cfg.count_min}个,当前{len(keyword_list)}个)" - self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) - if len(keyword_list) > cfg.count_max: - issue = f"中文关键词数量超限(最多{cfg.count_max}个,当前{len(keyword_list)}个)" - self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) - - # 末尾标点校验 - if ( - cfg.trailing_punct_forbidden - and keyword_text.strip() - and keyword_text.strip()[-1] in ";,。、" - ): - issue = "中文关键词末尾禁止出现标点符号" - self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py index db53eda..1ca9d96 100644 --- a/src/wordformat/rules/node.py +++ b/src/wordformat/rules/node.py @@ -92,6 +92,9 @@ class FormatNode(TreeNode, Generic[T]): CONFIG_MODEL: Type[T] + # 子类声明:规则名 → handler 方法名,框架自动按 config 启用/禁用调度 + RULES: dict[str, str] = {} + def __init__( self, value, @@ -158,14 +161,55 @@ def update_paragraph(self, paragraph: Paragraph | dict): def _base(self, doc, p: bool, r: bool): raise NotImplementedError("Subclasses should implement this!") + def _run_rules(self, doc: Document, p: bool) -> None: + """自动调度业务规则:遍历 RULES,读配置,若 enabled 则执行 handler。 + + p=True 表示检查模式,p=False 表示应用模式。handler 签名为 + (doc, rule_cfg, p),p 默认 False 以兼容不需要区分模式的 handler。 + + 仅子类声明了 RULES 且有对应配置时才执行。提供双向验证: + - 配置有规则但无 handler → warning + - RULES 声明了但配置无对应项 → warning + """ + if not self.RULES: + return + + rules_config = getattr(self._pydantic_config, "rules", None) + if rules_config is None: + logger.warning(f"[{self.NODE_TYPE}] RULES 已声明但配置无 rules 节点") + return + + declared_rules = set(self.RULES.keys()) + config_rules = set(type(rules_config).model_fields.keys()) + + orphan_handlers = declared_rules - config_rules + orphan_configs = config_rules - declared_rules + if orphan_handlers: + logger.warning( + f"[{self.NODE_TYPE}] RULES 声明了 {orphan_handlers} 但配置无对应项" + ) + if orphan_configs: + logger.warning( + f"[{self.NODE_TYPE}] 配置有 {orphan_configs} 但无对应 handler" + ) + + for rule_name, handler_name in self.RULES.items(): + rule_cfg = getattr(rules_config, rule_name, None) + if rule_cfg is None or not rule_cfg.enabled: + continue + handler = getattr(self, handler_name) + handler(doc, rule_cfg, p) + def check_format(self, doc: Document): - """虚方法:由子类实现具体的格式检查逻辑""" + """格式检查:先执行样式检查,再自动调度业务规则""" self._base(doc, p=True, r=True) + self._run_rules(doc, p=True) def apply_format(self, doc: Document): - """虚方法:由子类实现具体的格式应用逻辑""" + """格式应用:先清理、应用样式,再自动调度业务规则""" 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/set_style.py b/src/wordformat/set_style.py index 44ea0e7..e63c713 100644 --- a/src/wordformat/set_style.py +++ b/src/wordformat/set_style.py @@ -409,8 +409,6 @@ def traverse(node, parent_category="", current_chapter: int = 0): seq = counter[chapter] node.value["chapter_number"] = chapter node.value["sequence_number"] = seq - if hasattr(config, "numbering"): - node.value["_numbering_cfg"] = config.numbering.captions if node.paragraph: # 先执行内容替换(check/format 两种模式均执行) diff --git a/tests/conftest.py b/tests/conftest.py index ede3413..7674416 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -150,8 +150,13 @@ def sample_yaml_config(tmp_path): chinese_font_name: '宋体' font_size: '小四' bold: false - count_min: 3 - count_max: 5 + rules: + keyword_count: + enabled: true + count_min: 3 + count_max: 5 + trailing_punctuation: + enabled: true english: label: font_size: '三号' @@ -160,8 +165,11 @@ def sample_yaml_config(tmp_path): first_line_indent: '0字符' font_size: '小四' bold: false - count_min: 3 - count_max: 5 + rules: + keyword_count: + enabled: true + count_min: 3 + count_max: 5 headings: level_1: alignment: '居中对齐' diff --git a/tests/test_caption_numbering.py b/tests/test_caption_numbering.py index 54d17d8..9dcfd9f 100644 --- a/tests/test_caption_numbering.py +++ b/tests/test_caption_numbering.py @@ -546,11 +546,19 @@ def caption_yaml(self, tmp_path): figures: caption_position: 'below' caption_prefix: '图' + rules: + caption_numbering: + enabled: true + separator: '.' tables: caption_position: 'above' caption_prefix: '表' content: font_size: '五号' + rules: + caption_numbering: + enabled: true + separator: '.' numbering: enabled: false captions: @@ -738,7 +746,7 @@ def test_multi_chapter_counters_reset(self, caption_yaml): @pytest.mark.usefixtures("_suppress_format_comments") def test_disabled_skips_numbering_check(self, caption_yaml): - """captions.enabled=False 时不检查编号(但仍注入 chapter/seq)。""" + """rules.caption_numbering.enabled=False 时不检查编号(但仍注入 chapter/seq)。""" from wordformat.set_style import apply_format_check_to_all_nodes doc = Document() @@ -746,7 +754,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.numbering.captions.enabled = False + config.figures.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_integration.py b/tests/test_integration.py index 4c0ff68..14170d3 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -29,6 +29,7 @@ clear_config, ) from wordformat.config.datamodel import ( + KeywordCountRule, KeywordsConfig, GlobalFormatConfig, FontSizeType, @@ -120,14 +121,14 @@ class TestDataModelValidation: def test_keywords_count_positive(self): """count_min/count_max 必须大于 0""" with pytest.raises(ValueError): - KeywordsConfig(count_min=0) + KeywordCountRule(count_min=0) with pytest.raises(ValueError): - KeywordsConfig(count_max=-1) + KeywordCountRule(count_max=-1) def test_keywords_count_min_le_max(self): """count_min 不应大于 count_max""" with pytest.raises(ValidationError): - KeywordsConfig(count_min=10, count_max=3) + KeywordCountRule(count_min=10, count_max=3) def test_font_size_range_validation(self): """字号数值验证:负数应触发 ValidationError""" @@ -1201,7 +1202,7 @@ def test_content_style_check(self, sample_yaml_config): node._base(doc, p=True, r=True) def test_keyword_count_validation_min(self, sample_yaml_config): - """Keyword count < count_min triggers warning (line 152)""" + """Keyword count < count_min triggers warning (via _run_rules)""" from wordformat.config.config import init_config, get_config init_config(sample_yaml_config) config = get_config() @@ -1213,11 +1214,11 @@ def test_keyword_count_validation_min(self, sample_yaml_config): label_run.font.bold = True content_run = p.add_run("AI") node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) # count_min is 3, only 1 keyword -> should trigger count warning def test_keyword_count_validation_max(self, sample_yaml_config): - """Keyword count > count_max triggers warning (line 153)""" + """Keyword count > count_max triggers warning (via _run_rules)""" from wordformat.config.config import init_config, get_config init_config(sample_yaml_config) config = get_config() @@ -1229,7 +1230,7 @@ def test_keyword_count_validation_max(self, sample_yaml_config): label_run.font.bold = True content_run = p.add_run("AI, ML, NLP, CV, DB, SE") node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) # count_max is 5, 6 keywords -> should trigger count warning @@ -1309,7 +1310,7 @@ def test_content_style_check(self, sample_yaml_config): node._base(doc, p=True, r=True) def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config): - """Keyword count validation + trailing punctuation check (lines 234-239)""" + """Keyword count validation + trailing punctuation check (via _run_rules)""" from wordformat.config.config import init_config, get_config init_config(sample_yaml_config) config = get_config() @@ -1319,8 +1320,8 @@ def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config): p = doc.add_paragraph() run = p.add_run("关键词:人工智能;机器学习;") node.paragraph = p - node._base(doc, p=True, r=True) - # trailing_punct_forbidden should be True by default + node.check_format(doc) + # trailing_punctuation.enabled should be True by default # Text ends with ; which should trigger trailing punctuation warning diff --git a/tests/test_rules.py b/tests/test_rules.py index be2e287..865f609 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -261,8 +261,8 @@ def test_keywords_cn_from_dict(self, config_path): node.load_config(raw) assert node._pydantic_config is not None # dict 路径下 LANG='cn' 找不到 YAML 中的 'chinese' 键,使用默认值 - assert node._pydantic_config.count_min == 4 - assert node._pydantic_config.count_max == 4 + assert node._pydantic_config.rules.keyword_count.count_min == 4 + assert node._pydantic_config.rules.keyword_count.count_max == 6 def test_keywords_en_from_dict(self, config_path): """KeywordsEN 支持从 dict 加载配置。""" @@ -368,7 +368,7 @@ def test_cn_count_validation_too_few(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) # 应至少有一条数量不足的 comment texts = [c.kwargs["text"] for c in mock_comment.call_args_list] assert any("数量不足" in t for t in texts) @@ -381,7 +381,7 @@ def test_cn_count_validation_too_many(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) texts = [c.kwargs["text"] for c in mock_comment.call_args_list] assert any("数量超限" in t for t in texts) @@ -393,7 +393,7 @@ def test_cn_trailing_punct_detection(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) texts = [c.kwargs["text"] for c in mock_comment.call_args_list] assert any("末尾禁止" in t for t in texts) @@ -405,7 +405,7 @@ def test_en_count_validation_too_few(self, root_config): node = KeywordsEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) texts = [c.kwargs["text"] for c in mock_comment.call_args_list] assert any("数量不足" in t for t in texts) diff --git a/wordformat-skill/SKILL.md b/wordformat-skill/SKILL.md index 3d9f7d7..0cd7c50 100644 --- a/wordformat-skill/SKILL.md +++ b/wordformat-skill/SKILL.md @@ -57,11 +57,33 @@ pip install wordformat # 海外 ## 工作流程 -两个独立任务,先任务一再任务二。每个任务完成后**必须将产物复制到用户工作目录**。 +根据用户意图走不同路径: + +- **仅文本替换**:只想修正论文中某些段落的文字内容(如错别字、措辞),不检查格式 → [纯文本替换流程](#纯文本替换流程) +- **格式校验/修正**:检查或修正论文格式 → 下面两个独立任务,先任务一再任务二 + +每个任务完成后**必须将产物复制到用户工作目录**。 + +--- + +## 纯文本替换流程 + +> 用户只想换文字(错别字、措辞修正),不需要格式批注/编号/样式修改。 + +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 验证,禁止新建。** > **🚨 用户说"用上次的配置"/"和上次一样" → 复用已有配置,禁止新建。** @@ -126,7 +148,7 @@ cp config.yaml <用户工作目录>/ --- -## 任务二:执行格式化 +### 任务二:执行格式化 > 前提:有 `config.yaml` 和 `.docx` 论文。 diff --git a/wordformat-skill/data/config_spec.md b/wordformat-skill/data/config_spec.md index 7e26786..66471b9 100644 --- a/wordformat-skill/data/config_spec.md +++ b/wordformat-skill/data/config_spec.md @@ -59,14 +59,28 @@ abstract: english: # 同上 ``` -keywords 专用字段: +keywords 除 16 个 GlobalFormat 字段外,附加: + +```yaml +label: # KeywordLabelConfig,关键词标签的字符格式 +rules: + keyword_count: # 关键词数量校验 + enabled: true + count_min: 4 + count_max: 6 + trailing_punctuation: # 末尾标点校验(仅中文) + enabled: true + forbidden_chars: ";,。、" +``` | 字段 | 类型 | 默认值 | |------|------|--------| | `label` | GlobalFormat | 关键词标签("关键词:")的字符格式 | -| `count_min` | int | `4` | -| `count_max` | int | `4` | -| `trailing_punct_forbidden` | bool | `true` | +| `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(标题) @@ -83,7 +97,7 @@ headings: ### figures(图注) -继承 15 字段 + `caption_prefix`(默认 `图`)。 +继承 16 字段 + `caption_prefix`(默认 `图`)+ `rules`。 ```yaml figures: @@ -93,11 +107,16 @@ figures: alignment: '居中对齐' first_line_indent: '0字符' builtin_style_name: '题注' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false ``` ### tables(表注 + 表格内容) -继承 15 字段 + `caption_prefix`(默认 `表`)+ `content` 子配置(继承 15 字段,控制单元格内文字)。 +继承 16 字段 + `caption_prefix`(默认 `表`)+ `content` 子配置 + `rules`。 ```yaml tables: @@ -108,6 +127,11 @@ tables: <<: *global_format font_size: '五号' line_spacingrule: '单倍行距' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false ``` ### references(参考文献) @@ -170,11 +194,18 @@ numbering: | `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) + count_min, count_max, trailing_punct_forbidden | +| `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 | -| `tables` | 同 global_format + caption_prefix + content(同 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 | diff --git a/wordformat-skill/scripts/validate_config.py b/wordformat-skill/scripts/validate_config.py index e8183bf..bcc41e5 100644 --- a/wordformat-skill/scripts/validate_config.py +++ b/wordformat-skill/scripts/validate_config.py @@ -105,10 +105,8 @@ def _get_pip_mirror() -> list[str]: # abstract.keywords.chinese / english 合法字段 KEYWORDS_FIELDS = GLOBAL_FORMAT_FIELDS | { - "keywords_bold", - "count_min", - "count_max", - "trailing_punct_forbidden", + "label", + "rules", } # headings 各级别合法字段 @@ -117,11 +115,14 @@ def _get_pip_mirror() -> list[str]: # figures 合法字段 FIGURES_FIELDS = GLOBAL_FORMAT_FIELDS | { "caption_prefix", + "rules", } # tables 合法字段 TABLES_FIELDS = GLOBAL_FORMAT_FIELDS | { "caption_prefix", + "content", + "rules", } # references.title 合法字段 From 312fd192995584a74caf5d8a2c6a3189f3507781 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Tue, 16 Jun 2026 15:54:53 +0800 Subject: [PATCH 2/9] chore: bump version to 1.4.0 --- pyproject.toml | 2 +- src/wordformat/_version.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 64b59b0..602251a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "wordformat" -version = "1.3.2" +version = "1.4.0" description = "论文格式自动化处理工具" readme = "README.md" license = "Apache-2.0" diff --git a/src/wordformat/_version.py b/src/wordformat/_version.py index 0ba3485..b3d780d 100644 --- a/src/wordformat/_version.py +++ b/src/wordformat/_version.py @@ -1,3 +1,3 @@ # 此文件由 scripts/sync_version.py 自动生成,请勿手动编辑。 # 版本号唯一来源为 pyproject.toml。 -__version__ = "1.3.2" +__version__ = "1.4.0" From 5a24759b43d05eff25b3a6add84a6ca73cd0a393 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sat, 20 Jun 2026 00:02:08 +0800 Subject: [PATCH 3/9] =?UTF-8?q?feat:=20=E5=A3=B0=E6=98=8E=E5=BC=8F?= =?UTF-8?q?=E8=A7=84=E5=88=99=E5=BC=95=E6=93=8E=E3=80=81=E6=89=B9=E6=B3=A8?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E6=A0=87=E5=87=86=E5=8C=96=E3=80=81=E6=A0=B7?= =?UTF-8?q?=E5=BC=8F=E7=BB=A7=E6=89=BF=E4=BF=AE=E5=A4=8D=E3=80=81=E6=A0=87?= =?UTF-8?q?=E7=82=B9=E6=A3=80=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 RULES + DEFAULT_RULES 机制,段落/字符样式由框架自动调度 - 批注统一格式:位置-问题类型:现状,规范:标准 - 批注缓冲合并:同段落多问题合并为一条,字符级锚在具体 run - 样式继承回退:缩进、行距、段间距从样式链递归读取 - 半角标点检测:中文上下文中的半角标点提示改为全角 - 提醒级批注蓝色字体 - 新增图片段落识别 figure_image、表格对象 table_object - 配置从 Pydantic 模型自动生成,测试与示例文件解耦 - 检测报告摘要(仅 check 模式):错误统计 + 字数问题 + 分级 - 引用上标限制第一章,检查模式不修改文档 --- docs/rule-handler-guide.md | 166 +++++++++++++++ example/undergrad_thesis.yaml | 81 +++++--- src/wordformat/base.py | 71 ++++--- src/wordformat/config/datamodel.py | 38 +++- src/wordformat/rules/__init__.py | 3 + src/wordformat/rules/abstract.py | 97 +++------ src/wordformat/rules/acknowledgement.py | 92 +-------- src/wordformat/rules/body.py | 149 ++++++++++---- src/wordformat/rules/caption.py | 133 +++++------- src/wordformat/rules/heading.py | 81 +------- src/wordformat/rules/keywords.py | 211 +++++++++---------- src/wordformat/rules/node.py | 236 ++++++++++++++++++++-- src/wordformat/rules/object.py | 62 ++++++ src/wordformat/rules/references.py | 82 +------- src/wordformat/set_style.py | 183 +++++++++++++++-- src/wordformat/style/check_format.py | 211 +++++++++++++------ src/wordformat/style/comment_format.py | 77 +++++++ src/wordformat/style/get_some.py | 84 +++----- src/wordformat/style/style_enum.py | 45 ++++- src/wordformat/word_structure/settings.py | 4 + tests/test_caption_numbering.py | 22 +- tests/test_core.py | 36 ++-- tests/test_coverage_boost.py | 32 +-- tests/test_integration.py | 38 ++-- tests/test_rules.py | 175 +++++++--------- tests/test_style.py | 65 +++--- wordformat-skill/scripts/validate_json.py | 2 + 27 files changed, 1502 insertions(+), 974 deletions(-) create mode 100644 docs/rule-handler-guide.md create mode 100644 src/wordformat/rules/object.py create mode 100644 src/wordformat/style/comment_format.py diff --git a/docs/rule-handler-guide.md b/docs/rule-handler-guide.md new file mode 100644 index 0000000..92a9ec5 --- /dev/null +++ b/docs/rule-handler-guide.md @@ -0,0 +1,166 @@ +# 规则 Handler 开发指南 + +声明式规则引擎让开发者只需专注"判断是否有格式问题",是否生效、何时生效全部由 YAML 配置决定。 + +## 快速开始 + +### 1. 定义规则配置模型 + +```python +# datamodel.py +from pydantic import BaseModel, Field + +class BaseRuleConfig(BaseModel): + """所有规则的基类,enabled 控制开关。""" + enabled: bool = Field(default=True) + +class MyCheckRule(BaseRuleConfig): + """自定义校验规则的配置参数。""" + threshold: int = Field(default=3, description="最小数量阈值") + strict_mode: bool = Field(default=False) + + +class MyRulesConfig(BaseModel): + """规则集合。""" + my_check: MyCheckRule = Field(default_factory=MyCheckRule) +``` + +### 2. 挂到目标 Config 上 + +```python +class MyNodeConfig(GlobalFormatConfig): + # ... 原有格式字段 ... + rules: MyRulesConfig = Field(default_factory=MyRulesConfig) +``` + +### 3. 注册 Handler + +```python +class MyNode(FormatNode[MyNodeConfig]): + NODE_TYPE = "my_node" + CONFIG_MODEL = MyNodeConfig + CONFIG_PATH = "my_node" + + # 注册:规则名 → handler 方法名 + RULES = {"my_check": "_handle_my_check"} + + def _base(self, doc, p, r): + """只做格式样式检查(ParagraphStyle / CharacterStyle)。""" + ... + + def _handle_my_check(self, doc, rule_cfg: MyCheckRule, p: bool): + """业务规则:只管判断,不关心 enabled。""" + count = len(self.paragraph.text.split()) + if count < rule_cfg.threshold: + self.add_comment( + doc=doc, + runs=self.paragraph.runs, + text=f"数量不足(最少 {rule_cfg.threshold},当前 {count})", + ) +``` + +### 4. YAML 配置 + +```yaml +my_node: + <<: *global_format + rules: + my_check: + enabled: true # false 则整个 handler 不执行 + threshold: 5 + strict_mode: true +``` + +--- + +## Handler 签名 + +``` +handler(self, doc: Document, rule_cfg: RuleConfig, p: bool) -> None +``` + +| 参数 | 说明 | +|------|------| +| `doc` | python-docx 的 `Document` 对象,用于 `add_comment` | +| `rule_cfg` | 规则对应的 Pydantic 配置对象,`enabled` 已被框架检查过 | +| `p` | `True` = 检查模式(diff),`False` = 应用模式(apply) | + +**进入 handler 时 `enabled` 已为 `True`**,不需要在 handler 里再判断。 + +--- + +## 示例 + +### 示例 1:纯检查型规则(关键词数量) + +不需要区分 check/apply 模式,只做计数 + 报警。 + +```python +RULES = {"keyword_count": "_check_keyword_count"} + +def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False): + keyword_text = "".join(run.text for run in self.paragraph.runs) + keyword_list = [ + k.strip() for k in re.split(r"[;,]", keyword_text) if k.strip() + ] + if len(keyword_list) < rule_cfg.count_min: + self.add_comment( + doc=doc, runs=self.paragraph.runs, + text=f"数量不足(最少 {rule_cfg.count_min},当前 {len(keyword_list)})", + ) +``` + +### 示例 2:区分 check/apply 的规则(题注编号) + +check 模式加批注,apply 模式直接改文本。 + +```python +RULES = {"caption_numbering": "_handle_caption_numbering"} + +def _handle_caption_numbering(self, doc, rule_cfg: CaptionNumberingConfig, p: bool): + prefix = self.pydantic_config.caption_prefix or "图" + if p: + _check_caption_numbering(self, doc, prefix, rule_cfg) # 加批注 + else: + _apply_caption_numbering(self, prefix, rule_cfg) # 直接改 +``` + +### 示例 3:注册多个规则 + +```python +RULES = { + "keyword_count": "_check_keyword_count", + "trailing_punctuation": "_check_trailing_punctuation", +} + +def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False): + ... + +def _check_trailing_punctuation(self, doc, rule_cfg: TrailingPunctRule, p: bool = False): + text = "".join(r.text for r in self.paragraph.runs).strip() + if text and text[-1] in rule_cfg.forbidden_chars: + self.add_comment(doc=doc, runs=self.paragraph.runs, text="末尾禁止标点") +``` + +--- + +## 执行流程 + +``` +check_format(doc) + ├── _base(doc, p=True, r=True) ← 格式样式(自动) + └── _run_rules(doc, p=True) ← 业务规则(自动) + ├── 读 config.rules + ├── 比对 RULES ↔ config(不匹配则 warning) + └── 遍历 RULES: + enabled=True → handler(doc, rule_cfg, p) + enabled=False → 跳过 +``` + +## 注意事项 + +- **`_base()` 只放格式样式检查**(`ParagraphStyle` / `CharacterStyle`),不要在里面直接写业务判断 +- **Handler 里不要检查 `enabled`**——框架已过滤,能进来说明已启用 +- **`p` 参数默认值设为 `False`**(`p: bool = False`),兼容不需要区分模式的 handler +- **Handler 不应有返回值**——结果全部通过 `self.add_comment()` 上报 +- **新增配置模型记得在 `LEAF_TYPES` 注册**(`cli.py:58`),否则 `wordf config` 会把它当容器继续往下展开而非打印字段 diff --git a/example/undergrad_thesis.yaml b/example/undergrad_thesis.yaml index 0761d84..f5e1765 100644 --- a/example/undergrad_thesis.yaml +++ b/example/undergrad_thesis.yaml @@ -14,25 +14,27 @@ # 对齐:左对齐 / 居中对齐 / 右对齐 / 两端对齐 / 分散对齐 # =========================================================================== +template_name: "山西晋中理工学院(毕业设计)" + # --------------------------------------------------------------------------- # 预警开关:控制各字段差异是否在批注中显示(false=不显示该类型差异) # --------------------------------------------------------------------------- style_checks_warning: - bold: false + bold: true italic: false underline: false - font_size: false - font_name: false + font_size: true + font_name: true font_color: false - alignment: false - space_before: false - space_after: false - line_spacing: false - line_spacingrule: false - left_indent: false - right_indent: false - first_line_indent: false - builtin_style_name: false + alignment: true + space_before: true + space_after: true + line_spacing: true + line_spacingrule: true + left_indent: true + right_indent: true + first_line_indent: true + builtin_style_name: true # --------------------------------------------------------------------------- # 全局基础格式(其他节通过 <<: *global_format 继承后再覆写) @@ -71,7 +73,7 @@ abstract: alignment: '居中对齐' first_line_indent: '0字符' chinese_font_name: '黑体' - font_size: '小二' + font_size: '四号' bold: false # -- 中文摘要正文 -- @@ -88,8 +90,8 @@ abstract: <<: *global_format alignment: '居中对齐' first_line_indent: '0字符' - font_size: '小四' - bold: true + font_size: '四号' + bold: false # -- 英文摘要正文 -- english_content: @@ -103,15 +105,15 @@ abstract: chinese: # 从 global_format 继承,再覆写关键词特有字段 <<: *global_format - alignment: '左对齐' - first_line_indent: '0字符' - font_size: '四号' + alignment: '两端对齐' + first_line_indent: '2字符' + font_size: '小四' # -- 关键词标签("关键词:")的字符格式 -- label: <<: *global_format chinese_font_name: '黑体' font_size: '四号' - bold: true + bold: false # -- 关键词业务规则(均通过 enabled 开关控制) -- rules: keyword_count: @@ -124,13 +126,13 @@ abstract: english: <<: *global_format - alignment: '左对齐' - first_line_indent: '0字符' - font_size: '四号' + alignment: '两端对齐' + first_line_indent: '2字符' + font_size: '小四' label: <<: *global_format font_size: '四号' - bold: true + bold: false rules: keyword_count: enabled: true @@ -161,8 +163,8 @@ headings: chinese_font_name: '黑体' font_size: '三号' bold: false - space_before: "0.5行" - space_after: "0.5行" + space_before: "0行" + space_after: "0行" builtin_style_name: 'Heading 2' # -- 三级标题(1.1.1)-- @@ -173,8 +175,8 @@ headings: chinese_font_name: '黑体' font_size: '小四' bold: false - space_before: "0.5行" - space_after: "0.5行" + space_before: "0行" + space_after: "0行" builtin_style_name: 'Heading 3' # =========================================================================== @@ -182,6 +184,9 @@ headings: # =========================================================================== body_text: <<: *global_format + rules: + punctuation: + enabled: true # =========================================================================== # 插图题注(Figure) @@ -194,9 +199,19 @@ figures: first_line_indent: '0字符' # -- 插图特有字段 -- caption_prefix: '图' # 图注编号前缀 + # -- 图片段落格式(包含内联图片的段落)-- + image: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false # =========================================================================== -# 表格(题注 + 表格内容) +# 表格(题注 + 表格对象 + 表格内容) # =========================================================================== tables: <<: *global_format @@ -206,6 +221,11 @@ tables: first_line_indent: '0字符' # -- 表格题注特有字段 -- caption_prefix: '表' # 表注编号前缀 + # -- 表格对象格式(表格整体对齐、环绕)-- + object: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' # -- 表格内容格式(单元格内文字)-- content: <<: *global_format @@ -217,6 +237,11 @@ tables: first_line_indent: '0字符' space_before: "0行" space_after: "0行" + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false # =========================================================================== # 参考文献 diff --git a/src/wordformat/base.py b/src/wordformat/base.py index 4df0ee6..355e954 100644 --- a/src/wordformat/base.py +++ b/src/wordformat/base.py @@ -7,12 +7,20 @@ from loguru import logger from wordformat.agent.onnx_infer import onnx_batch_infer, onnx_single_infer - -# from wordformat.config.config import get_config, init_config from wordformat.settings import BATCH_SIZE from wordformat.utils import get_paragraph_numbering_text, get_paragraph_xml_fingerprint +def _para_contains_image(para) -> bool: + """检查段落是否包含内联图片(w:drawing)。""" + from docx.oxml.ns import qn + + for r in para._element.findall(qn("w:r")): + if r.find(qn("w:drawing")) is not None: + return True + return False + + class DocxBase: def __init__(self, docx_file, configpath): self.re_dict = {} @@ -31,40 +39,48 @@ def __init__(self, docx_file, configpath): # raise def parse(self) -> list[dict]: - paragraphs = [] - paragraph_objects = [] - result = [] - # 收集所有非空段落及其对象 - for para in self.document.paragraphs: + # 收集所有段落(含空段),空段/图片段直接标记,不走 AI 推理 + all_paras = list(self.document.paragraphs) + text_indices = [] + texts_for_ai = [] + for idx, para in enumerate(all_paras): text = para.text.strip() - if text: - # 拼接自动编号文字(para.text 不包含编号) - numbering_text = get_paragraph_numbering_text(para) - if numbering_text: - full_text = f"{numbering_text} {text}" - else: - full_text = para.text - paragraphs.append(full_text) - paragraph_objects.append(para) + if not text: + continue + numbering_text = get_paragraph_numbering_text(para) + full_text = f"{numbering_text} {text}" if numbering_text else para.text + texts_for_ai.append(full_text) + text_indices.append(idx) - # 按批次BATCH_SIZE进行批量推理 - for i in range(0, len(paragraphs), BATCH_SIZE): - batch_texts = paragraphs[i : i + BATCH_SIZE] - batch_paras = paragraph_objects[i : i + BATCH_SIZE] + result = [None] * len(all_paras) + for i, para in enumerate(all_paras): + if i not in text_indices: + has_image = _para_contains_image(para) + result[i] = { + "category": "figure_image" if has_image else "body_text", + "score": 1.0, + "comment": "图片段落" if has_image else "空段落", + "paragraph": "", + "fingerprint": get_paragraph_xml_fingerprint(para), + } + + # 对非空段进行批量 AI 推理 + for i in range(0, len(texts_for_ai), BATCH_SIZE): + batch_texts = texts_for_ai[i : i + BATCH_SIZE] + batch_indices = text_indices[i : i + BATCH_SIZE] try: batch_results = onnx_batch_infer(batch_texts) except Exception as e: logger.error(f"批量推理失败,降级到单条处理: {e}") - batch_results = [onnx_single_infer(text) for text in batch_texts] + batch_results = [onnx_single_infer(t) for t in batch_texts] - # batch 结果,应用后处理逻辑 - for _j, (text, para_obj, pred) in enumerate( - zip(batch_texts, batch_paras, batch_results, strict=False) + for idx, text, pred in zip( + batch_indices, batch_texts, batch_results, strict=False ): tag = pred["label"] score = pred["score"] - + para_obj = all_paras[idx] response = { "category": tag, "score": score, @@ -72,13 +88,12 @@ def parse(self) -> list[dict]: "paragraph": text, "fingerprint": get_paragraph_xml_fingerprint(para_obj), } - - # 置信度过低处理 if score < 0.6: response["category"] = "body_text" response["comment"] = ( f"原预测标签为 '{tag}',置信度 {score:.4f} < 0.6,已强制设为 'body_text'" ) - result.append(response) + result[idx] = response + assert all(r is not None for r in result), "存在未处理的段落" return result diff --git a/src/wordformat/config/datamodel.py b/src/wordformat/config/datamodel.py index 751344b..551eab8 100644 --- a/src/wordformat/config/datamodel.py +++ b/src/wordformat/config/datamodel.py @@ -227,10 +227,27 @@ class HeadingsConfig(BaseModel): ) +# -------------------------- 正文规则 -------------------------- +class PunctuationRule(BaseRuleConfig): + """半角/全角标点符号校验规则""" + + enabled: bool = Field(default=True, description="是否启用标点符号校验") + + +class BodyTextRulesConfig(BaseModel): + """正文业务规则集合""" + + punctuation: PunctuationRule = Field(default_factory=PunctuationRule) + + # -------------------------- 正文配置模型 -------------------------- class BodyTextConfig(GlobalFormatConfig): """正文配置(继承全局格式)""" + rules: BodyTextRulesConfig = Field( + default_factory=BodyTextRulesConfig, description="正文业务规则配置" + ) + # -------------------------- 题注编号配置模型 -------------------------- class CaptionNumberingConfig(BaseRuleConfig): @@ -258,10 +275,17 @@ class CaptionRulesConfig(BaseModel): # -------------------------- 插图配置模型 -------------------------- +class ImageFormatConfig(GlobalFormatConfig): + """图片段落格式配置(包含内联图片的段落,非题注)""" + + class FiguresConfig(GlobalFormatConfig): - """插图配置""" + """插图配置(题注格式 + 图片段落格式)""" caption_prefix: Optional[str] = Field(default="图", description="图注编号前缀") + image: ImageFormatConfig = Field( + default_factory=ImageFormatConfig, description="图片段落格式" + ) rules: CaptionRulesConfig = Field( default_factory=CaptionRulesConfig, description="题注业务规则配置" ) @@ -272,10 +296,17 @@ class TableContentConfig(GlobalFormatConfig): """表格内容格式配置(表格内单元格文字的字体、字号、行距等)""" +class TableObjectConfig(GlobalFormatConfig): + """表格对象格式配置(表格整体对齐、环绕等)""" + + class TablesConfig(GlobalFormatConfig): - """表格配置(题注格式 + 表格内容格式)""" + """表格配置(题注格式 + 表格对象格式 + 表格内容格式)""" caption_prefix: Optional[str] = Field(default="表", description="表注编号前缀") + object: TableObjectConfig = Field( + default_factory=TableObjectConfig, description="表格对象格式" + ) content: TableContentConfig = Field( default_factory=TableContentConfig, description="表格内容格式" ) @@ -394,6 +425,9 @@ class NumberingConfig(BaseModel): class NodeConfigRoot(BaseModel): """配置根节点模型""" + template_name: str = Field( + default="未知模板", description="模板名称(用于检测报告中显示)" + ) style_checks_warning: WarningFieldConfig = Field( default_factory=WarningFieldConfig, description="警告字段配置" ) diff --git a/src/wordformat/rules/__init__.py b/src/wordformat/rules/__init__.py index 6ab0b78..902c3d9 100644 --- a/src/wordformat/rules/__init__.py +++ b/src/wordformat/rules/__init__.py @@ -17,6 +17,7 @@ from .heading import HeadingLevel1Node, HeadingLevel2Node, HeadingLevel3Node from .keywords import KeywordsCN, KeywordsEN from .node import FormatNode +from .object import FigureImage, TableObject from .references import ReferenceEntry, References __all__ = [ @@ -33,6 +34,8 @@ "BodyText", "CaptionFigure", "CaptionTable", + "FigureImage", + "TableObject", "HeadingLevel1Node", "HeadingLevel2Node", "HeadingLevel3Node", diff --git a/src/wordformat/rules/abstract.py b/src/wordformat/rules/abstract.py index 5c802a8..7166280 100644 --- a/src/wordformat/rules/abstract.py +++ b/src/wordformat/rules/abstract.py @@ -17,43 +17,16 @@ class AbstractTitleCN(FormatNode[AbstractTitleConfig]): """摘要标题中文节点""" NODE_TYPE = "abstract.chinese.chinese_title" + NODE_LABEL = "中文摘要标题" CONFIG_MODEL = AbstractTitleConfig CONFIG_PATH = "abstract.chinese.chinese_title" - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - ps = ParagraphStyle.from_config(cfg) - if p: - issues = ps.diff_from_paragraph(self.paragraph) - else: - issues = ps.apply_to_paragraph(self.paragraph) - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - self.add_comment( - doc=doc, runs=self.paragraph.runs, text=ParagraphStyle.to_string(issues) - ) - class AbstractTitleContentCN(FormatNode[AbstractChineseConfig]): """摘要标题正文混合中文节点""" NODE_TYPE = "abstract.chinese" + NODE_LABEL = "中文摘要" CONFIG_MODEL = AbstractChineseConfig CONFIG_PATH = "abstract.chinese" @@ -108,10 +81,14 @@ def _base(self, doc, p: bool, r: bool): else: diff_result = C.apply_to_run(run) self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff_result, target=self.NODE_LABEL), ) self.add_comment( - doc=doc, runs=self.paragraph.runs, text=ParagraphStyle.to_string(issues) + doc=doc, + runs=self.paragraph.runs, + text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL), ) @@ -119,6 +96,7 @@ class AbstractContentCN(FormatNode[AbstractChineseConfig]): """摘要内容中文节点""" NODE_TYPE = "abstract.chinese.chinese_content" + NODE_LABEL = "中文摘要正文" CONFIG_MODEL = AbstractChineseConfig CONFIG_PATH = "abstract.chinese" @@ -144,10 +122,14 @@ def _base(self, doc, p: bool, r: bool): else: diff_result = cstyle.apply_to_run(run) self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff_result, target=self.NODE_LABEL), ) self.add_comment( - doc=doc, runs=self.paragraph.runs, text=ParagraphStyle.to_string(issues) + doc=doc, + runs=self.paragraph.runs, + text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL), ) @@ -155,48 +137,16 @@ class AbstractTitleEN(FormatNode[AbstractTitleConfig]): """摘要标题英文节点""" NODE_TYPE = "abstract.english.english_title" + NODE_LABEL = "英文摘要标题" CONFIG_MODEL = AbstractTitleConfig CONFIG_PATH = "abstract.english.english_title" - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - ps = ParagraphStyle.from_config(cfg) - if p: - issues = ps.diff_from_paragraph(self.paragraph) - else: - issues = ps.apply_to_paragraph(self.paragraph) - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - if issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text="".join(str(dr) for dr in issues), - ) - return [] - class AbstractTitleContentEN(FormatNode[AbstractEnglishConfig]): """摘要标题正文混合英文节点""" NODE_TYPE = "abstract.english" + NODE_LABEL = "英文摘要" CONFIG_MODEL = AbstractEnglishConfig CONFIG_PATH = "abstract.english" @@ -278,11 +228,15 @@ def _base(self, doc, p: bool, r: bool): else: diff_result = c.apply_to_run(run) self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff_result, target=self.NODE_LABEL), ) cum += len(text_clean) self.add_comment( - doc=doc, runs=self.paragraph.runs, text=ParagraphStyle.to_string(issues) + doc=doc, + runs=self.paragraph.runs, + text=ParagraphStyle.to_string(issues, target=self.NODE_LABEL), ) @@ -290,6 +244,7 @@ class AbstractContentEN(FormatNode[AbstractEnglishConfig]): """摘要内容英文节点""" NODE_TYPE = "abstract.english.english_content" + NODE_LABEL = "英文摘要正文" CONFIG_MODEL = AbstractEnglishConfig CONFIG_PATH = "abstract.english" @@ -316,7 +271,9 @@ def _base(self, doc, p: bool, r: bool): diff_result = cstyle.apply_to_run(run) if diff_result: # 可选:仅当有差异时才添加批注 self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff_result, target=self.NODE_LABEL), ) if issues: self.add_comment( diff --git a/src/wordformat/rules/acknowledgement.py b/src/wordformat/rules/acknowledgement.py index 132aa88..07c626b 100644 --- a/src/wordformat/rules/acknowledgement.py +++ b/src/wordformat/rules/acknowledgement.py @@ -8,109 +8,21 @@ AcknowledgementsTitleConfig, ) from wordformat.rules.node import FormatNode -from wordformat.style.check_format import CharacterStyle, ParagraphStyle class Acknowledgements(FormatNode[AcknowledgementsTitleConfig]): """致谢节点""" NODE_TYPE = "acknowledgements" + NODE_LABEL = "致谢标题" CONFIG_MODEL = AcknowledgementsTitleConfig CONFIG_PATH = "acknowledgements.title" - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - # 段落样式 - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - # 字符样式 - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 检查每个 run 的字符格式 - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: # 仅当有差异时添加批注 - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 检查段落格式差异 - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - - return [] - class AcknowledgementsCN(FormatNode[AcknowledgementsContentConfig]): """致谢内容""" NODE_TYPE = "acknowledgements.content" + NODE_LABEL = "致谢内容" CONFIG_MODEL = AcknowledgementsContentConfig CONFIG_PATH = "acknowledgements.content" - - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - # 段落样式 - ps = ParagraphStyle( - alignment=cfg.alignment, - space_before=cfg.space_before, - space_after=cfg.space_after, - line_spacing=cfg.line_spacing, - line_spacingrule=cfg.line_spacingrule, - first_line_indent=cfg.first_line_indent, - builtin_style_name=cfg.builtin_style_name, - ) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - # 字符样式 - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 检查每个 run 的字符格式 - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: # 仅当有差异时添加批注 - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 检查段落格式差异 - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - return [] diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py index b2d3467..3d976ee 100644 --- a/src/wordformat/rules/body.py +++ b/src/wordformat/rules/body.py @@ -9,21 +9,118 @@ from docx.oxml import OxmlElement from docx.oxml.ns import qn -from wordformat.config.datamodel import BodyTextConfig +from wordformat.config.datamodel import BodyTextConfig, PunctuationRule from wordformat.rules.node import FormatNode -from wordformat.style.check_format import CharacterStyle, ParagraphStyle # 匹配正文中的参考文献交叉引用标记 # 支持: [1] [1,2] [1-3] [1,2,3-5] [1,2] [1、2] [1, 2] _CITATION_PATTERN = re.compile(r"\[[\d\-,—、,\s]+\]") +# 半角标点 → 对应全角标点 +_HALF_TO_FULL = { + ",": ",", + ".": "。", + ";": ";", + ":": ":", + "(": "(", + ")": ")", + "[": "【", + "]": "】", +} +# 匹配中文上下文中的半角标点(仅当两侧均为中文字符,或中文+结尾) +_PUNCT_PATTERN = re.compile(r"([一-鿿])([,.;:()\[\]])([一-鿿]|$)") + + +def _split_run_at(paragraph, start: int, end: int): + """拆分段落 run,返回只含 [start, end) 字符的独立 run。""" + from docx.oxml import OxmlElement + + para_elem = paragraph._element + cum = 0 + for r_elem in para_elem.findall(qn("w:r")): + t_elem = r_elem.find(qn("w:t")) + if t_elem is None or t_elem.text is None: + continue + rl = len(t_elem.text) + r_start, r_end = cum, cum + rl + if r_start <= start and end <= r_end: + i0 = start - r_start + i1 = end - r_start + before_text = t_elem.text[:i0] + punct_text = t_elem.text[i0:i1] + after_text = t_elem.text[i1:] + # 修改原 run 为标点前的文本 + t_elem.text = before_text + if before_text and before_text != before_text.strip(): + t_elem.set(qn("xml:space"), "preserve") + # 创建标点 run + rPr = r_elem.find(qn("w:rPr")) + punct_r = OxmlElement("w:r") + if rPr is not None: + punct_r.append(deepcopy(rPr)) + punct_t = OxmlElement("w:t") + punct_t.text = punct_text + punct_t.set(qn("xml:space"), "preserve") + punct_r.append(punct_t) + r_elem.addnext(punct_r) + # 创建标点后的 run + if after_text: + after_r = OxmlElement("w:r") + if rPr is not None: + after_r.append(deepcopy(rPr)) + after_t = OxmlElement("w:t") + after_t.text = after_text + after_t.set(qn("xml:space"), "preserve") + after_r.append(after_t) + punct_r.addnext(after_r) + # 返回新 run 对象 + for r in paragraph.runs: + if r._element is punct_r: + return r + return None + cum += rl + return None + class BodyText(FormatNode[BodyTextConfig]): """正文节点""" NODE_TYPE = "body_text" + NODE_LABEL = "正文段落" CONFIG_MODEL = BodyTextConfig CONFIG_PATH = "body_text" + RULES = {"punctuation": "_check_punctuation"} + + def _check_punctuation(self, doc, rule_cfg: PunctuationRule, p: bool = False): + """检测中文正文中的半角标点,锚在具体字符上(拆分 run)。""" + if self.paragraph is None: + return + # 找出引用标记区间,跳过引用内的标点 + cite_ranges = [ + (m.start(), m.end()) + for m in _CITATION_PATTERN.finditer(self.paragraph.text) + ] + para_text = self.paragraph.text + for m in _PUNCT_PATTERN.finditer(para_text): + p0, p1 = m.start(2), m.end(2) + if any(cs <= p0 < ce for cs, ce in cite_ranges): + continue + half = m.group(2) + full = _HALF_TO_FULL.get(half, half) + # 拆分 run,使标点独占一个 run + target_run = _split_run_at(self.paragraph, p0, p1) + if target_run is None: + target_run = ( + self.paragraph.runs[0] + if self.paragraph.runs + else self.paragraph.runs + ) + msg = ( + f"{self.NODE_LABEL}-提醒-标点符号问题:" + f'使用了半角英文标点符号"{half}(英文)",' + f'规范:应使用全角中文标点符号"{full}(中文)"' + ) + self.add_comment(doc=doc, runs=target_run, text=msg) def apply_replace(self, doc=None) -> bool: """文本替换后清除引用标记格式。 @@ -44,51 +141,17 @@ def apply_replace(self, doc=None) -> bool: return replaced - def _base(self, doc, p: bool, r: bool): - """检查正文段落的字符与段落格式是否符合规范。""" - cfg = self.pydantic_config - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - - return [] - def apply_format(self, doc): - """格式化正文段落,之后将引用标记自动设为上标。""" + """格式化正文段落,引用标记上标仅限第一章。""" super().apply_format(doc) - self._apply_citation_superscript() + chapter = ( + self.value.get("chapter_number", 0) if isinstance(self.value, dict) else 0 + ) + if chapter == 1: + self._apply_citation_superscript() # ------------------------------------------------------------------ - # 引用上标 + # 引用上标(仅第一章) # ------------------------------------------------------------------ def _apply_citation_superscript(self): diff --git a/src/wordformat/rules/caption.py b/src/wordformat/rules/caption.py index 481c61b..5461539 100644 --- a/src/wordformat/rules/caption.py +++ b/src/wordformat/rules/caption.py @@ -10,7 +10,6 @@ TablesConfig, ) from wordformat.rules.node import FormatNode -from wordformat.style.check_format import CharacterStyle, ParagraphStyle from wordformat.utils import parse_caption_text @@ -44,30 +43,68 @@ def _check_caption_numbering( return parsed = parse_caption_text(text) + from wordformat.style.comment_format import format_comment + + target = getattr(node, "NODE_LABEL", "题注") + if parsed is None: - node.add_comment(document, paragraph.runs, f"题注格式无法识别:'{text[:50]}'") + node.add_comment( + document, + paragraph.runs, + format_comment( + target, "格式错误", f"无法识别'{text[:50]}'", "正确题注格式" + ), + ) return issues = [] if parsed["label"] != expected_label: - issues.append(f"题注标签应为'{expected_label}',当前为'{parsed['label']}'") + issues.append( + format_comment( + target, + "标签错误", + f"当前为'{parsed['label']}'", + f"应为'{expected_label}'", + ) + ) # 检查标签与编号之间的空格(跳过续前缀再检查) check_text = text[1:].lstrip() if parsed.get("is_continued") else text label_with_space = check_text.startswith(f"{expected_label} ") if label_with_space != numbering_cfg.label_number_space: want = "有空格" if numbering_cfg.label_number_space else "无空格" - issues.append(f"标签与编号间应为{want}") + issues.append(format_comment(target, "间距错误", "当前不符合", f"应为{want}")) ch = parsed.get("chapter_num") if ch is not None and ch != chapter: - issues.append(f"章节号应为{chapter},当前为{ch}") + issues.append( + format_comment( + target, + "章节号错误", + f"当前为{ch}", + f"应为{chapter}", + ) + ) if parsed["separator"] != separator: - issues.append(f"分隔符应为'{separator}',当前为'{parsed['separator']}'") + issues.append( + format_comment( + target, + "分隔符错误", + f"当前为'{parsed['separator']}'", + f"应为'{separator}'", + ) + ) num = parsed.get("number_num") if num is not None and num != seq: - issues.append(f"题注编号应为{seq},当前为{num}") + issues.append( + format_comment( + target, + "编号错误", + f"当前为{num}", + f"应为{seq}", + ) + ) if issues: - node.add_comment(document, paragraph.runs, ";".join(issues)) + node.add_comment(document, paragraph.runs, "\n".join(issues)) def _apply_caption_numbering( @@ -101,6 +138,7 @@ class CaptionFigure(FormatNode[FiguresConfig]): """题注-图片""" NODE_TYPE = "figures" + NODE_LABEL = "图注" CONFIG_MODEL = FiguresConfig CONFIG_PATH = "figures" RULES = {"caption_numbering": "_handle_caption_numbering"} @@ -113,50 +151,12 @@ def _handle_caption_numbering(self, doc, rule_cfg: CaptionNumberingConfig, p: bo else: _apply_caption_numbering(self, prefix, rule_cfg) - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - # 段落样式 - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - # 字符样式 - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 检查每个 run 的字符格式 - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 检查段落格式差异 - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - class CaptionTable(FormatNode[TablesConfig]): """题注-表格""" NODE_TYPE = "tables" + NODE_LABEL = "表注" CONFIG_MODEL = TablesConfig CONFIG_PATH = "tables" RULES = {"caption_numbering": "_handle_caption_numbering"} @@ -168,42 +168,3 @@ def _handle_caption_numbering(self, doc, rule_cfg: CaptionNumberingConfig, p: bo _check_caption_numbering(self, doc, prefix, rule_cfg) else: _apply_caption_numbering(self, prefix, rule_cfg) - - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - # 段落样式 - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - # 字符样式 - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 检查每个 run 的字符格式 - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 检查段落格式差异 - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) diff --git a/src/wordformat/rules/heading.py b/src/wordformat/rules/heading.py index d4274aa..ccee8d2 100644 --- a/src/wordformat/rules/heading.py +++ b/src/wordformat/rules/heading.py @@ -3,12 +3,10 @@ # @Author : afish # @File : heading.py -from docx.document import Document from loguru import logger from wordformat.config.datamodel import HeadingLevelConfig, NodeConfigRoot from wordformat.rules.node import FormatNode -from wordformat.style.check_format import CharacterStyle, ParagraphStyle class BaseHeadingNode(FormatNode[HeadingLevelConfig]): @@ -57,82 +55,6 @@ def load_config(self, root_config: dict | NodeConfigRoot): logger.error(f"{self.LEVEL}级标题配置加载失败:{str(e)}") raise # 抛出异常,避免后续使用错误配置 - def _base(self, doc: Document, p: bool, r: bool): - """通用标题格式检查逻辑""" - # 修复:空值校验(直接检查底层私有属性 _pydantic_config) - if self._pydantic_config is None: - error_msg = f"{self.LEVEL}级标题配置未加载,跳过检查" - self.add_comment(doc=doc, runs=self.paragraph.runs, text=error_msg) - logger.warning(error_msg) - return [ - {"error": error_msg, "level": self.LEVEL, "node_type": self.NODE_TYPE} - ] - - # 类型断言(读取@property的 pydantic_config,本质是 _pydantic_config) - cfg = self.pydantic_config - - # 段落样式:由 ParagraphStyle.apply_to_paragraph 先应用 builtin_style_name, - # 再应用显式段落格式(对齐、缩进、间距等),后者覆盖前者的对应属性。 - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - # 字符样式检查(完整使用所有配置字段) - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 逐run检查并添加批注 - run_issues = [] - for idx, run in enumerate(self.paragraph.runs): - if not run.text.strip(): - continue # 跳过空白run,减少无效检查 - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: - run_issue = { - "run_index": idx, - "run_text": run.text, - "diff": diff_result, - } - run_issues.append(run_issue) - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 段落样式批注 - all_issues = [] - if paragraph_issues: - para_issue = { - "type": "paragraph_style", - "diff": paragraph_issues, - "paragraph_text": self.paragraph.text[:50] + "..." - if len(self.paragraph.text) > 50 - else self.paragraph.text, - } - all_issues.append(para_issue) - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - - # 添加字符样式问题 - if run_issues: - all_issues.extend(run_issues) - - return all_issues - # 各层级标题节点(无需重写check_format,直接复用基类逻辑) class HeadingLevel1Node(BaseHeadingNode): @@ -140,6 +62,7 @@ class HeadingLevel1Node(BaseHeadingNode): LEVEL = 1 NODE_TYPE = "headings.level_1" + NODE_LABEL = "一级标题" class HeadingLevel2Node(BaseHeadingNode): @@ -147,6 +70,7 @@ class HeadingLevel2Node(BaseHeadingNode): LEVEL = 2 NODE_TYPE = "headings.level_2" + NODE_LABEL = "二级标题" class HeadingLevel3Node(BaseHeadingNode): @@ -154,3 +78,4 @@ class HeadingLevel3Node(BaseHeadingNode): LEVEL = 3 NODE_TYPE = "headings.level_3" + NODE_LABEL = "三级标题" diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py index f0377f1..f828372 100644 --- a/src/wordformat/rules/keywords.py +++ b/src/wordformat/rules/keywords.py @@ -57,7 +57,7 @@ def _check_paragraph_style(self, cfg: KeywordsConfig, p: bool) -> str: issues = ps.diff_from_paragraph(self.paragraph) else: issues = ps.apply_to_paragraph(self.paragraph) - return ParagraphStyle.to_string(issues) + return ParagraphStyle.to_string(issues, target=self.NODE_LABEL) def _split_mixed_runs(self) -> None: """检测标签和内容混合在同一个 run 中的情况,拆分为两个独立的 run。 @@ -119,6 +119,7 @@ class KeywordsEN(BaseKeywordsNode): LANG = "en" NODE_TYPE = "abstract.keywords.english" + NODE_LABEL = "英文关键词" RULES = {"keyword_count": "_check_keyword_count"} def _check_keyword_label(self, run) -> bool: @@ -137,44 +138,37 @@ def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False) r"[,;]", re.sub(r"Keywords?:", "", keyword_text, flags=re.IGNORECASE) ) keyword_list = [k.strip() for k in keyword_list if k.strip()] + from wordformat.style.comment_format import format_comment + + target = self.NODE_LABEL if len(keyword_list) < rule_cfg.count_min: - issue = f"英文关键词数量不足(最少{rule_cfg.count_min}个,当前{len(keyword_list)}个)" + issue = format_comment( + target, + "数量过少", + f"当前{len(keyword_list)}个", + f"{rule_cfg.count_min}-{rule_cfg.count_max}个", + ) self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) if len(keyword_list) > rule_cfg.count_max: - issue = f"英文关键词数量超限(最多{rule_cfg.count_max}个,当前{len(keyword_list)}个)" + issue = format_comment( + target, + "数量过多", + f"当前{len(keyword_list)}个", + f"{rule_cfg.count_min}-{rule_cfg.count_max}个", + ) self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) - def _base(self, doc, p: bool, r: bool): # noqa C901 - """ - 校验英文关键词格式: - - 段落整体格式(对齐、行距等) - - "Keywords:" 部分加粗,其余内容不加粗 - """ + # 覆盖默认 handler:区分标签与内容的字符样式 + def _handle_character_style(self, doc, rule_cfg, p: bool): cfg = self.pydantic_config - - # 1. 段落样式检查 - paragraph_issues = self._check_paragraph_style(cfg, p) - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=paragraph_issues, - ) - - # 2. 拆分标签和内容混合的 run(仅在格式化模式下执行) - if not p: - self._split_mixed_runs() - - # 3. 字符样式检查(区分标签/内容) - label_cfg = cfg.label label_style = CharacterStyle( - font_name_cn=label_cfg.chinese_font_name, - font_name_en=label_cfg.english_font_name, - font_size=label_cfg.font_size, - font_color=label_cfg.font_color, - bold=label_cfg.bold, - italic=label_cfg.italic, - underline=label_cfg.underline, + font_name_cn=cfg.label.chinese_font_name, + font_name_en=cfg.label.english_font_name, + font_size=cfg.label.font_size, + font_color=cfg.label.font_color, + bold=cfg.label.bold, + italic=cfg.label.italic, + underline=cfg.label.underline, ) content_style = CharacterStyle( font_name_cn=cfg.chinese_font_name, @@ -185,33 +179,27 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 italic=cfg.italic, underline=cfg.underline, ) - for run in self.paragraph.runs: if not run.text.strip(): continue - - if self._check_keyword_label(run): - if r: - diff = label_style.diff_from_run(run) - else: - diff = label_style.apply_to_run(run) - if diff: - self.add_comment( - doc=doc, - runs=run, - text=CharacterStyle.to_string(diff), - ) + is_label = self._check_keyword_label(run) + cstyle = label_style if is_label else content_style + target = f"{self.NODE_LABEL}标签" if is_label else f"{self.NODE_LABEL}内容" + if p: + diff = cstyle.diff_from_run(run) else: - if r: - diff = content_style.diff_from_run(run) - else: - diff = content_style.apply_to_run(run) - if diff: - self.add_comment( - doc=doc, - runs=run, - text=CharacterStyle.to_string(diff), - ) + diff = cstyle.apply_to_run(run) + if diff: + self.add_comment( + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff, target=target), + ) + + def _base(self, doc, p: bool, r: bool): + """拆分标签和内容混合的 run(仅在应用模式下执行)""" + if not p: + self._split_mixed_runs() # 第三步:中文关键词节点(专属逻辑) @@ -220,6 +208,7 @@ class KeywordsCN(BaseKeywordsNode): LANG = "cn" NODE_TYPE = "abstract.keywords.chinese" + NODE_LABEL = "中文关键词" RULES = { "keyword_count": "_check_keyword_count", "trailing_punctuation": "_check_trailing_punctuation", @@ -241,66 +230,60 @@ def _check_keyword_count(self, doc, rule_cfg: KeywordCountRule, p: bool = False) keyword_text = "".join([run.text for run in self.paragraph.runs]) keyword_list = re.split(r";", re.sub(r"关键词[::]", "", keyword_text)) keyword_list = [k.strip() for k in keyword_list if k.strip()] + from wordformat.style.comment_format import format_comment + + target = self.NODE_LABEL if len(keyword_list) < rule_cfg.count_min: - issue = f"中文关键词数量不足(最少{rule_cfg.count_min}个,当前{len(keyword_list)}个)" + issue = format_comment( + target, + "数量过少", + f"当前{len(keyword_list)}个", + f"{rule_cfg.count_min}-{rule_cfg.count_max}个", + ) self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) if len(keyword_list) > rule_cfg.count_max: - issue = f"中文关键词数量超限(最多{rule_cfg.count_max}个,当前{len(keyword_list)}个)" + issue = format_comment( + target, + "数量过多", + f"当前{len(keyword_list)}个", + f"{rule_cfg.count_min}-{rule_cfg.count_max}个", + ) self.add_comment(doc=doc, runs=self.paragraph.runs, text=issue) def _check_trailing_punctuation( self, doc, rule_cfg: TrailingPunctRule, p: bool = False ): """校验中文关键词末尾标点""" + from wordformat.style.comment_format import format_comment + keyword_text = "".join([run.text for run in self.paragraph.runs]) if ( keyword_text.strip() and keyword_text.strip()[-1] in rule_cfg.forbidden_chars ): + punct = keyword_text.strip()[-1] self.add_comment( doc=doc, runs=self.paragraph.runs, - text="中文关键词末尾禁止出现标点符号", - ) - - def _base(self, doc, p: bool, r: bool): # noqa C901 - """ - 校验中文关键词格式: - - 段落整体格式(对齐、行距等) - - "关键词:"部分加粗,其余内容不加粗 - """ - # 空值校验 - if self.pydantic_config is None: - self.add_comment( - doc=doc, runs=self.paragraph.runs, text="中文关键词配置未加载,跳过检查" + text=format_comment( + self.NODE_LABEL, + "标点错误", + f"末尾有'{punct}'", + "末尾无标点", + ), ) - return [{"error": "配置未加载", "lang": "cn", "node_type": self.NODE_TYPE}] + # 覆盖默认 handler:区分标签与内容的字符样式 + def _handle_character_style(self, doc, rule_cfg, p: bool): cfg = self.pydantic_config - - # 段落样式检查 - paragraph_issues = self._check_paragraph_style(cfg, p) - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=paragraph_issues, - ) - - # 拆分标签和内容混合的 run(仅在格式化模式下执行) - if not p: - self._split_mixed_runs() - - # 字符样式检查(区分标签/内容) - label_cfg = cfg.label label_style = CharacterStyle( - font_name_cn=label_cfg.chinese_font_name, - font_name_en=label_cfg.english_font_name, - font_size=label_cfg.font_size, - font_color=label_cfg.font_color, - bold=label_cfg.bold, - italic=label_cfg.italic, - underline=label_cfg.underline, + font_name_cn=cfg.label.chinese_font_name, + font_name_en=cfg.label.english_font_name, + font_size=cfg.label.font_size, + font_color=cfg.label.font_color, + bold=cfg.label.bold, + italic=cfg.label.italic, + underline=cfg.label.underline, ) content_style = CharacterStyle( font_name_cn=cfg.chinese_font_name, @@ -311,30 +294,24 @@ def _base(self, doc, p: bool, r: bool): # noqa C901 italic=cfg.italic, underline=cfg.underline, ) - for run in self.paragraph.runs: if not run.text.strip(): continue - - if self._check_keyword_label(run): - if r: - diff = label_style.diff_from_run(run) - else: - diff = label_style.apply_to_run(run) - if diff: - self.add_comment( - doc=doc, - runs=run, - text=CharacterStyle.to_string(diff), - ) + is_label = self._check_keyword_label(run) + cstyle = label_style if is_label else content_style + target = f"{self.NODE_LABEL}标签" if is_label else f"{self.NODE_LABEL}内容" + if p: + diff = cstyle.diff_from_run(run) else: - if r: - diff = content_style.diff_from_run(run) - else: - diff = content_style.apply_to_run(run) - if diff: - self.add_comment( - doc=doc, - runs=run, - text=CharacterStyle.to_string(diff), - ) + diff = cstyle.apply_to_run(run) + if diff: + self.add_comment( + doc=doc, + runs=run, + text=CharacterStyle.to_string(diff, target=target), + ) + + def _base(self, doc, p: bool, r: bool): + """拆分标签和内容混合的 run(仅在应用模式下执行)""" + if not p: + self._split_mixed_runs() diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py index 1ca9d96..569553e 100644 --- a/src/wordformat/rules/node.py +++ b/src/wordformat/rules/node.py @@ -92,9 +92,22 @@ class FormatNode(TreeNode, Generic[T]): CONFIG_MODEL: Type[T] + # 全局错误统计(类变量,一次 check 周期内累加) + _error_stats: dict[str, int] = {"total": 0, "严重": 0, "一般": 0, "提醒": 0} + + # 中文标签,用作批注的 [位置] 部分 + NODE_LABEL: str = "" + # 子类声明:规则名 → handler 方法名,框架自动按 config 启用/禁用调度 + # 只放需要 YAML 配置控制的业务规则。段落/字符格式由 DEFAULT_RULES 自动处理 RULES: dict[str, str] = {} + # 框架自动注入的默认规则,不依赖配置,所有节点默认启用 + DEFAULT_RULES: dict[str, str] = { + "paragraph_style": "_handle_paragraph_style", + "character_style": "_handle_character_style", + } + def __init__( self, value, @@ -107,6 +120,9 @@ def __init__( self.paragraph: Paragraph = paragraph self.expected_rule = expected_rule self._pydantic_config: Optional[T] = None # Pydantic配置对象 + self._comment_texts: list[ + tuple + ] = [] # (runs, text) 缓冲,flush 时按 runs 分组合并 @property def pydantic_config(self) -> T: @@ -159,57 +175,196 @@ def update_paragraph(self, paragraph: Paragraph | dict): self.paragraph = paragraph def _base(self, doc, p: bool, r: bool): - raise NotImplementedError("Subclasses should implement this!") + """子类可覆写以添加自定义逻辑(如拆分混合 run)。标准节点无需覆写。""" def _run_rules(self, doc: Document, p: bool) -> None: - """自动调度业务规则:遍历 RULES,读配置,若 enabled 则执行 handler。 + """自动调度所有规则:DEFAULT_RULES(无需配置)+ RULES(需 YAML 配置)。 p=True 表示检查模式,p=False 表示应用模式。handler 签名为 (doc, rule_cfg, p),p 默认 False 以兼容不需要区分模式的 handler。 - 仅子类声明了 RULES 且有对应配置时才执行。提供双向验证: + DEFAULT_RULES 总是执行,RULES 仅当配置 enabled=true 时执行。提供双向验证: - 配置有规则但无 handler → warning - RULES 声明了但配置无对应项 → warning """ - if not self.RULES: + all_rules = {**self.DEFAULT_RULES, **self.RULES} + if not all_rules: return rules_config = getattr(self._pydantic_config, "rules", None) - if rules_config is None: - logger.warning(f"[{self.NODE_TYPE}] RULES 已声明但配置无 rules 节点") - return - declared_rules = set(self.RULES.keys()) - config_rules = set(type(rules_config).model_fields.keys()) + # 双向验证(仅检查自定义 RULES) + if self.RULES: + if rules_config is None: + logger.warning(f"[{self.NODE_TYPE}] RULES 已声明但配置无 rules 节点") + else: + declared_rules = set(self.RULES.keys()) + config_rules = set(type(rules_config).model_fields.keys()) + orphan_handlers = declared_rules - config_rules + orphan_configs = config_rules - declared_rules + if orphan_handlers: + logger.warning( + f"[{self.NODE_TYPE}] RULES 声明了 {orphan_handlers} 但配置无对应项" + ) + if orphan_configs: + logger.warning( + f"[{self.NODE_TYPE}] 配置有 {orphan_configs} 但无对应 handler" + ) - orphan_handlers = declared_rules - config_rules - orphan_configs = config_rules - declared_rules - if orphan_handlers: - logger.warning( - f"[{self.NODE_TYPE}] RULES 声明了 {orphan_handlers} 但配置无对应项" - ) - if orphan_configs: - logger.warning( - f"[{self.NODE_TYPE}] 配置有 {orphan_configs} 但无对应 handler" - ) + for rule_name, handler_name in all_rules.items(): + # 默认规则无配置,总是执行 + if rule_name in self.DEFAULT_RULES: + handler = getattr(self, handler_name) + handler(doc, None, p) + continue - for rule_name, handler_name in self.RULES.items(): + # 自定义规则:读配置,检查 enabled + if rules_config is None: + continue rule_cfg = getattr(rules_config, rule_name, None) if rule_cfg is None or not rule_cfg.enabled: continue handler = getattr(self, handler_name) handler(doc, rule_cfg, p) + # ------------------------------------------------------------------ + # 默认规则 handler:段落样式 + 字符样式 + # ------------------------------------------------------------------ + + def _handle_paragraph_style(self, doc, rule_cfg, p: bool): + """默认段落样式检查/应用。配置需继承 GlobalFormatConfig。""" + if self.paragraph is None or not self.paragraph.runs: + return + from wordformat.style.check_format import ParagraphStyle + + cfg = self.pydantic_config + if not hasattr(cfg, "alignment"): + return + ps = ParagraphStyle.from_config(cfg) + if p: + issues = ps.diff_from_paragraph(self.paragraph) + 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), + ) + + def _handle_character_style(self, doc, rule_cfg, p: bool): + """默认字符样式检查/应用。配置需继承 GlobalFormatConfig。""" + if not self.paragraph.runs: + return + from wordformat.style.check_format import CharacterStyle + + cfg = self.pydantic_config + if not hasattr(cfg, "chinese_font_name"): + return + cstyle = CharacterStyle( + font_name_cn=cfg.chinese_font_name, + font_name_en=cfg.english_font_name, + font_size=cfg.font_size, + font_color=cfg.font_color, + bold=cfg.bold, + italic=cfg.italic, + underline=cfg.underline, + ) + for run in self.paragraph.runs: + if not run.text.strip(): + continue + if p: + diff = cstyle.diff_from_run(run) + 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), + ) + + def _collect_comment(self, runs, text: str) -> None: + """将批注文本加入缓冲区,(runs, text) 成对存储。""" + if text.strip(): + self._comment_texts.append((runs, text.strip())) + + def _flush_comments(self, doc: Document) -> None: + """将缓冲的批注按锚点分组写入。段落级合并为一条,run 级按 run 分开。""" + if not self._comment_texts or not self.paragraph.runs: + self._comment_texts.clear() + return + groups = self._group_comments() + self._write_comment_groups(doc, groups) + self._comment_texts.clear() + + def _group_comments(self) -> dict[tuple, tuple[tuple, list[str]]]: + """按 runs 分组批注文本。段落级用 ('__para__',),run 级用索引 i。""" + para_runs = list(self.paragraph.runs) + groups: dict[tuple, tuple[tuple, list[str]]] = {} + + def _key(runs): + if isinstance(runs, Sequence) and len(runs) == len(para_runs): + return ("__para__",) + r = runs[0] if isinstance(runs, Sequence) else runs + for i, pr in enumerate(para_runs): + if r is pr or r._element is pr._element: + return (i,) + return (id(runs),) + + for runs, text in self._comment_texts: + k = _key(runs) + if k not in groups: + groups[k] = (runs, []) + groups[k][1].append(text) + return groups + + def _write_comment_groups(self, doc, groups) -> None: + """将分组后的批注写入文档并更新统计。""" + from wordformat.style.comment_format import SEVERITY_ORDER, get_severity + + for runs, texts in groups.values(): + merged = "\n".join(texts) + doc.add_comment( + runs=runs, text=merged, author="Wordformat", initials="afish" + ) + # 提醒级批注用蓝色字体 + max_sev = "提醒" + for t in texts: + sev = get_severity(t) + if SEVERITY_ORDER.get(sev, 3) < SEVERITY_ORDER.get(max_sev, 3): + max_sev = sev + if max_sev == "提醒": + _color_last_comment(doc, "0000FF") + FormatNode._error_stats["total"] += 1 + FormatNode._error_stats[max_sev] = ( + FormatNode._error_stats.get(max_sev, 0) + 1 + ) + for t in texts: + sev = get_severity(t) + if SEVERITY_ORDER.get(sev, 3) < SEVERITY_ORDER.get(max_sev, 3): + max_sev = sev + FormatNode._error_stats[max_sev] = ( + FormatNode._error_stats.get(max_sev, 0) + 1 + ) + + @classmethod + def reset_stats(cls) -> None: + """重置全局错误统计。""" + cls._error_stats = {"total": 0, "严重": 0, "一般": 0, "提醒": 0} + def check_format(self, doc: Document): """格式检查:先执行样式检查,再自动调度业务规则""" self._base(doc, p=True, r=True) self._run_rules(doc, p=True) + 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_replace(self, doc: Document = None) -> bool: """替换段落文本内容(由 JSON 的 replace 字段驱动)。 @@ -282,5 +437,42 @@ def _clean_paragraph_edge_spaces(self) -> None: break def add_comment(self, doc: Document, runs: Run | Sequence[Run], text: str): - if text.strip(): - doc.add_comment(runs=runs, text=text, author="论文解析器", initials="afish") + """追加批注到缓冲区,按锚点 run 分组,flush 时同组合并为一条。""" + self._collect_comment(runs, text) + + +def _color_last_comment(doc, hex_color: str) -> None: + """将文档最后一条批注的文字颜色设为指定色(如 0000FF 蓝色)。""" + try: + comments = doc._part.comments + if comments is None: + return + last = comments[-1] + for p in last._element.findall( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}p" + ): + for r in p.findall( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}r" + ): + rPr = r.find( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}rPr" + ) + if rPr is None: + from docx.oxml import OxmlElement + + rPr = OxmlElement("w:rPr") + r.insert(0, rPr) + color = rPr.find( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}color" + ) + if color is None: + from docx.oxml import OxmlElement + + color = OxmlElement("w:color") + rPr.append(color) + color.set( + "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}val", + hex_color, + ) + except Exception: + pass diff --git a/src/wordformat/rules/object.py b/src/wordformat/rules/object.py new file mode 100644 index 0000000..936db1f --- /dev/null +++ b/src/wordformat/rules/object.py @@ -0,0 +1,62 @@ +"""图片段落和表格对象节点。""" + +from wordformat.config.datamodel import ImageFormatConfig, TableObjectConfig +from wordformat.rules.node import FormatNode +from wordformat.style.comment_format import format_comment + + +class FigureImage(FormatNode[ImageFormatConfig]): + """图片段落节点(包含内联图片的段落,非题注)。 + + 只检查对齐和首行缩进,不检查行距、字体等。 + """ + + NODE_TYPE = "figure_image" + CONFIG_MODEL = ImageFormatConfig + CONFIG_PATH = "figures.image" + NODE_LABEL = "图片段落" + DEFAULT_RULES = {} + + def _base(self, doc, p: bool, r: bool): + """仅检查对齐和首行缩进。""" + from wordformat.style.check_format import _format_para_value + from wordformat.style.style_enum import Alignment, FirstLineIndent + + cfg = self.pydantic_config + expected_align = Alignment(str(cfg.alignment)) + actual_align = expected_align.get_from_paragraph(self.paragraph) + if expected_align != actual_align: + self.add_comment( + doc=doc, + runs=self.paragraph.runs, + text=format_comment( + self.NODE_LABEL, + "对齐错误", + _format_para_value("alignment", actual_align), + _format_para_value("alignment", expected_align), + ), + ) + + expected_indent = FirstLineIndent(str(cfg.first_line_indent)) + actual_indent = expected_indent.get_from_paragraph(self.paragraph) + if expected_indent != actual_indent: + self.add_comment( + doc=doc, + runs=self.paragraph.runs, + text=format_comment( + self.NODE_LABEL, + "首行缩进错误", + _format_para_value("first_line_indent", actual_indent), + _format_para_value("first_line_indent", expected_indent), + ), + ) + + +class TableObject(FormatNode[TableObjectConfig]): + """表格对象节点(表格整体格式,非题注)。""" + + NODE_TYPE = "table_object" + CONFIG_MODEL = TableObjectConfig + CONFIG_PATH = "tables.object" + NODE_LABEL = "表格对象" + DEFAULT_RULES = {} # 表格对象格式由 Word 表格属性控制 diff --git a/src/wordformat/rules/references.py b/src/wordformat/rules/references.py index 4d26288..30dfdf1 100644 --- a/src/wordformat/rules/references.py +++ b/src/wordformat/rules/references.py @@ -5,98 +5,20 @@ from wordformat.config.datamodel import ReferencesContentConfig, ReferencesTitleConfig from wordformat.rules.node import FormatNode -from wordformat.style.check_format import CharacterStyle, ParagraphStyle class References(FormatNode[ReferencesTitleConfig]): """参考文献节点""" NODE_TYPE = "references" + NODE_LABEL = "参考文献标题" CONFIG_MODEL = ReferencesTitleConfig CONFIG_PATH = "references.title" - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - # 段落样式 - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - # 字符样式 - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 检查每个 run 的字符格式 - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: # 仅当有差异时添加批注 - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 检查段落格式差异 - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - return [] - class ReferenceEntry(FormatNode[ReferencesContentConfig]): """参考文献条目节点""" + NODE_LABEL = "参考文献条目" CONFIG_MODEL = ReferencesContentConfig CONFIG_PATH = "references.content" - - def _base(self, doc, p: bool, r: bool): - cfg = self.pydantic_config - # 段落样式 - ps = ParagraphStyle.from_config(cfg) - if p: - paragraph_issues = ps.diff_from_paragraph(self.paragraph) - else: - paragraph_issues = ps.apply_to_paragraph(self.paragraph) - - # 字符样式 - cstyle = CharacterStyle( - font_name_cn=cfg.chinese_font_name, - font_name_en=cfg.english_font_name, - font_size=cfg.font_size, - font_color=cfg.font_color, - bold=cfg.bold, - italic=cfg.italic, - underline=cfg.underline, - ) - - # 检查每个 run 的字符格式 - for run in self.paragraph.runs: - if r: - diff_result = cstyle.diff_from_run(run) - else: - diff_result = cstyle.apply_to_run(run) - if diff_result: # 仅当有差异时添加批注 - self.add_comment( - doc=doc, runs=run, text=CharacterStyle.to_string(diff_result) - ) - - # 检查段落格式差异 - if paragraph_issues: - self.add_comment( - doc=doc, - runs=self.paragraph.runs, - text=ParagraphStyle.to_string(paragraph_issues), - ) - return [] diff --git a/src/wordformat/set_style.py b/src/wordformat/set_style.py index e63c713..fde0290 100644 --- a/src/wordformat/set_style.py +++ b/src/wordformat/set_style.py @@ -410,6 +410,10 @@ def traverse(node, parent_category="", current_chapter: int = 0): node.value["chapter_number"] = chapter node.value["sequence_number"] = seq + # 给所有节点注入章节号(BodyText 引用上标需要) + if isinstance(node.value, dict): + node.value.setdefault("chapter_number", current_chapter) + if node.paragraph: # 先执行内容替换(check/format 两种模式均执行) node.apply_replace(document) @@ -432,20 +436,34 @@ def traverse(node, parent_category="", current_chapter: int = 0): traverse(root_node) +def _clear_matched_flags(root_node): + """清除树中所有节点的 _matched 标记,每次匹配前调用。""" + from collections import deque + + q = deque([root_node]) + while q: + n = q.popleft() + if hasattr(n, "_matched"): + del n._matched + q.extend(n.children) + + def xg(root_node, paragraph): """ - 根据段落对象查找对应的节点 - :param root_node: 树的根节点 - :param paragraph: docx文档的段落对象 - :return: 找到的节点 + 根据 XML 指纹匹配树节点。已匹配过的节点不再参与后续匹配, + 防止多个相同指纹的段落(如空段)绑到同一个节点。 """ + fp = get_paragraph_xml_fingerprint(paragraph) def condition(node): - if getattr(node, "fingerprint", False): - return node.fingerprint == get_paragraph_xml_fingerprint(paragraph) + if getattr(node, "fingerprint", False) and not getattr(node, "_matched", False): + return node.fingerprint == fp return False - return find_and_modify_first(root=root_node, condition=condition) + node = find_and_modify_first(root=root_node, condition=condition) + if node: + node._matched = True + return node def format_table_content( @@ -490,12 +508,12 @@ def format_table_content( para_issues = ps.diff_from_paragraph(paragraph) else: para_issues = ps.apply_to_paragraph(paragraph) - para_text = ParagraphStyle.to_string(para_issues) + para_text = ParagraphStyle.to_string(para_issues, target="表格内容") if para_text.strip(): document.add_comment( runs=paragraph.runs, text=para_text, - author="论文解析器", + author="Wordformat", initials="afish", ) @@ -507,16 +525,144 @@ def format_table_content( diff = cstyle.diff_from_run(run) else: diff = cstyle.apply_to_run(run) - run_text = CharacterStyle.to_string(diff) + run_text = CharacterStyle.to_string(diff, target="表格内容") if run_text.strip(): document.add_comment( runs=run, text=run_text, - author="论文解析器", + author="Wordformat", initials="afish", ) +def _build_check_summary(root_node, document, config_model) -> str: + """遍历树和错误统计,生成检测报告摘要文本。""" + from wordformat.rules.node import FormatNode + + stats = FormatNode._error_stats + total = stats["total"] + + # 遍历树,收集文档级统计 + import re + + def _collect_section(node, sections): + cls_name = type(node).__name__ + para = node.paragraph + if para and para.text.strip(): + text = para.text.strip() + if cls_name == "AbstractContentCN": + chinese_only = "".join(ch for ch in text if "一" <= ch <= "鿿") + if chinese_only: + sections["abstract_cn_chars"] = sections.get( + "abstract_cn_chars", 0 + ) + len(chinese_only) + elif cls_name == "AbstractContentEN": + sections["abstract_en_words"] = sections.get( + "abstract_en_words", 0 + ) + len(text.split()) + elif cls_name == "KeywordsCN": + kw_text = re.sub(r"关键词[::]?\s*", "", text) + kws = [k.strip() for k in re.split(r"[;;]", kw_text) if k.strip()] + if kws: + sections["keyword_cn_count"] = len(kws) + elif cls_name == "KeywordsEN": + kw_text = re.sub(r"Keywords?:?\s*", "", text, flags=re.IGNORECASE) + kws = [k.strip() for k in re.split(r"[,;]", kw_text) if k.strip()] + if kws: + sections["keyword_en_count"] = len(kws) + elif cls_name == "ReferenceEntry": + has_chinese = any("一" <= ch <= "鿿" for ch in text) + if has_chinese: + sections["ref_cn"] = sections.get("ref_cn", 0) + 1 + else: + sections["ref_en"] = sections.get("ref_en", 0) + 1 + # 处理混合节点:AbstractTitleContentCN/EN 的子 BodyText 是摘要正文 + if cls_name == "AbstractTitleContentCN": + for child in node.children: + cp = child.paragraph + if cp and cp.text.strip(): + cn = "".join(ch for ch in cp.text.strip() if "一" <= ch <= "鿿") + cnt = len(cn) + cur = sections.get("abstract_cn_chars", 0) + sections["abstract_cn_chars"] = cur + cnt + elif cls_name == "AbstractTitleContentEN": + for child in node.children: + cp = child.paragraph + if cp and cp.text.strip(): + cnt = len(cp.text.strip().split()) + sections["abstract_en_words"] = ( + sections.get("abstract_en_words", 0) + cnt + ) + for child in node.children: + _collect_section(child, sections) + + sections: dict = {} + _collect_section(root_node, sections) + + # 计算万字差错率 + total_chars = sum( + len(p.text) for p in document.paragraphs if p.text and p.text.strip() + ) + error_rate = (total / max(total_chars, 1)) * 10000 if total else 0 + + # 模板名(从 config 读取) + template_name = getattr(config_model, "template_name", None) or "未知模板" + + lines = [ + "检测结果:", + f"检测模板:《{template_name}》", + f"检测错误数:{total},万字差错率:{error_rate:.1f}", + f"严重错误:{stats.get('严重', 0)},一般错误:{stats.get('一般', 0)},提醒:{stats.get('提醒', 0)}", + ] + + # 字数问题 + word_issues = [] + if sections.get("abstract_cn_chars"): + word_issues.append( + f"中文摘要:规范:300字左右,原文:{sections['abstract_cn_chars']}字" + ) + if sections.get("abstract_en_words"): + word_issues.append( + f"英文摘要:规范:300字左右,原文:{sections['abstract_en_words']}词" + ) + if sections.get("keyword_cn_count"): + word_issues.append( + f"中文关键词:规范:3-5个,原文:{sections['keyword_cn_count']}个" + ) + if sections.get("keyword_en_count"): + word_issues.append( + f"英文关键词:规范:3-5个,原文:{sections['keyword_en_count']}个" + ) + ref_cn = sections.get("ref_cn", 0) + ref_en = sections.get("ref_en", 0) + if ref_cn or ref_en: + word_issues.append( + f"参考文献:规范:不少于15条,原文:中文{ref_cn}条;外文{ref_en}条" + ) + if word_issues: + lines.append("字数问题:") + lines.extend(word_issues) + + lines.append("说明:") + lines.append( + "1.请确保文档中正确使用换行符,硬回车(Enter):指换行且生成新段落;软回车(Shift+Enter):指换行但不生成新段落。" + ) + lines.append("2.图片请使用“嵌入型”环绕方式,表格为无环绕方式。") + lines.append("3.提醒不计算错误。") + + return "\n".join(lines) + + +def _add_summary_comment(document, summary: str) -> None: + """将检测报告摘要作为批注添加到文档第一段。空段临时塞空 run 做锚点。""" + para = document.paragraphs[0] + if not para.runs: + para.add_run("") + document.add_comment( + runs=para.runs, text=summary, author="Wordformat", initials="afish" + ) + + def auto_format_thesis_document( jsonpath: str | list, docxpath: str, @@ -585,9 +731,8 @@ def auto_format_thesis_document( style_list.append(style.name) logger.info(f"可用的样式有:{style_list}") + _clear_matched_flags(root_node) for paragraph in document.paragraphs: - if not paragraph.text: - continue node = xg(root_node, paragraph) if node: node.paragraph = paragraph @@ -607,10 +752,18 @@ def auto_format_thesis_document( _fix_all_style_definitions(document, config_model) # 执行格式化 + if check: + FormatNode.reset_stats() apply_format_check_to_all_nodes(root_node, document, config_model, check) - # 表格内容格式化 - format_table_content(document, config_model, check) + # 表格内容格式化(已移除:表格内格式由 Word 表格样式控制,不做段落/字符级检查) + # format_table_content(document, config_model, check) + + # 检测报告摘要(仅 check 模式) + if check: + summary = _build_check_summary(root_node, document, config_model) + if summary: + _add_summary_comment(document, summary) # 处理标题自动编号(仅在格式化模式下执行,检查模式不修改编号) if ( diff --git a/src/wordformat/style/check_format.py b/src/wordformat/style/check_format.py index 7d80e01..0dd0c81 100644 --- a/src/wordformat/style/check_format.py +++ b/src/wordformat/style/check_format.py @@ -35,6 +35,114 @@ style_checks_warning: WarningFieldConfig | None = None +def _char_warning_enabled(diff_type: str) -> bool: + """判断某个字符 diff_type 是否在 WarningFieldConfig 中开启。""" + if style_checks_warning is None: + return True + mapping = { + "bold": style_checks_warning.bold, + "italic": style_checks_warning.italic, + "underline": style_checks_warning.underline, + "font_size": style_checks_warning.font_size, + "font_color": style_checks_warning.font_color, + "font_name_cn": style_checks_warning.font_name, + "font_name_en": style_checks_warning.font_name, + } + return mapping.get(diff_type, True) + + +# 磅值 → 中文字号反向映射 +_FONT_SIZE_PT_LABELS: dict[float, str] = { + 26: "一号", + 24: "小一", + 22: "二号", + 18: "小二", + 16: "三号", + 15: "小三", + 14: "四号", + 12: "小四", + 10.5: "五号", + 9: "小五", + 7.5: "六号", + 5.5: "七号", +} + + +def _pt_to_label(pt: float) -> str: + """磅值 → 中文标签。精确匹配显示字号,否则直接显示 Xpt。""" + return _FONT_SIZE_PT_LABELS.get(pt, f"{pt}pt") + + +def _format_char_value(diff_type: str, value) -> str: + """将字符 diff 的值格式化为可读文本。""" + if diff_type == "bold": + return "加粗" if value else "不加粗" + if diff_type == "italic": + return "斜体" if value else "非斜体" + if diff_type == "underline": + return "有下划线" if value else "无下划线" + if diff_type == "font_size" and isinstance(value, (int, float)): + return _pt_to_label(float(value)) + return str(value) + + +_LINE_SPACING_LABELS = { + 0: "单倍行距", + 1: "1.5倍行距", + 2: "2倍行距", + 3: "最小值", + 4: "固定值", + 5: "多倍行距", +} + +_ALIGNMENT_LABELS = { + 0: "左对齐", + 1: "居中对齐", + 2: "右对齐", + 3: "两端对齐", + 4: "分散对齐", +} + + +def _format_para_value(diff_type: str, value) -> str: + """将段落 diff 的值格式化为可读文本。""" + if value is None: + return "未设置" + if diff_type == "line_spacing_rule": + try: + return _LINE_SPACING_LABELS.get(int(value), str(value)) + except (ValueError, TypeError): + return str(value) + if diff_type == "alignment": + try: + return _ALIGNMENT_LABELS.get(int(value), str(value)) + except (ValueError, TypeError): + return str(value) + if diff_type == "line_spacing": + if isinstance(value, (int, float)): + return f"{value}倍" + return str(value) + return str(value) + + +def _para_warning_enabled(diff_type: str) -> bool: + """判断某个段落 diff_type 是否在 WarningFieldConfig 中开启。""" + if style_checks_warning is None: + return True + mapping = { + "alignment": style_checks_warning.alignment, + "space_before": style_checks_warning.space_before, + "space_after": style_checks_warning.space_after, + "line_spacing": style_checks_warning.line_spacing, + "line_spacing_rule": style_checks_warning.line_spacingrule, + "first_line_indent": style_checks_warning.first_line_indent, + "left_indent": style_checks_warning.left_indent, + "right_indent": style_checks_warning.right_indent, + "builtin_style_name": style_checks_warning.builtin_style_name, + } + return mapping.get(diff_type, True) + + @dataclass class DIFFResult: """ @@ -100,8 +208,8 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901 diffs = [] - # 1. 加粗 - bold = run.font.bold + # 1. 加粗(None = 继承,视为不加粗) + bold = bool(run.font.bold) if bold != self.bold: diffs.append( DIFFResult( @@ -112,8 +220,8 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901 1, ) ) - # 2. 斜体 - italic = run.font.italic + # 2. 斜体(None = 继承,视为非斜体) + italic = bool(run.font.italic) if italic != self.italic: diffs.append( DIFFResult( @@ -125,8 +233,8 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901 ) ) - # 3. 下划线 - underline = run.font.underline + # 3. 下划线(None = 继承,视为无下划线) + underline = bool(run.font.underline) if underline != self.underline: diffs.append( DIFFResult( @@ -169,8 +277,8 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901 ) # 6. 东亚字体 - font_name = run_get_font_name(run) - if font_name != self.font_name_cn: + font_name = run_get_font_name(run) or "" + if str(font_name).lower() != str(self.font_name_cn).lower(): diffs.append( DIFFResult( "font_name_cn", @@ -180,9 +288,9 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901 1, ) ) - # 7. 非东亚字体 - ascii_font = run.font.name # 注意:可能为 None - if ascii_font != self.font_name_en: + # 7. 非东亚字体(None = 继承,视为空) + ascii_font = run.font.name or "" + if str(ascii_font).lower() != str(self.font_name_en).lower(): diffs.append( DIFFResult( "font_name_en", @@ -235,36 +343,20 @@ def apply_to_run(self, run: Run): return result @staticmethod - def to_string(value: list[DIFFResult]) -> str: - """ - 将列表的DIFFResult转换为字符串 - Args: - value: + def to_string(value: list[DIFFResult], target: str = "") -> str: + """将 DIFFResult 列表转为标准格式批注文本。""" + from .comment_format import CHAR_DIFF_LABELS, format_comment - Returns: - - """ - if style_checks_warning is None: - return "\n".join([str(i) for i in value]) t = [] for diff in value: - if style_checks_warning.bold and diff.diff_type == "bold": - t.append(diff) - if style_checks_warning.italic and diff.diff_type == "italic": - t.append(diff) - if style_checks_warning.underline and diff.diff_type == "underline": - t.append(diff) - if style_checks_warning.font_size and diff.diff_type == "font_size": - t.append(diff) - if style_checks_warning.font_color and diff.diff_type == "font_color": - t.append(diff) - if style_checks_warning.font_name and diff.diff_type in ( - "font_name_cn", - "font_name_en", - ): - t.append(diff) - - return "\n".join([str(i) for i in t]) + if style_checks_warning is not None: + if not _char_warning_enabled(diff.diff_type): + continue + prop = CHAR_DIFF_LABELS.get(diff.diff_type, diff.diff_type) + actual = _format_char_value(diff.diff_type, diff.current_value) + standard = _format_char_value(diff.diff_type, diff.expected_value) + t.append(format_comment(target, prop, actual, standard)) + return "\n".join(t) class ParagraphStyle: @@ -451,8 +543,8 @@ def diff_from_paragraph(self, paragraph: Paragraph) -> list[DIFFResult]: # noqa 1, ) ) - # 缩进:文本之前 - left_indent = self.left_indent.get_from_paragraph(paragraph) + # 缩进:文本之前(None = 未设置,视为0) + left_indent = self.left_indent.get_from_paragraph(paragraph) or 0 if self.left_indent != left_indent: diffs.append( DIFFResult( @@ -463,8 +555,8 @@ def diff_from_paragraph(self, paragraph: Paragraph) -> list[DIFFResult]: # noqa 1, ) ) - # 文本之后缩进 - right_indent = self.right_indent.get_from_paragraph(paragraph) + # 文本之后缩进(None = 未设置,视为0) + right_indent = self.right_indent.get_from_paragraph(paragraph) or 0 if self.right_indent != right_indent: diffs.append( DIFFResult( @@ -490,31 +582,20 @@ def diff_from_paragraph(self, paragraph: Paragraph) -> list[DIFFResult]: # noqa return sorted(diffs, key=lambda x: x.level) @staticmethod - def to_string(value: list[DIFFResult]) -> str: - if style_checks_warning is None: - return "\n".join([str(i) for i in value]) - t = [] - attr = [ - "alignment", - "space_before", - "space_after", - "line_spacing", - "line_spacingrule", - "first_line_indent", - "left_indent", - "right_indent", - "builtin_style_name", - ] - warning = {} - for attr_name in attr: - warning[attr_name] = getattr(style_checks_warning, attr_name) + def to_string(value: list[DIFFResult], target: str = "") -> str: + """将 DIFFResult 列表转为标准格式批注文本。""" + from .comment_format import PARA_DIFF_LABELS, format_comment + t = [] for diff in value: - type_name = diff.diff_type - if warning.get(type_name): - t.append(diff) - - return "\n".join([str(i) for i in t]) + if style_checks_warning is not None: + if not _para_warning_enabled(diff.diff_type): + continue + prop = PARA_DIFF_LABELS.get(diff.diff_type, diff.diff_type) + actual = _format_para_value(diff.diff_type, diff.current_value) + standard = _format_para_value(diff.diff_type, diff.expected_value) + t.append(format_comment(target, prop, actual, standard)) + return "\n".join(t) @classmethod def from_config(cls, config: Any) -> "ParagraphStyle": diff --git a/src/wordformat/style/comment_format.py b/src/wordformat/style/comment_format.py new file mode 100644 index 0000000..bccd569 --- /dev/null +++ b/src/wordformat/style/comment_format.py @@ -0,0 +1,77 @@ +"""批注文本格式化工具。 + +所有 add_comment 调用最终由此模块生成符合标准格式的批注文本: + [位置]-[问题类型]:[现状],规范:[标准] +""" + +# diff_type → 中文问题类型 +CHAR_DIFF_LABELS: dict[str, str] = { + "font_size": "字号错误", + "bold": "加粗错误", + "italic": "斜体错误", + "underline": "下划线错误", + "font_color": "字体颜色错误", + "font_name_cn": "中文字体错误", + "font_name_en": "英文字体错误", +} + +PARA_DIFF_LABELS: dict[str, str] = { + "alignment": "对齐错误", + "first_line_indent": "首行缩进错误", + "line_spacing": "行距问题", + "line_spacing_rule": "行距类型问题", + "space_before": "段前间距错误", + "space_after": "段后间距错误", + "left_indent": "左缩进错误", + "right_indent": "右缩进错误", + "builtin_style_name": "内置样式错误", +} + + +# 问题类型 → 严重等级 +SEVERITY_MAP: dict[str, str] = { + "行距问题": "严重", + "对齐错误": "严重", + "首行缩进错误": "一般", + "段前间距错误": "一般", + "段后间距错误": "一般", + "行距类型问题": "一般", + "左缩进错误": "一般", + "右缩进错误": "一般", + "内置样式错误": "一般", + "字号错误": "一般", + "加粗错误": "一般", + "数量过少": "一般", + "数量过多": "一般", + "编号错误": "一般", + "章节号错误": "一般", + "分隔符错误": "提醒", + "标签错误": "提醒", + "间距错误": "提醒", + "格式错误": "提醒", + "斜体错误": "提醒", + "下划线错误": "提醒", + "字体颜色错误": "提醒", + "中文字体错误": "提醒", + "英文字体错误": "提醒", + "标点错误": "提醒", +} + +_DEFAULT_SEVERITY = "一般" + +# 严重等级排序权重(数值越小越严重) +SEVERITY_ORDER: dict[str, int] = {"严重": 0, "一般": 1, "提醒": 2} + + +def get_severity(comment_text: str) -> str: + """从批注文本中提取严重等级。格式:位置-问题类型:...""" + try: + prop = comment_text.split("-", 1)[1].split(":")[0] + except (IndexError, AttributeError): + return _DEFAULT_SEVERITY + return SEVERITY_MAP.get(prop, _DEFAULT_SEVERITY) + + +def format_comment(target: str, property_name: str, actual: str, standard: str) -> str: + """生成标准批注文本:位置-问题类型:现状,规范:标准。""" + return f"{target}-{property_name}:{actual},规范:{standard}" diff --git a/src/wordformat/style/get_some.py b/src/wordformat/style/get_some.py index 71cea62..b9b46ed 100644 --- a/src/wordformat/style/get_some.py +++ b/src/wordformat/style/get_some.py @@ -209,26 +209,13 @@ def paragraph_get_space_after(paragraph) -> float | None: def paragraph_get_line_spacing(paragraph): # noqa c901 - """ - 获取段落的行距(仅当为“倍数”类型时返回 float,否则返回 None)。 - - 支持的倍数类型: - - SINGLE → 1.0 - - ONE_POINT_FIVE → 1.5 - - DOUBLE → 2.0 - - MULTIPLE → 自定义 float(如 2.3) - - 不支持的类型(返回 None): - - AT_LEAST - - EXACTLY - - 其他异常情况 - """ + """Return line spacing as float; fallback to style chain.""" try: - fmt = paragraph.paragraph_format - rule = fmt.line_spacing_rule - spacing = fmt.line_spacing + from wordformat.style.style_enum import _get_with_style_fallback - # 映射预设规则到倍数值 + rule = _get_with_style_fallback(paragraph, "line_spacing_rule", None) + if rule is None: + return None if rule == WD_LINE_SPACING.SINGLE: return 1.0 elif rule == WD_LINE_SPACING.ONE_POINT_FIVE: @@ -236,53 +223,44 @@ def paragraph_get_line_spacing(paragraph): # noqa c901 elif rule == WD_LINE_SPACING.DOUBLE: return 2.0 elif rule == WD_LINE_SPACING.MULTIPLE: - # spacing 应为 float(如 2.3) + spacing = _get_with_style_fallback(paragraph, "line_spacing", None) if isinstance(spacing, (int, float)) and spacing > 0: return float(spacing) - else: - # 异常值兜底 - return None - else: - # AT_LEAST, EXACTLY 等固定值类型,不视为“倍数行距” - return None - + return None except (AttributeError, TypeError): - # 段落格式异常 return None def paragraph_get_first_line_indent(paragraph: Paragraph) -> float | None: # noqa c901 - """ - 精准获取首行缩进,优先解析XML字符单位(firstLineChars),不兼容物理单位 - :param para: 目标段落对象 - :return: 字符数为浮点数 - """ - try: - p = paragraph._element - pPr = p.find(qn("w:pPr")) - if pPr is None: - return None + """获取首行缩进(字符单位)。优先段落自身,无则查样式链。""" - # 获取XML中的ind节点(缩进核心节点) - ind = pPr.find(qn("w:ind")) + def _read(pPr_elem): + ind = pPr_elem.find(qn("w:ind")) if ind is None: return None - - # 步骤1:优先解析字符单位 firstLineChars(核心:值=字符数×100) - first_line_chars = ind.get(qn("w:firstLineChars")) - if first_line_chars and first_line_chars.lstrip("-").isdigit(): - chars_num = int(first_line_chars) / 100 # 200 → 2.0字符 - return chars_num - - # 步骤2:解析悬挂缩进 hangingChars(返回负值表示悬挂缩进) - hanging_chars = ind.get(qn("w:hangingChars")) - if hanging_chars and hanging_chars.isdigit(): - chars_num = -int(hanging_chars) / 100 # 220 → -2.2字符 - return chars_num - - # 无任何缩进设置 + v = ind.get(qn("w:firstLineChars")) + if v and v.lstrip("-").isdigit(): + return int(v) / 100 + v = ind.get(qn("w:hangingChars")) + if v and v.isdigit(): + return -int(v) / 100 return None + try: + pPr = paragraph._element.find(qn("w:pPr")) + if pPr is not None: + val = _read(pPr) + if val is not None: + return val + style = paragraph.style + while style is not None: + sPr = style.element.find(qn("w:pPr")) + if sPr is not None: + val = _read(sPr) + if val is not None: + return val + style = style.base_style + return None except Exception as e: logger.error(f"获取首行缩进失败:{e}") return None diff --git a/src/wordformat/style/style_enum.py b/src/wordformat/style/style_enum.py index 4d6e9bf..58706e9 100644 --- a/src/wordformat/style/style_enum.py +++ b/src/wordformat/style/style_enum.py @@ -72,6 +72,15 @@ class UnitLabelEnum(metaclass=UnitEnumMeta): _LABEL_MAP = {} + @classmethod + def _missing_(cls, value): + """处理非预定义枚举值(如自定义样式名、任意字体名)。""" + member = object.__new__(cls) + member._name_ = str(value) + member._value_ = value + member.__init__(value) + return member + def __init__(self, value): self.value = value # 获取的原始值 self.original_unit = None # 解析的单位 @@ -170,7 +179,9 @@ def __eq__(self, other): if isinstance(other, self.__class__): return self.rel_value == other.rel_value if isinstance(self.rel_value, str): - return self.value.lower() == other + return str(self.rel_value).lower() == str(other).lower() + if other is None: + return self.rel_value == 0 return self.rel_value == other @@ -474,6 +485,23 @@ def get_from_paragraph(self, paragraph: Paragraph) -> float | None: return None +def _get_with_style_fallback(paragraph, attr: str, default): + """从段落或样式链读取属性值。""" + val = getattr(paragraph.paragraph_format, attr, None) + if val is not None: + return val + style = paragraph.style + while style is not None: + try: + val = getattr(style.paragraph_format, attr, None) + if val is not None: + return val + except AttributeError: + pass + style = style.base_style + return default + + class LineSpacingRule(UnitLabelEnum): """ 设置行距选项 @@ -497,9 +525,18 @@ def base_set(self, docx_obj: Paragraph, **kwargs): raise ValueError(f"无效的行距选项: '{self.value}'") def get_from_paragraph(self, paragraph: Paragraph): - # 默认单倍行距 - linespacingrule = paragraph.paragraph_format.line_spacing_rule - return linespacingrule if linespacingrule else WD_LINE_SPACING.MULTIPLE + rule = _get_with_style_fallback( + paragraph, "line_spacing_rule", WD_LINE_SPACING.MULTIPLE + ) + if rule == WD_LINE_SPACING.MULTIPLE: + spacing = _get_with_style_fallback(paragraph, "line_spacing", None) + if spacing == 1.0: + return WD_LINE_SPACING.SINGLE + if spacing == 1.5: + return WD_LINE_SPACING.ONE_POINT_FIVE + if spacing == 2.0: + return WD_LINE_SPACING.DOUBLE + return rule class LineSpacing(UnitLabelEnum): diff --git a/src/wordformat/word_structure/settings.py b/src/wordformat/word_structure/settings.py index 272100a..50deb2c 100644 --- a/src/wordformat/word_structure/settings.py +++ b/src/wordformat/word_structure/settings.py @@ -13,12 +13,14 @@ BodyText, CaptionFigure, CaptionTable, + FigureImage, HeadingLevel1Node, HeadingLevel2Node, HeadingLevel3Node, KeywordsCN, KeywordsEN, References, + TableObject, ) # 标签节点映射 @@ -38,6 +40,8 @@ "acknowledgements_title": Acknowledgements, "caption_figure": CaptionFigure, "caption_table": CaptionTable, + "figure_image": FigureImage, + "table_object": TableObject, "body_text": BodyText, } LEVEL_MAP = { diff --git a/tests/test_caption_numbering.py b/tests/test_caption_numbering.py index 9dcfd9f..4ca3ac0 100644 --- a/tests/test_caption_numbering.py +++ b/tests/test_caption_numbering.py @@ -280,7 +280,7 @@ def test_wrong_chapter_adds_comment(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "章节号应为1" in mock_comment.call_args[0][2] + assert "章节号错误" in mock_comment.call_args[0][2] def test_wrong_separator_adds_comment(self): from wordformat.rules.caption import _check_caption_numbering @@ -294,7 +294,7 @@ def test_wrong_separator_adds_comment(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "分隔符" in mock_comment.call_args[0][2] + assert "分隔符错误" in mock_comment.call_args[0][2] def test_wrong_label_adds_comment(self): from wordformat.rules.caption import _check_caption_numbering @@ -308,7 +308,7 @@ def test_wrong_label_adds_comment(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "标签" in mock_comment.call_args[0][2] + assert "标签错误" in mock_comment.call_args[0][2] def test_wrong_sequence_adds_comment(self): from wordformat.rules.caption import _check_caption_numbering @@ -322,7 +322,7 @@ def test_wrong_sequence_adds_comment(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "题注编号应为2" in mock_comment.call_args[0][2] + assert "编号错误" in mock_comment.call_args[0][2] def test_unparseable_adds_comment(self): from wordformat.rules.caption import _check_caption_numbering @@ -336,7 +336,7 @@ def test_unparseable_adds_comment(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "无法识别" in mock_comment.call_args[0][2] + assert "格式错误" in mock_comment.call_args[0][2] def test_label_space_enabled_no_space_in_text(self): """label_number_space=true,题注无空格 → 添加批注。""" @@ -351,7 +351,7 @@ def test_label_space_enabled_no_space_in_text(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "标签与编号间应为有空格" in mock_comment.call_args[0][2] + assert "间距错误" in mock_comment.call_args[0][2] def test_label_space_disabled_with_space_in_text(self): """label_number_space=false,题注有空格 → 添加批注。""" @@ -366,7 +366,7 @@ def test_label_space_disabled_with_space_in_text(self): _check_caption_numbering(node, doc, "图", cfg) mock_comment.assert_called_once() - assert "应为无空格" in mock_comment.call_args[0][2] + assert "间距错误" in mock_comment.call_args[0][2] def test_disabled_does_nothing(self): """配置禁用时不检查。此逻辑在 _base() 中判断,这里验证 disabled 的 cfg 不触发。""" @@ -424,7 +424,7 @@ def test_continued_table_label_space_wrong(self): _check_caption_numbering(node, doc, "表", cfg) mock_comment.assert_called_once() - assert "标签与编号间应为有空格" in mock_comment.call_args[0][2] + assert "间距错误" in mock_comment.call_args[0][2] # ======================== _apply_caption_numbering ======================== @@ -635,10 +635,10 @@ def _suppress_format_comments(self): mock_cs.diff_from_run.return_value = {} mock_cs.apply_to_run.return_value = {} with patch( - "wordformat.rules.caption.ParagraphStyle.from_config", + "wordformat.style.check_format.ParagraphStyle.from_config", return_value=mock_ps, ), patch( - "wordformat.rules.caption.CharacterStyle", + "wordformat.style.check_format.CharacterStyle", return_value=mock_cs, ): yield @@ -674,7 +674,7 @@ def test_check_mode_wrong_chapter_adds_comment(self, caption_yaml): apply_format_check_to_all_nodes(heading, doc, config, check=True) mock_comment.assert_called_once() - assert "章节号应为1" in mock_comment.call_args[0][2] + assert "章节号错误" in mock_comment.call_args[0][2] @pytest.mark.usefixtures("_suppress_format_comments") def test_apply_mode_rewrites_caption(self, caption_yaml): diff --git a/tests/test_core.py b/tests/test_core.py index df9bb0a..692b084 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -544,30 +544,40 @@ def test_update_paragraph(self, doc): node.update_paragraph(p) assert node.paragraph is p - def test_base_raises_not_implemented(self, doc): + def test_base_is_noop(self, doc): + """_base() 默认为空操作。""" node = FormatNode(value="test", level=1) - with pytest.raises(NotImplementedError, match="Subclasses should implement"): - node._base(doc, p=True, r=True) + node._base(doc, p=True, r=True) + node._base(doc, p=False, r=False) def test_check_format_raises(self, doc): - node = FormatNode(value="test", level=1) - with pytest.raises(NotImplementedError): + """未加载配置时 check_format 通过 handler 触发 ValueError。""" + p = doc.add_paragraph("test") + node = FormatNode(value="test", level=1, paragraph=p) + with pytest.raises(ValueError, match="尚未加载"): node.check_format(doc) def test_apply_format_raises(self, doc): - node = FormatNode(value="test", level=1) - with pytest.raises(NotImplementedError): + """未加载配置时 apply_format 通过 handler 触发 ValueError。""" + p = doc.add_paragraph("test") + node = FormatNode(value="test", level=1, paragraph=p) + with pytest.raises(ValueError, match="尚未加载"): node.apply_format(doc) - def test_add_comment_with_text(self, doc): + def test_add_comment_buffers(self, doc): + """add_comment 缓冲文本,_flush_comments 合并写入。""" node = FormatNode(value="test", level=1) p = doc.add_paragraph("hello") - run = p.runs[0] + node.paragraph = p + node.add_comment(doc, p.runs[0], "格式错误") + node.add_comment(doc, p.runs[0], "字体问题") with patch.object(doc, "add_comment") as mock_add: - node.add_comment(doc, run, "格式错误") - mock_add.assert_called_once_with( - runs=run, text="格式错误", author="论文解析器", initials="afish" - ) + node._flush_comments(doc) + mock_add.assert_called_once() + merged = mock_add.call_args[1]["text"] + assert "格式错误" in merged + assert "字体问题" in merged + assert merged.count("\n") == 1 def test_add_comment_empty_text_skipped(self, doc): node = FormatNode(value="test", level=1) diff --git a/tests/test_coverage_boost.py b/tests/test_coverage_boost.py index 3c30635..230fb6d 100644 --- a/tests/test_coverage_boost.py +++ b/tests/test_coverage_boost.py @@ -73,11 +73,11 @@ def _load_root_config(config_path): @pytest.fixture -def root_config(config_path): - """从 example/undergrad_thesis.yaml 加载真实 NodeConfigRoot。""" +def root_config(sample_yaml_config): + """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.config import init_config - init_config(config_path) - return _load_root_config(config_path) + init_config(sample_yaml_config) + return _load_root_config(sample_yaml_config) # =========================================================================== @@ -109,7 +109,7 @@ def test_apply_format_sets_paragraph_style_and_alignment(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) # builtin_style_name 已设置为 Heading 1(python-docx 存储为 "Heading1") assert p.style.name == "Heading 1" @@ -141,7 +141,7 @@ def test_apply_format_updates_existing_pstyle(self, root_config): node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) # python-docx 通过 paragraph.style = "Heading 2" 更新了样式 assert p.style.name == "Heading 2" @@ -280,7 +280,7 @@ def test_apply_to_paragraph_path_cn(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) def test_apply_to_paragraph_path_en(self, root_config): """覆盖行 56 (EN): apply_to_paragraph 路径(p=False)。 @@ -293,7 +293,7 @@ def test_apply_to_paragraph_path_en(self, root_config): node = KeywordsEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) def test_split_mixed_runs_cn(self, root_config): """覆盖行 69-106: _split_mixed_runs 拆分标签和内容混合的 run。 @@ -306,7 +306,7 @@ def test_split_mixed_runs_cn(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) # 拆分后应有多个 run assert len(p.runs) >= 2 @@ -319,7 +319,7 @@ def test_split_mixed_runs_en(self, root_config): node = KeywordsEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) assert len(p.runs) >= 2 @@ -344,7 +344,7 @@ def test_split_mixed_runs_in_format_mode_cn(self, root_config): node.load_config(root_config) initial_run_count = len(p.runs) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) # 格式化模式下拆分后 run 数量应增加 assert len(p.runs) >= initial_run_count @@ -361,7 +361,7 @@ def test_en_label_apply_to_run(self, root_config): node = KeywordsEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_en_content_apply_to_run(self, root_config): @@ -379,7 +379,7 @@ def test_en_content_apply_to_run(self, root_config): node = KeywordsEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_cn_label_apply_to_run(self, root_config): @@ -393,7 +393,7 @@ def test_cn_label_apply_to_run(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_cn_content_apply_to_run(self, root_config): @@ -408,7 +408,7 @@ def test_cn_content_apply_to_run(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_cn_split_mixed_runs_format_mode(self, root_config): @@ -419,7 +419,7 @@ def test_cn_split_mixed_runs_format_mode(self, root_config): node = KeywordsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment"): - node._base(doc, p=False, r=False) + node.apply_format(doc) assert len(p.runs) >= 2 def test_cn_get_label_split_pattern(self, root_config): diff --git a/tests/test_integration.py b/tests/test_integration.py index 14170d3..aa86f8f 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -400,7 +400,6 @@ def test_set_tag_main_calls_parse(self, mock_docx_cls): {"category": "body_text", "score": 0.9, "paragraph": "test", "fingerprint": "fp1"} ] result = set_tag_main("dummy.docx", "dummy.yaml") - assert isinstance(result, list) assert len(result) == 1 mock_instance.parse.assert_called_once() @@ -997,7 +996,6 @@ def test_single_infer_truncation(self): def test_batch_infer_empty_texts(self): """Empty texts list returns [] (line 156)""" result = onnx_batch_infer([]) - assert result == [] def test_batch_infer_loads_model_when_tokenizer_none(self): """_tokenizer is None triggers _load_model (line 159)""" @@ -1075,7 +1073,6 @@ def test_batch_infer_result_assembly(self): def test_safe_batch_infer_empty_texts(self): """Empty texts returns [] (line 244)""" result = safe_batch_infer([]) - assert result == [] # ==================== (m) keywords.py 覆盖测试 ==================== @@ -1168,7 +1165,7 @@ def test_empty_run_skip(self, sample_yaml_config): empty_run.text = " " node.paragraph = p # Should not crash, empty run is skipped - node._base(doc, p=True, r=True) + node.check_format(doc) def test_label_style_check(self, sample_yaml_config): """Label run style is checked (line 121)""" @@ -1182,7 +1179,7 @@ def test_label_style_check(self, sample_yaml_config): label_run = p.add_run("Keywords: ") label_run.font.bold = False # Wrong - should be bold per config node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) # Should have added a comment about bold mismatch def test_content_style_check(self, sample_yaml_config): @@ -1199,7 +1196,7 @@ def test_content_style_check(self, sample_yaml_config): content_run = p.add_run("AI, ML") content_run.font.bold = True # Wrong - content should not be bold node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) def test_keyword_count_validation_min(self, sample_yaml_config): """Keyword count < count_min triggers warning (via _run_rules)""" @@ -1248,8 +1245,8 @@ def _make_cn_node(self, config_dict=None): node.load_config(config_dict) return node - def test_config_none_check(self): - """pydantic_config is None -> returns error (lines 177-180)""" + def test_config_none_raises(self): + """未加载配置时 check_format 抛出 ValueError。""" from wordformat.rules.keywords import KeywordsCN node = KeywordsCN( value={"category": "abstract.keywords.chinese", "fingerprint": "fp"}, @@ -1257,13 +1254,10 @@ def test_config_none_check(self): ) doc = Document() p = doc.add_paragraph() - p.add_run("关键词:测试") # Need at least one run for add_comment + p.add_run("关键词:测试") node.paragraph = p - # Mock pydantic_config property to return None (bypassing the ValueError guard) - with mock.patch.object(type(node), 'pydantic_config', new_callable=mock.PropertyMock, return_value=None): - result = node._base(doc, p=True, r=True) - assert len(result) == 1 - assert result[0]["error"] == "配置未加载" + with pytest.raises(ValueError, match="尚未加载"): + node.check_format(doc) def test_paragraph_style_check(self, sample_yaml_config): """Paragraph style is checked (line 187)""" @@ -1277,7 +1271,7 @@ def test_paragraph_style_check(self, sample_yaml_config): p.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER # Wrong - should be left run = p.add_run("关键词:人工智能") node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) def test_label_style_check(self, sample_yaml_config): """Label run style is checked (line 218)""" @@ -1291,7 +1285,7 @@ def test_label_style_check(self, sample_yaml_config): label_run = p.add_run("关键词") label_run.font.bold = False # Wrong - should be bold node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) def test_content_style_check(self, sample_yaml_config): """Content run style is checked (line 225)""" @@ -1307,7 +1301,7 @@ def test_content_style_check(self, sample_yaml_config): content_run = p.add_run("人工智能;机器学习") content_run.font.bold = True # Wrong - content should not be bold node.paragraph = p - node._base(doc, p=True, r=True) + node.check_format(doc) def test_keyword_count_and_trailing_punctuation(self, sample_yaml_config): """Keyword count validation + trailing punctuation check (via _run_rules)""" @@ -1412,8 +1406,7 @@ def test_heading_base_with_config(self, sample_yaml_config): p = doc.add_paragraph() run = p.add_run("第一章 绪论") node.paragraph = p - result = node._base(doc, p=True, r=True) - assert isinstance(result, list) + node.check_format(doc) # 通过 RULES handler 执行格式检查 # ==================== (o) set_style.py 额外覆盖测试 ==================== @@ -1530,11 +1523,10 @@ def test_body_text_apply_format(self, sample_yaml_config): run = p.add_run("test content") run.font.size = Pt(14) # Wrong size node.paragraph = p - result = node._base(doc, p=False, r=False) - assert isinstance(result, list) + node.apply_format(doc) def test_body_text_apply_to_run(self, sample_yaml_config): - """BodyText._base with r=False triggers apply_to_run (line 45)""" + """apply_format 通过 handler 修正字符格式。""" from wordformat.config.config import init_config, get_config from wordformat.rules.body import BodyText init_config(sample_yaml_config) @@ -1550,7 +1542,7 @@ def test_body_text_apply_to_run(self, sample_yaml_config): run = p.add_run("test") run.font.bold = True # Wrong - should be False per config node.paragraph = p - node._base(doc, p=True, r=False) + node.apply_format(doc) assert run.font.bold is False # Should have been fixed diff --git a/tests/test_rules.py b/tests/test_rules.py index 865f609..6c83ba3 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -58,11 +58,11 @@ def _load_yaml(path): @pytest.fixture -def root_config(config_path): - """从 example/undergrad_thesis.yaml 加载真实 NodeConfigRoot。""" +def root_config(sample_yaml_config): + """从 sample_yaml_config 加载 NodeConfigRoot,与示例文件解耦。""" from wordformat.config.config import init_config - init_config(config_path) - return _load_root_config(config_path) + init_config(sample_yaml_config) + return _load_root_config(sample_yaml_config) @pytest.fixture @@ -91,23 +91,25 @@ def run_with_text(para): class TestFormatNodeBase: """FormatNode 基类的核心契约。""" - def test_base_raises_not_implemented(self, doc, para): - """_base 必须抛出 NotImplementedError。""" + def test_base_is_noop(self, doc, para): + """_base() 默认为空操作。""" node = FormatNodeBase(value=para, level=0, paragraph=para) - with pytest.raises(NotImplementedError): - node._base(doc, p=True, r=True) + node._base(doc, p=True, r=True) + node._base(doc, p=False, r=False) 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: + 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: + 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) @@ -177,7 +179,7 @@ def test_abstract_title_cn(self, root_config): node.load_config(root_config) assert node._pydantic_config is not None assert node._pydantic_config.chinese_font_name == "黑体" - assert node._pydantic_config.bold is False + assert node._pydantic_config.bold is True def test_abstract_content_cn(self, root_config): node = _make_node(AbstractContentCN) @@ -234,7 +236,7 @@ def test_references(self, root_config): node = _make_node(References) node.load_config(root_config) assert node._pydantic_config is not None - assert node._pydantic_config.bold is False + assert node._pydantic_config.bold is True def test_reference_entry(self, root_config): node = _make_node(ReferenceEntry) @@ -246,7 +248,7 @@ def test_acknowledgements(self, root_config): node = _make_node(Acknowledgements) node.load_config(root_config) assert node._pydantic_config is not None - assert node._pydantic_config.bold is False + assert node._pydantic_config.bold is True def test_acknowledgements_cn(self, root_config): node = _make_node(AcknowledgementsCN) @@ -371,7 +373,7 @@ def test_cn_count_validation_too_few(self, root_config): node.check_format(doc) # 应至少有一条数量不足的 comment texts = [c.kwargs["text"] for c in mock_comment.call_args_list] - assert any("数量不足" in t for t in texts) + assert any("数量过少" in t for t in texts) def test_cn_count_validation_too_many(self, root_config): """中文关键词数量超限时应触发 add_comment。""" @@ -383,7 +385,7 @@ def test_cn_count_validation_too_many(self, root_config): with patch.object(node, "add_comment") as mock_comment: node.check_format(doc) texts = [c.kwargs["text"] for c in mock_comment.call_args_list] - assert any("数量超限" in t for t in texts) + assert any("数量过多" in t for t in texts) def test_cn_trailing_punct_detection(self, root_config): """中文关键词末尾标点校验。""" @@ -395,7 +397,7 @@ def test_cn_trailing_punct_detection(self, root_config): with patch.object(node, "add_comment") as mock_comment: node.check_format(doc) texts = [c.kwargs["text"] for c in mock_comment.call_args_list] - assert any("末尾禁止" in t for t in texts) + assert any("标点错误" in t for t in texts) def test_en_count_validation_too_few(self, root_config): """英文关键词数量不足。""" @@ -407,7 +409,7 @@ def test_en_count_validation_too_few(self, root_config): with patch.object(node, "add_comment") as mock_comment: node.check_format(doc) texts = [c.kwargs["text"] for c in mock_comment.call_args_list] - assert any("数量不足" in t for t in texts) + assert any("数量过少" in t for t in texts) def test_cn_no_config_raises_value_error(self, doc): """KeywordsCN 在 _pydantic_config 为 None 时访问 pydantic_config 抛出 ValueError。 @@ -417,7 +419,7 @@ def test_cn_no_config_raises_value_error(self, doc): node = KeywordsCN(value=p, level=0, paragraph=p) # 不加载配置 with pytest.raises(ValueError, match="尚未加载"): - node._base(doc, p=True, r=True) + node.check_format(doc) def test_keywords_unsupported_type_raises(self): """KeywordsCN.load_config 传入不支持的类型应抛出 TypeError。""" @@ -464,15 +466,12 @@ def test_abstract_title_cn_check_runs(self, root_config): # 至少对 run 和 paragraph 各调用一次 assert mock_comment.call_count >= 2 - def test_heading_no_config_returns_error(self, doc): - """HeadingLevel1Node 在无配置时应返回错误字典。""" + def test_heading_no_config_raises_value_error(self, doc): + """HeadingLevel1Node 未加载配置时 check_format 抛出 ValueError。""" p = doc.add_paragraph("第一章 绪论") node = HeadingLevel1Node(value=p, level=1, paragraph=p) - # 不加载配置 - with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) - assert isinstance(result, list) - assert any(isinstance(item, dict) and "error" in item for item in result) + with pytest.raises(ValueError, match="尚未加载"): + node.check_format(doc) def test_references_check_runs(self, root_config): """References.check_format 应遍历 run 并调用 add_comment。""" @@ -635,7 +634,7 @@ def test_check_with_wrong_format_triggers_comments(self, root_config): node = AbstractTitleCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 def test_apply_fixes_wrong_format(self, root_config): @@ -648,20 +647,17 @@ def test_apply_fixes_wrong_format(self, root_config): node = AbstractTitleCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) # apply 模式也会调用 add_comment assert mock_comment.call_count >= 2 - def test_check_no_runs_still_calls_paragraph_comment(self, root_config): - """段落无 run 时,仍应调用段落级别的 add_comment。""" + def test_check_no_runs_skips_without_error(self, root_config): + """段落无 run 时(空段),check_format 安全跳过不报错。""" doc = Document() p = doc.add_paragraph() node = AbstractTitleCN(value=p, level=0, paragraph=p) node.load_config(root_config) - with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) - # 至少有段落级别的 comment - assert mock_comment.call_count >= 1 + node.check_format(doc) # 不应抛异常 # --------------------------------------------------------------------------- @@ -681,7 +677,7 @@ def test_check_title_run_uses_title_style(self, root_config): node = AbstractTitleContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 def test_check_content_run_uses_content_style(self, root_config): @@ -693,7 +689,7 @@ def test_check_content_run_uses_content_style(self, root_config): node = AbstractTitleContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 def test_apply_title_and_content_runs(self, root_config): @@ -707,7 +703,7 @@ def test_apply_title_and_content_runs(self, root_config): node = AbstractTitleContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 3 def test_check_mode_does_not_mutate_run_text(self, root_config): @@ -719,7 +715,7 @@ def test_check_mode_does_not_mutate_run_text(self, root_config): r.font.size = Pt(10) node = AbstractTitleContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) - node._base(doc, p=True, r=True) + node.check_format(doc) # 修复后:检查模式不应改变 run.text assert r.text == original_text @@ -741,7 +737,7 @@ def test_check_with_wrong_font_size(self, root_config): node = AbstractContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 def test_apply_fixes_font_size(self, root_config): @@ -753,7 +749,7 @@ def test_apply_fixes_font_size(self, root_config): node = AbstractContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 2 def test_check_multiple_runs(self, root_config): @@ -767,7 +763,7 @@ def test_check_multiple_runs(self, root_config): node = AbstractContentCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) # 2 run comments + 1 paragraph comment assert mock_comment.call_count >= 3 @@ -791,7 +787,7 @@ def test_check_with_wrong_format(self, root_config): node = AbstractTitleEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_check_returns_empty_list(self, root_config): @@ -802,8 +798,7 @@ def test_check_returns_empty_list(self, root_config): r.font.size = Pt(10) node = AbstractTitleEN(value=p, level=0, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert result == [] + node.check_format(doc) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -815,7 +810,7 @@ def test_apply_with_wrong_format(self, root_config): node = AbstractTitleEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 @@ -836,7 +831,7 @@ def test_check_title_run_uses_title_style(self, root_config): node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 def test_check_content_run_uses_content_style(self, root_config): @@ -848,7 +843,7 @@ def test_check_content_run_uses_content_style(self, root_config): node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 def test_apply_mixed_runs(self, root_config): @@ -862,7 +857,7 @@ def test_apply_mixed_runs(self, root_config): node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 3 def test_check_title_normalizes_case_lower(self, root_config): @@ -873,7 +868,7 @@ def test_check_title_normalizes_case_lower(self, root_config): r.font.size = Pt(10) node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) - node._base(doc, p=True, r=True) + node.check_format(doc) assert r.text.startswith("Abstract") def test_check_title_normalizes_case_upper(self, root_config): @@ -884,7 +879,7 @@ def test_check_title_normalizes_case_upper(self, root_config): r.font.size = Pt(10) node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) - node._base(doc, p=True, r=True) + node.check_format(doc) assert r.text.startswith("Abstract") def test_split_abstract_across_runs(self, root_config): @@ -897,7 +892,7 @@ def test_split_abstract_across_runs(self, root_config): r2.font.size = Pt(10) node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) - node._base(doc, p=True, r=True) + node.check_format(doc) # r1 开头被修正为 "Abstract",r2 保持 "body text" 部分 assert r1.text.startswith("Abstract") assert "body text" in r2.text @@ -914,7 +909,7 @@ def test_split_abstract_across_three_runs(self, root_config): r3.font.size = Pt(10) node = AbstractTitleContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) - node._base(doc, p=True, r=True) + node.check_format(doc) # r1 应被修正为 "Abstract" assert r1.text.startswith("Abstract") # r2 和 r3 开头部分属于标题前缀,应被清空 @@ -940,7 +935,7 @@ def test_check_with_wrong_format(self, root_config): node = AbstractContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_apply_with_wrong_format(self, root_config): @@ -954,7 +949,7 @@ def test_apply_with_wrong_format(self, root_config): with patch.object(node, "add_comment") as mock_comment: # 配置中 line_spacing 为 "0倍",现会触发 ValueError,mock 掉该步 with patch("wordformat.style.check_format.LineSpacing.format"): - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_check_multiple_runs(self, root_config): @@ -968,7 +963,7 @@ def test_check_multiple_runs(self, root_config): node = AbstractContentEN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 2 @@ -990,7 +985,7 @@ def test_check_with_wrong_format(self, root_config): node = Acknowledgements(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_check_returns_empty_list(self, root_config): @@ -1001,8 +996,7 @@ def test_check_returns_empty_list(self, root_config): r.font.size = Pt(10) node = Acknowledgements(value=p, level=0, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert result == [] + node.check_format(doc) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1013,7 +1007,7 @@ def test_apply_with_wrong_format(self, root_config): node = Acknowledgements(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_check_no_diffs_no_comment(self, root_config): @@ -1024,7 +1018,7 @@ def test_check_no_diffs_no_comment(self, root_config): node = Acknowledgements(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) # 即使格式正确,段落级别的 diff 仍可能触发 comment # 但如果没有差异,不应有 comment # 注意:新 Document 的段落默认对齐方式可能与配置不同 @@ -1047,7 +1041,7 @@ def test_check_with_wrong_format(self, root_config): node = AcknowledgementsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_check_returns_empty_list(self, root_config): @@ -1058,8 +1052,7 @@ def test_check_returns_empty_list(self, root_config): r.font.size = Pt(10) node = AcknowledgementsCN(value=p, level=0, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert result == [] + node.check_format(doc) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1070,7 +1063,7 @@ def test_apply_with_wrong_format(self, root_config): node = AcknowledgementsCN(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_check_first_line_indent(self, root_config): @@ -1084,7 +1077,7 @@ def test_check_first_line_indent(self, root_config): # 验证配置中有 first_line_indent 字段 assert hasattr(node._pydantic_config, "first_line_indent") with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 @@ -1105,7 +1098,7 @@ def test_check_with_wrong_format(self, root_config): node = CaptionFigure(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_apply_with_wrong_format(self, root_config): @@ -1117,7 +1110,7 @@ def test_apply_with_wrong_format(self, root_config): node = CaptionFigure(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 @@ -1138,7 +1131,7 @@ def test_check_with_wrong_format(self, root_config): node = CaptionTable(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_apply_with_wrong_format(self, root_config): @@ -1150,7 +1143,7 @@ def test_apply_with_wrong_format(self, root_config): node = CaptionTable(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 @@ -1172,9 +1165,8 @@ def test_check_with_wrong_format(self, root_config): node = HeadingLevel1Node(value=p, level=1, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 - assert isinstance(result, list) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1185,9 +1177,8 @@ def test_apply_with_wrong_format(self, root_config): node = HeadingLevel1Node(value=p, level=1, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 - assert isinstance(result, list) def test_check_returns_issues_list(self, root_config): """返回值应为包含 issue 字典的列表。""" @@ -1197,10 +1188,8 @@ def test_check_returns_issues_list(self, root_config): r.font.size = Pt(10) node = HeadingLevel1Node(value=p, level=1, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert isinstance(result, list) + node.check_format(doc) # 应有 run_issues 或 paragraph_issues - assert len(result) >= 1 def test_check_skips_empty_runs(self, root_config): """空白 run 应被跳过。""" @@ -1212,7 +1201,7 @@ def test_check_skips_empty_runs(self, root_config): node = HeadingLevel1Node(value=p, level=1, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) + node.check_format(doc) # 空白 run 不应触发 comment run_comments = [ c for c in mock_comment.call_args_list @@ -1238,9 +1227,8 @@ def test_check_with_wrong_format(self, root_config): node = HeadingLevel2Node(value=p, level=2, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 - assert isinstance(result, list) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1251,9 +1239,8 @@ def test_apply_with_wrong_format(self, root_config): node = HeadingLevel2Node(value=p, level=2, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 - assert isinstance(result, list) def test_check_returns_issues(self, root_config): """返回值应包含 issue 字典。""" @@ -1263,9 +1250,7 @@ def test_check_returns_issues(self, root_config): r.font.size = Pt(10) node = HeadingLevel2Node(value=p, level=2, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert isinstance(result, list) - assert len(result) >= 1 + node.check_format(doc) # --------------------------------------------------------------------------- @@ -1285,9 +1270,8 @@ def test_check_with_wrong_format(self, root_config): node = HeadingLevel3Node(value=p, level=3, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 - assert isinstance(result, list) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1298,9 +1282,8 @@ def test_apply_with_wrong_format(self, root_config): node = HeadingLevel3Node(value=p, level=3, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 - assert isinstance(result, list) def test_check_returns_issues(self, root_config): """返回值应包含 issue 字典。""" @@ -1310,9 +1293,7 @@ def test_check_returns_issues(self, root_config): r.font.size = Pt(10) node = HeadingLevel3Node(value=p, level=3, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert isinstance(result, list) - assert len(result) >= 1 + node.check_format(doc) # --------------------------------------------------------------------------- @@ -1333,7 +1314,7 @@ def test_check_with_wrong_format(self, root_config): node = References(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_check_returns_empty_list(self, root_config): @@ -1344,8 +1325,7 @@ def test_check_returns_empty_list(self, root_config): r.font.size = Pt(10) node = References(value=p, level=0, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert result == [] + node.check_format(doc) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1356,7 +1336,7 @@ def test_apply_with_wrong_format(self, root_config): node = References(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 @@ -1377,7 +1357,7 @@ def test_check_with_wrong_format(self, root_config): node = ReferenceEntry(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=True, r=True) + node.check_format(doc) assert mock_comment.call_count >= 1 def test_check_returns_empty_list(self, root_config): @@ -1388,8 +1368,7 @@ def test_check_returns_empty_list(self, root_config): r.font.size = Pt(10) node = ReferenceEntry(value=p, level=0, paragraph=p) node.load_config(root_config) - result = node._base(doc, p=True, r=True) - assert result == [] + node.check_format(doc) def test_apply_with_wrong_format(self, root_config): """apply 模式:应修正格式。""" @@ -1400,7 +1379,7 @@ def test_apply_with_wrong_format(self, root_config): node = ReferenceEntry(value=p, level=0, paragraph=p) node.load_config(root_config) with patch.object(node, "add_comment") as mock_comment: - result = node._base(doc, p=False, r=False) + node.apply_format(doc) assert mock_comment.call_count >= 1 def test_check_alignment_and_indent(self, root_config): diff --git a/tests/test_style.py b/tests/test_style.py index bc7a2e4..ac11ad1 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -151,9 +151,9 @@ def test_apply_to_run_fixes_bold(self, doc, mock_warning): _clear_warning() def test_to_string_filters_by_warning(self, mock_warning, mock_warning_off): - diffs = [DIFFResult(diff_type="bold", comment="b")] + diffs = [DIFFResult(diff_type="bold", current_value=True, expected_value=False)] _set_warning(mock_warning) - assert "b" in CharacterStyle.to_string(diffs) + assert "加粗错误" in CharacterStyle.to_string(diffs) _set_warning(mock_warning_off) assert CharacterStyle.to_string(diffs) == "" _clear_warning() @@ -184,12 +184,12 @@ def test_diff_detects_alignment(self, doc, mock_warning): assert "alignment" in [d.diff_type for d in ParagraphStyle(alignment="左对齐").diff_from_paragraph(p)] _clear_warning() - def test_diff_builtin_style_case_mismatch(self, doc, mock_warning): - """BuiltInStyle('正文').rel_value='Normal' but get_from_paragraph returns 'normal'.""" + def test_diff_builtin_style_name_match(self, doc, mock_warning): + """BuiltInStyle('正文').rel_value='Normal' 与 'normal' 应视为一致。""" _set_warning(mock_warning) p = doc.add_paragraph() types = [d.diff_type for d in ParagraphStyle(builtin_style_name="正文").diff_from_paragraph(p)] - assert "builtin_style_name" in types + assert "builtin_style_name" not in types # 英文"normal" = 中文"正文" _clear_warning() def test_to_string_line_spacing_rule_key_bug(self, doc, mock_warning): @@ -2279,27 +2279,28 @@ class TestCharacterStyleToStringNone: """Cover line 244: CharacterStyle.to_string with style_checks_warning is None""" def test_to_string_warning_none(self): - """When style_checks_warning is None, return all diffs joined (line 244)""" + """style_checks_warning=None 时返回所有 diff 的标准格式文本。""" import wordformat.style.check_format as m m.style_checks_warning = None diffs = [ - DIFFResult(diff_type="bold", comment="bold_issue"), - DIFFResult(diff_type="italic", comment="italic_issue"), + DIFFResult(diff_type="bold", current_value=True, expected_value=False), + DIFFResult(diff_type="italic", current_value=True, expected_value=False), ] - result = CharacterStyle.to_string(diffs) - assert "bold_issue" in result - assert "italic_issue" in result + result = CharacterStyle.to_string(diffs, target="测试") + assert "加粗错误" in result + assert "斜体错误" in result + assert "测试" in result class TestCharacterStyleToStringBoldFilter: """Cover line 250: CharacterStyle.to_string with style_checks_warning.bold = True""" def test_to_string_bold_filtered_in(self, mock_warning): - """warning.bold=True includes bold diffs (line 250)""" + """warning.bold=True 时 bold diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="bold", comment="b")] - result = CharacterStyle.to_string(diffs) - assert "b" in result + diffs = [DIFFResult(diff_type="bold", current_value=True, expected_value=False)] + result = CharacterStyle.to_string(diffs, target="测试") + assert "加粗错误" in result _clear_warning() @@ -2307,35 +2308,35 @@ 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 includes italic diffs (line 252)""" + """warning.italic=True 时 italic diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="italic", comment="i")] + diffs = [DIFFResult(diff_type="italic", current_value=True, expected_value=False)] result = CharacterStyle.to_string(diffs) - assert "i" in result + assert "斜体错误" in result _clear_warning() def test_to_string_font_size_filtered(self, mock_warning): - """warning.font_size=True includes font_size diffs (line 254)""" + """warning.font_size=True 时 font_size diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="font_size", comment="fs")] + diffs = [DIFFResult(diff_type="font_size", current_value=10.0, expected_value=12.0)] result = CharacterStyle.to_string(diffs) - assert "fs" in result + assert "字号错误" in result _clear_warning() def test_to_string_font_color_filtered(self, mock_warning): - """warning.font_color=True includes font_color diffs (line 256)""" + """warning.font_color=True 时 font_color diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="font_color", comment="fc")] + diffs = [DIFFResult(diff_type="font_color", current_value="红色", expected_value="黑色")] result = CharacterStyle.to_string(diffs) - assert "fc" in result + assert "字体颜色错误" in result _clear_warning() def test_to_string_font_name_filtered(self, mock_warning): - """warning.font_name=True includes font_name_cn/en diffs (lines 257-261)""" + """warning.font_name=True 时 font_name_cn diff 被包含。""" _set_warning(mock_warning) - diffs = [DIFFResult(diff_type="font_name_cn", comment="fnc")] + diffs = [DIFFResult(diff_type="font_name_cn", current_value="宋体", expected_value="黑体")] result = CharacterStyle.to_string(diffs) - assert "fnc" in result + assert "中文字体错误" in result _clear_warning() @@ -2343,13 +2344,13 @@ class TestParagraphStyleToStringNone: """Cover line 478: ParagraphStyle.to_string with style_checks_warning is None""" def test_to_string_warning_none(self): - """When style_checks_warning is None, return all diffs joined (line 478)""" + """style_checks_warning=None 时返回所有 diff 的标准格式文本。""" import wordformat.style.check_format as m m.style_checks_warning = None diffs = [ - DIFFResult(diff_type="alignment", comment="align_issue"), - DIFFResult(diff_type="space_before", comment="sb_issue"), + 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 "align_issue" in result - assert "sb_issue" in result + assert "对齐错误" in result + assert "段前间距错误" in result diff --git a/wordformat-skill/scripts/validate_json.py b/wordformat-skill/scripts/validate_json.py index ec412ff..13e6b55 100644 --- a/wordformat-skill/scripts/validate_json.py +++ b/wordformat-skill/scripts/validate_json.py @@ -39,6 +39,8 @@ "acknowledgements_title", "caption_figure", "caption_table", + "figure_image", + "table_object", "body_text", "other", } From f8aafac85f270cf5423326ebc19285f019514472 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sat, 20 Jun 2026 10:43:30 +0800 Subject: [PATCH 4/9] =?UTF-8?q?fix:=20=E4=B8=AD=E6=96=87=E5=AD=97=E4=BD=93?= =?UTF-8?q?=E6=A3=80=E6=9F=A5=E4=BB=85=E5=90=ABCJK=E5=AD=97=E7=AC=A6?= =?UTF-8?q?=E7=9A=84run=E3=80=81=E6=A0=87=E7=AD=BE=E8=AF=86=E5=88=AB?= =?UTF-8?q?=E9=98=B2=E6=8B=86=E5=88=86=E3=80=81=E5=8D=8A=E8=A7=92=E6=A0=87?= =?UTF-8?q?=E7=82=B9=E8=A1=A5=E5=85=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - font_name_cn 仅当 run 含中文字符时才检查,避免引号/英文被误报 - Keywords 标签识别用全文位置匹配,防Keywords被拆成多run导致误判 - 半角标点补全引号/感叹号/问号,段首标点匹配 - 双向验证日志降为 debug,不再刷屏 --- src/wordformat/base.py | 24 ++++++++++++++ src/wordformat/rules/body.py | 8 +++-- src/wordformat/rules/keywords.py | 45 ++++++++++++++++++++++---- src/wordformat/rules/node.py | 6 ++-- src/wordformat/style/check_format.py | 5 +-- tests/test_rules.py | 47 +++++++++++++++------------- tests/test_style.py | 4 +-- 7 files changed, 103 insertions(+), 36 deletions(-) diff --git a/src/wordformat/base.py b/src/wordformat/base.py index 355e954..796ecee 100644 --- a/src/wordformat/base.py +++ b/src/wordformat/base.py @@ -3,6 +3,8 @@ # @Author : afish # @File : DocxBase.py +import re + from docx import Document from loguru import logger @@ -96,4 +98,26 @@ def parse(self) -> list[dict]: result[idx] = response assert all(r is not None for r in result), "存在未处理的段落" + # 后处理:已知模式的段落强制修正分类 + _fix_known_categories(result) return result + + +def _fix_known_categories(result: list[dict]) -> None: + """用已知文本模式修正常见 AI 分类错误。""" + patterns = [ + # (正则, 修正分类) + (r"^(摘要)\s*$", "abstract_chinese_title"), + (r"^(Abstract)\s*$", "abstract_english_title"), + (r"^摘要\s*[::]", "abstract_chinese_title_content"), + (r"^Abstract\s*[::]?", "abstract_english_title_content"), + ] + for item in result: + if item["category"] not in ("body_text",): + continue # 已是正确类别,不干涉 + text = (item.get("paragraph") or "").strip() + for pat, cat in patterns: + if re.match(pat, text): + item["category"] = cat + item["comment"] = f"模式修正为 {cat}(原:body_text)" + break diff --git a/src/wordformat/rules/body.py b/src/wordformat/rules/body.py index 3d976ee..c3859ce 100644 --- a/src/wordformat/rules/body.py +++ b/src/wordformat/rules/body.py @@ -26,9 +26,13 @@ ")": ")", "[": "【", "]": "】", + '"': "“", + "'": "‘", + "!": "!", + "?": "?", } -# 匹配中文上下文中的半角标点(仅当两侧均为中文字符,或中文+结尾) -_PUNCT_PATTERN = re.compile(r"([一-鿿])([,.;:()\[\]])([一-鿿]|$)") +# 匹配中文上下文中的半角标点(段首/中文后 + 标点 + 中文/段尾) +_PUNCT_PATTERN = re.compile(r"(^|[一-鿿])([,.;:()\[\]\"\'\!\?])([一-鿿]|$)") def _split_run_at(paragraph, start: int, end: int): diff --git a/src/wordformat/rules/keywords.py b/src/wordformat/rules/keywords.py index f828372..eb024de 100644 --- a/src/wordformat/rules/keywords.py +++ b/src/wordformat/rules/keywords.py @@ -123,9 +123,24 @@ class KeywordsEN(BaseKeywordsNode): RULES = {"keyword_count": "_check_keyword_count"} def _check_keyword_label(self, run) -> bool: - """检查run是否包含英文关键词标签(Keywords/KEY WORDS)""" - pattern = r"Keywords?|KEY\s*WORDS" - return bool(re.search(pattern, run.text, re.IGNORECASE)) + """判断 run 是否属于英文关键词标签部分。""" + if not run.text.strip(): + return False + if self.paragraph is None: + return bool(re.search(r"Keywords?|KEY\s*WORDS", run.text, re.IGNORECASE)) + full = "".join(r.text for r in self.paragraph.runs) + m = re.match(r"Keywords?\s*[::]?\s*|KEY\s*WORDS\s*", full, re.IGNORECASE) + if not m: + return False + label_end = m.end() + # 找出当前 run 在全文中的位置(用 XML 元素 identity 比较) + pos = 0 + for r in self.paragraph.runs: + rl = len(r.text) + if r._element is run._element: + return pos < label_end + pos += rl + return False def _get_label_split_pattern(self) -> re.Pattern | None: """英文标签拆分模式:匹配 'Keywords:' 或 'Keywords ' 及其变体""" @@ -215,9 +230,27 @@ class KeywordsCN(BaseKeywordsNode): } def _check_keyword_label(self, run) -> bool: - """检查run是否包含中文关键词标签(关键词)""" - pattern = r"关[^a-zA-Z0-9\u4e00-\u9fff]*键[^a-zA-Z0-9\u4e00-\u9fff]*词" - return bool(re.search(pattern, run.text)) + """判断 run 是否属于中文关键词标签部分(防拆分)。""" + if not run.text.strip(): + return False + if self.paragraph is None: + p = r"关[^a-zA-Z0-9\u4e00-\u9fff]*键[^a-zA-Z0-9\u4e00-\u9fff]*词" + return bool(re.search(p, run.text)) + full = "".join(r.text for r in self.paragraph.runs) + m = re.search( + r"关[^a-zA-Z0-9\u4e00-\u9fff]*键[^a-zA-Z0-9\u4e00-\u9fff]*词\s*[::]?\s*", + full, + ) + if not m: + return False + label_end = m.end() + pos = 0 + for r in self.paragraph.runs: + rl = len(r.text) + if r._element is run._element: + return pos < label_end + pos += rl + return False def _get_label_split_pattern(self) -> re.Pattern | None: """中文标签拆分模式:匹配 '关键词:' 或 '关键词:' 及其变体""" diff --git a/src/wordformat/rules/node.py b/src/wordformat/rules/node.py index 569553e..919d029 100644 --- a/src/wordformat/rules/node.py +++ b/src/wordformat/rules/node.py @@ -196,18 +196,18 @@ def _run_rules(self, doc: Document, p: bool) -> None: # 双向验证(仅检查自定义 RULES) if self.RULES: if rules_config is None: - logger.warning(f"[{self.NODE_TYPE}] RULES 已声明但配置无 rules 节点") + logger.debug(f"[{self.NODE_TYPE}] RULES 已声明但配置无 rules 节点") else: declared_rules = set(self.RULES.keys()) config_rules = set(type(rules_config).model_fields.keys()) orphan_handlers = declared_rules - config_rules orphan_configs = config_rules - declared_rules if orphan_handlers: - logger.warning( + logger.debug( f"[{self.NODE_TYPE}] RULES 声明了 {orphan_handlers} 但配置无对应项" ) if orphan_configs: - logger.warning( + logger.debug( f"[{self.NODE_TYPE}] 配置有 {orphan_configs} 但无对应 handler" ) diff --git a/src/wordformat/style/check_format.py b/src/wordformat/style/check_format.py index 0dd0c81..4782eb5 100644 --- a/src/wordformat/style/check_format.py +++ b/src/wordformat/style/check_format.py @@ -276,9 +276,10 @@ def diff_from_run(self, run: Run) -> list[DIFFResult]: # noqa c901 ) ) - # 6. 东亚字体 + # 6. 东亚字体(仅当 run 含中文字符时才检查) font_name = run_get_font_name(run) or "" - if str(font_name).lower() != str(self.font_name_cn).lower(): + has_cjk = any("一" <= ch <= "鿿" for ch in run.text) + if has_cjk and str(font_name).lower() != str(self.font_name_cn).lower(): diffs.append( DIFFResult( "font_name_cn", diff --git a/tests/test_rules.py b/tests/test_rules.py index 6c83ba3..1c72174 100644 --- a/tests/test_rules.py +++ b/tests/test_rules.py @@ -2,7 +2,7 @@ rules 模块测试 —— 聚焦真实行为验证,无填充。 """ import re -from unittest.mock import MagicMock, patch +from unittest.mock import patch import pytest from docx import Document @@ -334,33 +334,38 @@ class TestKeywordsLogic: """关键词节点的标签识别、数量校验、标点校验。""" def test_cn_label_detection(self): - """中文关键词标签识别。""" - node = _make_node(KeywordsCN) - mock_run = MagicMock() - mock_run.text = "关键词" - assert node._check_keyword_label(mock_run) is True + """中文关键词标签识别(使用真实 paragraph runs)。""" + doc = Document() + p = doc.add_paragraph("关键词") + node = KeywordsCN(value=p, level=0, paragraph=p) + assert node._check_keyword_label(p.runs[0]) is True - mock_run.text = "关 键 词" - assert node._check_keyword_label(mock_run) is True + p2 = doc.add_paragraph("关 键 词") + node.paragraph = p2 + assert node._check_keyword_label(p2.runs[0]) is True - mock_run.text = "机器学习" - assert node._check_keyword_label(mock_run) is False + p3 = doc.add_paragraph("机器学习") + node.paragraph = p3 + assert node._check_keyword_label(p3.runs[0]) is False def test_en_label_detection(self): - """英文关键词标签识别。""" - node = _make_node(KeywordsEN) - mock_run = MagicMock() - mock_run.text = "Keywords" - assert node._check_keyword_label(mock_run) is True + """英文关键词标签识别(使用真实 paragraph runs)。""" + doc = Document() + p = doc.add_paragraph("Keywords") + node = KeywordsEN(value=p, level=0, paragraph=p) + assert node._check_keyword_label(p.runs[0]) is True - mock_run.text = "Keyword" - assert node._check_keyword_label(mock_run) is True + p2 = doc.add_paragraph("Keyword") + node.paragraph = p2 + assert node._check_keyword_label(p2.runs[0]) is True - mock_run.text = "KEY WORDS" - assert node._check_keyword_label(mock_run) is True + p3 = doc.add_paragraph("KEY WORDS") + node.paragraph = p3 + assert node._check_keyword_label(p3.runs[0]) is True - mock_run.text = "machine learning" - assert node._check_keyword_label(mock_run) is False + p4 = doc.add_paragraph("machine learning") + node.paragraph = p4 + assert node._check_keyword_label(p4.runs[0]) is False def test_cn_count_validation_too_few(self, root_config): """中文关键词数量不足时应触发 add_comment。""" diff --git a/tests/test_style.py b/tests/test_style.py index ac11ad1..ad20e06 100644 --- a/tests/test_style.py +++ b/tests/test_style.py @@ -133,7 +133,7 @@ def test_diff_boolean_mismatch(self, doc, mock_warning, prop, expected_val, curr def test_diff_font_size_and_name_cn(self, doc, mock_warning): _set_warning(mock_warning) cs = CharacterStyle(font_size="小四", font_name_cn="宋体") - run = doc.add_paragraph().add_run("t") + run = doc.add_paragraph().add_run("测试") run.font.size = Pt(14) run_set_font_name(run, "黑体") types = [d.diff_type for d in cs.diff_from_run(run)] @@ -2266,7 +2266,7 @@ def test_apply_to_run_fixes_font_name_cn(self, doc, mock_warning): _set_warning(mock_warning) cs = CharacterStyle(font_name_cn="黑体") p = doc.add_paragraph() - run = p.add_run("test") + run = p.add_run("测试") # Default CN font is 宋体, so 黑体 should trigger a fix run_set_font_name(run, "宋体") result = cs.apply_to_run(run) From 53f22cc1f59c230106c060a69f1e9a21c07ff221 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sat, 20 Jun 2026 10:58:21 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix:=20WordFormatUI=20=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E5=90=8C=E6=AD=A5=E5=90=8E=E7=AB=AF=E7=89=88?= =?UTF-8?q?=E6=9C=AC=E6=9B=B4=E6=96=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 关键词字段迁移至 rules 结构(keyword_count / trailing_punctuation) - 新增 template_name 字段 - 新增 figures.image / tables.object 段落格式配置 - 新增 figures.rules / tables.rules 编号检查开关 - 新增 body_text.rules.punctuation 标点检查开关 - style_checks_warning 默认启用主要检查项 --- .../src/config-generator/ConfigGenerator.vue | 8 ++ .../components/AbstractConfig.vue | 19 ++-- .../components/FiguresConfig.vue | 90 +++++----------- .../components/TablesConfig.vue | 102 +++++------------- WordFormatUI/src/config-generator/utils.js | 73 +++++++++---- 5 files changed, 117 insertions(+), 175 deletions(-) diff --git a/WordFormatUI/src/config-generator/ConfigGenerator.vue b/WordFormatUI/src/config-generator/ConfigGenerator.vue index b5a5e4b..b7db939 100644 --- a/WordFormatUI/src/config-generator/ConfigGenerator.vue +++ b/WordFormatUI/src/config-generator/ConfigGenerator.vue @@ -2,6 +2,14 @@
+ + +
+ + +
+
+ diff --git a/WordFormatUI/src/config-generator/components/AbstractConfig.vue b/WordFormatUI/src/config-generator/components/AbstractConfig.vue index 4f762ca..19ee08a 100644 --- a/WordFormatUI/src/config-generator/components/AbstractConfig.vue +++ b/WordFormatUI/src/config-generator/components/AbstractConfig.vue @@ -21,16 +21,19 @@

关键词配置

中文关键词

+
+ +
- +
- +
- +
中文关键词内容格式
@@ -42,15 +45,15 @@

英文关键词

- - +
- - + +
- + +
英文关键词内容格式
diff --git a/WordFormatUI/src/config-generator/components/FiguresConfig.vue b/WordFormatUI/src/config-generator/components/FiguresConfig.vue index deb4242..42951b5 100644 --- a/WordFormatUI/src/config-generator/components/FiguresConfig.vue +++ b/WordFormatUI/src/config-generator/components/FiguresConfig.vue @@ -5,8 +5,14 @@
+
+ +
+
题注格式
+
图片段落格式(包含内联图片的段落)
+
@@ -28,73 +34,25 @@ const props = defineProps({ align-items: center; gap: 8px; } - .form-item label { - font-weight: 500; - color: #555; - font-size: 13px; - white-space: nowrap; - min-width: 100px; -} - -.form-item input[type="text"], -.form-item input[type="number"], -.form-item select { - width: 90px; - min-width: 80px; - padding: 6px 8px; - border: 1px solid #ddd; - border-radius: 4px; - font-size: 13px; -} - -.grid { - display: grid; -} - -.grid-cols-2 { - grid-template-columns: repeat(4, 1fr); -} - -/* 大屏幕显示更多列 */ -@media (min-width: 1024px) { - .grid-cols-2 { - grid-template-columns: repeat(5, 1fr); - } -} - -@media (min-width: 1200px) { - .grid-cols-2 { - grid-template-columns: repeat(6, 1fr); - } -} - -.gap-4 { - gap: 10px; -} - -.mb-4 { - margin-bottom: 12px; -} - + font-weight: 500; color: #555; font-size: 13px; white-space: nowrap; min-width: 100px; +} +.form-item input[type="text"], .form-item input[type="number"], .form-item select { + width: 90px; min-width: 80px; padding: 6px 8px; border: 1px solid #ddd; border-radius: 4px; font-size: 13px; +} +.form-item input[type="checkbox"] { margin-right: 5px; } +.grid { display: grid; } +.grid-cols-2 { grid-template-columns: repeat(4, 1fr); } +.gap-4 { gap: 10px; } +.mb-4 { margin-bottom: 12px; } +.subsection-title { font-size: 12px; font-weight: 600; color: #94a3b8; margin-bottom: 4px; } +.mt-3 { margin-top: 8px; } +@media (min-width: 1024px) { .grid-cols-2 { grid-template-columns: repeat(5, 1fr); } } +@media (min-width: 1200px) { .grid-cols-2 { grid-template-columns: repeat(6, 1fr); } } @media (max-width: 768px) { - .grid-cols-2 { - grid-template-columns: 1fr; - } - - .form-item { - flex-direction: column; - align-items: flex-start; - } - - .form-item label { - min-width: auto; - } - - .form-item input[type="text"], - .form-item input[type="number"], - .form-item select { - width: 120px; - } + .grid-cols-2 { grid-template-columns: 1fr; } + .form-item { flex-direction: column; align-items: flex-start; } + .form-item label { min-width: auto; } + .form-item input[type="text"], .form-item input[type="number"], .form-item select { width: 120px; } } diff --git a/WordFormatUI/src/config-generator/components/TablesConfig.vue b/WordFormatUI/src/config-generator/components/TablesConfig.vue index ab299d9..5ce59e9 100644 --- a/WordFormatUI/src/config-generator/components/TablesConfig.vue +++ b/WordFormatUI/src/config-generator/components/TablesConfig.vue @@ -5,10 +5,17 @@ +
+ +
+
题注格式
-

表格内容格式(单元格内文字)

+
表格对象格式(表格整体对齐、环绕)
+ + +
表格内容格式(单元格内文字)
@@ -25,83 +32,22 @@ const props = defineProps({ diff --git a/WordFormatUI/src/config-generator/utils.js b/WordFormatUI/src/config-generator/utils.js index b417d70..b0edf00 100644 --- a/WordFormatUI/src/config-generator/utils.js +++ b/WordFormatUI/src/config-generator/utils.js @@ -28,22 +28,23 @@ export const createConfigWithGlobalInheritance = (baseConfig = {}) => { // 默认配置 export const defaultConfig = { + template_name: "未知模板", style_checks_warning: { - bold: false, + bold: true, italic: false, underline: false, - font_size: false, - font_name: false, + font_size: true, + font_name: true, font_color: false, - alignment: false, - space_before: false, - space_after: false, - line_spacing: false, - line_spacingrule: false, - left_indent: false, - right_indent: false, - first_line_indent: false, - builtin_style_name: false + alignment: true, + space_before: true, + space_after: true, + line_spacing: true, + line_spacingrule: true, + left_indent: true, + right_indent: true, + first_line_indent: true, + builtin_style_name: true }, global_format: { ...defaultGlobalFormat @@ -64,7 +65,8 @@ export const defaultConfig = { english_title: createConfigWithGlobalInheritance({ alignment: "居中对齐", first_line_indent: "0字符", - font_size: "四号" + font_size: "四号", + bold: false }), english_content: createConfigWithGlobalInheritance({ alignment: "两端对齐" @@ -72,23 +74,30 @@ export const defaultConfig = { }, keywords: { chinese: createConfigWithGlobalInheritance({ + alignment: "两端对齐", + first_line_indent: "2字符", + font_size: "小四", label: createConfigWithGlobalInheritance({ chinese_font_name: '黑体', font_size: '四号', bold: false }), - count_min: 3, - count_max: 5, - trailing_punct_forbidden: true + rules: { + keyword_count: { enabled: true, count_min: 3, count_max: 5 }, + trailing_punctuation: { enabled: true, forbidden_chars: ";,。、" } + } }), english: createConfigWithGlobalInheritance({ + alignment: "两端对齐", + first_line_indent: "2字符", + font_size: "小四", label: createConfigWithGlobalInheritance({ font_size: '四号', bold: false }), - count_min: 3, - count_max: 5, - trailing_punct_forbidden: true + rules: { + keyword_count: { enabled: true, count_min: 3, count_max: 5 } + } }) } }, @@ -121,13 +130,24 @@ export const defaultConfig = { builtin_style_name: "Heading 3" }) }, - body_text: createConfigWithGlobalInheritance(), + body_text: createConfigWithGlobalInheritance({ + rules: { + punctuation: { enabled: true } + } + }), figures: createConfigWithGlobalInheritance({ alignment: "居中对齐", first_line_indent: "0字符", font_size: "五号", builtin_style_name: "题注", - caption_prefix: "图" + caption_prefix: "图", + image: createConfigWithGlobalInheritance({ + alignment: "居中对齐", + first_line_indent: "0字符" + }), + rules: { + caption_numbering: { enabled: true, separator: '.', label_number_space: false } + } }), tables: createConfigWithGlobalInheritance({ alignment: "居中对齐", @@ -135,16 +155,23 @@ export const defaultConfig = { 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: '1.5倍行距', + 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({ From 38efec10bd76306bdb74bd2149296a6d9f030ce7 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sat, 20 Jun 2026 11:08:03 +0800 Subject: [PATCH 6/9] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E9=85=8D?= =?UTF-8?q?=E7=BD=AE=E6=A8=A1=E6=9D=BF=E5=88=97=E8=A1=A8=E4=BE=A7=E8=BE=B9?= =?UTF-8?q?=E6=A0=8F=20+=20=E6=9C=8D=E5=8A=A1=E5=99=A8=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - API: GET /configs 列出可用配置,GET /configs/{name} 读取,POST /configs/save 保存 - UI: 配置生成器左侧新增配置模板侧边栏,点击快速切换模板 - 新增保存到服务器按钮,可将当前配置写入 configs/ 目录 - 默认附带 undergrad_thesis.yaml 示例配置 --- WordFormatUI/src/App.vue | 70 +++- WordFormatUI/src/components/ConfigSidebar.vue | 113 ++++++ configs/undergrad_thesis.yaml | 330 ++++++++++++++++++ src/wordformat/api/__init__.py | 49 +++ 4 files changed, 554 insertions(+), 8 deletions(-) create mode 100644 WordFormatUI/src/components/ConfigSidebar.vue create mode 100644 configs/undergrad_thesis.yaml diff --git a/WordFormatUI/src/App.vue b/WordFormatUI/src/App.vue index ca29ff6..eca96fd 100644 --- a/WordFormatUI/src/App.vue +++ b/WordFormatUI/src/App.vue @@ -16,6 +16,9 @@ + @@ -34,16 +37,20 @@ - -
- - + +
+ + +
+ + - - + + - - + + +
@@ -62,6 +69,7 @@ import {ref, onMounted} from 'vue'; import GlobalToast from "./components/GlobalToast.vue"; import DocTagChecker from "./components/DocTagChecker.vue"; import ConfigGenerator from "./config-generator/ConfigGenerator.vue"; +import ConfigSidebar from "./components/ConfigSidebar.vue"; import SettingsPage from "./components/SettingsPage.vue"; import yaml from 'js-yaml'; import {defaultConfig, mergeWithDefaults} from "./config-generator/utils"; @@ -149,6 +157,45 @@ const onConfigFileSelected = async (e) => { } }; +// 保存配置到服务器 configs 目录 +const API_BASE = window.__API_BASE__ || ''; +const saveConfigToServer = async () => { + if (!generatedConfig.value) return; + try { + const yamlContent = yaml.dump(generatedConfig.value, { indent: 2, skipInvalid: true }); + const fn = prompt('请输入配置文件名(如 my-thesis.yaml):', 'custom.yaml'); + if (!fn) return; + const res = await fetch(`${API_BASE}/configs/save`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ filename: fn, content: yamlContent }), + }); + const json = await res.json(); + if (json.code === 200) { + toastRef.value?.toast.success(json.msg); + } else { + toastRef.value?.toast.error(json.msg || '保存失败'); + } + } catch (error) { + toastRef.value?.toast.error('保存配置失败:' + error.message); + } +}; + +// 从服务器侧边栏加载配置 +const onServerConfigSelected = ({ filename, content }) => { + try { + const config = yaml.load(content); + const merged = mergeWithDefaults(config, defaultConfig); + generatedConfig.value = merged; + if (configGeneratorRef.value) { + configGeneratorRef.value.importConfig(merged); + } + toastRef.value?.toast.success(`已加载配置: ${filename}`); + } catch (error) { + toastRef.value?.toast.error('解析配置失败:' + error.message); + } +}; + diff --git a/configs/undergrad_thesis.yaml b/configs/undergrad_thesis.yaml new file mode 100644 index 0000000..f5e1765 --- /dev/null +++ b/configs/undergrad_thesis.yaml @@ -0,0 +1,330 @@ +# =========================================================================== +# 本科毕业论文格式配置示例 +# =========================================================================== +# 本文件展示所有可配置字段及其默认值。 +# 使用 YAML 锚点 &global_format 定义全局基础格式, +# 各章节通过 <<: *global_format 继承后覆写特定字段。 +# +# 支持的单位格式: +# 字号:小四 / 四号 / 三号 / 小二 / 五号 / 数值(pt) +# 间距:0行 / 0.5行 / 1行 / 12磅 / 0.5cm +# 缩进:2字符 / -2.2字符(悬挂) / 0.75cm / 24磅 +# 行距:单倍行距 / 1.5倍行距 / 2倍行距 / 最小值 / 固定值 / 多倍行距 +# 字体颜色:黑色 / 红色 / 蓝色 等 +# 对齐:左对齐 / 居中对齐 / 右对齐 / 两端对齐 / 分散对齐 +# =========================================================================== + +template_name: "山西晋中理工学院(毕业设计)" + +# --------------------------------------------------------------------------- +# 预警开关:控制各字段差异是否在批注中显示(false=不显示该类型差异) +# --------------------------------------------------------------------------- +style_checks_warning: + bold: true + italic: false + underline: false + font_size: true + font_name: true + font_color: false + alignment: true + space_before: true + space_after: true + line_spacing: true + line_spacingrule: true + left_indent: true + right_indent: true + first_line_indent: true + builtin_style_name: true + +# --------------------------------------------------------------------------- +# 全局基础格式(其他节通过 <<: *global_format 继承后再覆写) +# --------------------------------------------------------------------------- +global_format: &global_format + # -- 段落格式 -- + alignment: '两端对齐' # 左对齐/居中对齐/右对齐/两端对齐/分散对齐 + space_before: "0行" # 段前间距 + space_after: "0行" # 段后间距 + line_spacingrule: "1.5倍行距" # 单倍行距/1.5倍行距/2倍行距/最小值/固定值/多倍行距 + line_spacing: '1.5倍' # 行距参数 + left_indent: "0字符" # 左侧缩进 + right_indent: "0字符" # 右侧缩进 + first_line_indent: '2字符' # 首行缩进(正值)/ 悬挂缩进(负值,如 -2.2字符) + builtin_style_name: '正文' # Word 内置样式名(Heading 1, Heading 2, 正文, 题注 等) + # -- 字符格式 -- + chinese_font_name: '宋体' # 中文字体 + english_font_name: 'Times New Roman' # 英文字体 + font_size: '小四' # 字号(四号/三号/小二/五号 等) + font_color: '黑色' # 字体颜色 + bold: false # 加粗 + italic: false # 斜体 + underline: false # 下划线 + +# =========================================================================== +# 摘要(中英文) +# =========================================================================== +abstract: + # ----------------------------------------------------------------------- + # 中文摘要 + # ----------------------------------------------------------------------- + chinese: + # -- 中文摘要标题("摘要"二字)-- + chinese_title: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + chinese_font_name: '黑体' + font_size: '四号' + bold: false + + # -- 中文摘要正文 -- + chinese_content: + <<: *global_format + alignment: '两端对齐' + + # ----------------------------------------------------------------------- + # 英文摘要 + # ----------------------------------------------------------------------- + english: + # -- 英文摘要标题("Abstract")-- + english_title: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + font_size: '四号' + bold: false + + # -- 英文摘要正文 -- + english_content: + <<: *global_format + alignment: '两端对齐' + + # ----------------------------------------------------------------------- + # 关键词(中英文) + # ----------------------------------------------------------------------- + keywords: + chinese: + # 从 global_format 继承,再覆写关键词特有字段 + <<: *global_format + alignment: '两端对齐' + first_line_indent: '2字符' + font_size: '小四' + # -- 关键词标签("关键词:")的字符格式 -- + label: + <<: *global_format + chinese_font_name: '黑体' + font_size: '四号' + bold: false + # -- 关键词业务规则(均通过 enabled 开关控制) -- + rules: + keyword_count: + enabled: true + count_min: 3 # 最少关键词数 + count_max: 5 # 最多关键词数 + trailing_punctuation: + enabled: true + forbidden_chars: ";,。、" # 中文关键词末尾禁止出现的标点 + + english: + <<: *global_format + alignment: '两端对齐' + first_line_indent: '2字符' + font_size: '小四' + label: + <<: *global_format + font_size: '四号' + bold: false + rules: + keyword_count: + enabled: true + count_min: 3 + count_max: 5 + +# =========================================================================== +# 各级标题(章、节、小节) +# =========================================================================== +headings: + # -- 一级标题(第1章)-- + level_1: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + chinese_font_name: '黑体' + font_size: '小二' + bold: false + space_before: "0.5行" + space_after: "0.5行" + builtin_style_name: 'Heading 1' + + # -- 二级标题(1.1)-- + level_2: + <<: *global_format + alignment: '左对齐' + first_line_indent: '0字符' + chinese_font_name: '黑体' + font_size: '三号' + bold: false + space_before: "0行" + space_after: "0行" + builtin_style_name: 'Heading 2' + + # -- 三级标题(1.1.1)-- + level_3: + <<: *global_format + alignment: '左对齐' + first_line_indent: '0字符' + chinese_font_name: '黑体' + font_size: '小四' + bold: false + space_before: "0行" + space_after: "0行" + builtin_style_name: 'Heading 3' + +# =========================================================================== +# 正文段落 +# =========================================================================== +body_text: + <<: *global_format + rules: + punctuation: + enabled: true + +# =========================================================================== +# 插图题注(Figure) +# =========================================================================== +figures: + <<: *global_format + font_size: '五号' + builtin_style_name: '题注' + alignment: '居中对齐' + first_line_indent: '0字符' + # -- 插图特有字段 -- + caption_prefix: '图' # 图注编号前缀 + # -- 图片段落格式(包含内联图片的段落)-- + image: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false + +# =========================================================================== +# 表格(题注 + 表格对象 + 表格内容) +# =========================================================================== +tables: + <<: *global_format + font_size: '五号' + builtin_style_name: '题注' + alignment: '居中对齐' + first_line_indent: '0字符' + # -- 表格题注特有字段 -- + caption_prefix: '表' # 表注编号前缀 + # -- 表格对象格式(表格整体对齐、环绕)-- + object: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + # -- 表格内容格式(单元格内文字)-- + content: + <<: *global_format + chinese_font_name: '宋体' + english_font_name: 'Times New Roman' + font_size: '五号' + line_spacingrule: '单倍行距' + alignment: '居中对齐' + first_line_indent: '0字符' + space_before: "0行" + space_after: "0行" + rules: + caption_numbering: + enabled: true + separator: '.' + label_number_space: false + +# =========================================================================== +# 参考文献 +# =========================================================================== +references: + # -- 参考文献标题("参考文献")-- + title: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + chinese_font_name: '黑体' + font_size: '三号' + bold: false + + # -- 参考文献条目内容 -- + content: + <<: *global_format + alignment: '两端对齐' + first_line_indent: '-2.2字符' # 悬挂缩进(负值=悬挂) + left_indent: '0.26字符' # 文本之前 + chinese_font_name: '宋体' + english_font_name: 'Times New Roman' + font_size: '五号' + +# =========================================================================== +# 致谢 +# =========================================================================== +acknowledgements: + # -- 致谢标题("致谢")-- + title: + <<: *global_format + alignment: '居中对齐' + first_line_indent: '0字符' + chinese_font_name: '黑体' + font_size: '小二' + bold: false + + # -- 致谢正文 -- + content: + <<: *global_format + alignment: '两端对齐' + font_size: '五号' + +# =========================================================================== +# 标题自动编号 +# =========================================================================== +numbering: + enabled: true # 总开关:是否启用自动编号 + + # -- 题注编号校验/修正 -- + captions: + enabled: true # 是否启用题注编号校验/修正 + separator: '.' # 章节号与题注编号间的分隔符 + label_number_space: false # 标签与编号间加空格(图 1.1 vs 图1.1) + + # -- 一级标题编号(如"第1章")-- + level_1: + enabled: true + template: '%1' # %1=本级序号 + suffix: 'space' # 编号后分隔符:tab / space / nothing + numbering_indent: # [可选] 编号缩进,如 '0字符' + text_indent: # [可选] 文本缩进(悬挂缩进),如 '0.75cm' + + # -- 二级标题编号(如"1.1")-- + level_2: + enabled: true + template: '%1.%2' # %1=上级序号, %2=本级序号 + suffix: 'space' + numbering_indent: + text_indent: + + # -- 三级标题编号(如"1.1.1")-- + level_3: + enabled: true + template: '%1.%2.%3' + suffix: 'space' + numbering_indent: + text_indent: + + # -- 参考文献条目编号(如"[1]")-- + references: + enabled: true + template: '[%1]' + suffix: 'space' + numbering_indent: + text_indent: # 悬挂缩进,如 '2.2字符'(优先级高于 references.content.first_line_indent) diff --git a/src/wordformat/api/__init__.py b/src/wordformat/api/__init__.py index 65304a8..07bf362 100644 --- a/src/wordformat/api/__init__.py +++ b/src/wordformat/api/__init__.py @@ -274,6 +274,55 @@ def download_file(filename: str): raise HTTPException(status_code=500, detail=f"文件下载失败:{str(e)}") from e +# ---------------------- 配置文件管理 ---------------------- +CONFIGS_DIR = BASE_DIR / "configs" +CONFIGS_DIR.mkdir(parents=True, exist_ok=True) + + +@app.get("/configs", summary="获取可用配置文件列表") +def list_configs(): + """返回 configs 目录下的所有 YAML 文件名。""" + try: + files = [f.name for f in CONFIGS_DIR.glob("*.yaml") if f.is_file()] + [ + f.name for f in CONFIGS_DIR.glob("*.yml") if f.is_file() + ] + return {"code": 200, "data": sorted(set(files))} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +@app.get("/configs/{filename}", summary="读取指定配置文件内容") +def read_config(filename: str): + """返回配置文件 YAML 内容。""" + file_path = CONFIGS_DIR / filename + if not file_path.is_file(): + raise HTTPException(status_code=404, detail=f"配置文件 {filename} 不存在") + try: + with open(file_path, encoding="utf-8") as f: + return {"code": 200, "data": f.read()} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + +class SaveConfigRequest(BaseModel): + filename: str + content: str + + +@app.post("/configs/save", summary="保存配置文件到 configs 目录") +def save_config(req: SaveConfigRequest): + """将 YAML 内容保存到 configs 目录。""" + if not req.filename.endswith((".yaml", ".yml")): + req.filename += ".yaml" + file_path = CONFIGS_DIR / req.filename + try: + with open(file_path, "w", encoding="utf-8") as f: + f.write(req.content) + return {"code": 200, "msg": f"配置已保存到 configs/{req.filename}"} + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) from e + + # ---------------------- 前端静态文件(Vue SPA)--------------------- # 路由优先级:API 路由(已定义)> 静态文件 # html=True 会自动将 / 映射到 index.html From baa6d32cdb55dabe1e2aee70800bff54812b0232 Mon Sep 17 00:00:00 2001 From: Afish <1593699665@qq.com> Date: Sat, 20 Jun 2026 11:10:44 +0800 Subject: [PATCH 7/9] =?UTF-8?q?fix:=20=E7=A7=BB=E9=99=A4=20Google=20Fonts?= =?UTF-8?q?=20=E4=BE=9D=E8=B5=96=EF=BC=8C=E6=94=B9=E7=94=A8=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=AD=97=E4=BD=93=E9=81=BF=E5=85=8D=E5=8A=A0=E8=BD=BD?= =?UTF-8?q?=E5=8D=A1=E9=A1=BF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WordFormatUI/index.html | 4 ---- WordFormatUI/src/App.vue | 4 ++-- WordFormatUI/src/components/DocTagChecker.vue | 2 +- WordFormatUI/src/components/SettingsPage.vue | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/WordFormatUI/index.html b/WordFormatUI/index.html index e193111..c239904 100644 --- a/WordFormatUI/index.html +++ b/WordFormatUI/index.html @@ -5,10 +5,6 @@ WordFormat - 论文格式自动化处理工具 - - - - diff --git a/WordFormatUI/src/App.vue b/WordFormatUI/src/App.vue index eca96fd..6277724 100644 --- a/WordFormatUI/src/App.vue +++ b/WordFormatUI/src/App.vue @@ -207,7 +207,7 @@ const onServerConfigSelected = ({ filename, content }) => { body { background-color: #0f172a; color: #e2e8f0; - font-family: 'Atkinson Hyperlegible', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'PingFang SC', 'Microsoft YaHei', sans-serif; -webkit-font-smoothing: antialiased; } @@ -240,7 +240,7 @@ body { } .app-title { - font-family: 'Crimson Pro', serif; + font-family: 'Georgia', 'Times New Roman', serif; font-size: 1.4rem; font-weight: 600; color: #f1f5f9; diff --git a/WordFormatUI/src/components/DocTagChecker.vue b/WordFormatUI/src/components/DocTagChecker.vue index 4f3fa69..f421b3c 100644 --- a/WordFormatUI/src/components/DocTagChecker.vue +++ b/WordFormatUI/src/components/DocTagChecker.vue @@ -442,7 +442,7 @@ watch(isFileLoaded, (loaded) => { .header-bar { background-color: #1e293b; border-bottom: 1px solid #334155; padding: 0.75rem 1.5rem; } .header-content { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 1rem; max-width: 1280px; margin: 0 auto; width: 100%; } .header-left { display: flex; flex-direction: column; gap: 0.25rem; } -.tool-title { font-size: 1.15rem; font-weight: 600; color: #f1f5f9; margin: 0; font-family: 'Crimson Pro', serif; } +.tool-title { font-size: 1.15rem; font-weight: 600; color: #f1f5f9; margin: 0; font-family: 'Georgia, Times New Roman', serif; } .stats-info { font-size: 0.8125rem; color: #64748b; } .stats-info span { font-weight: 600; color: #22c55e; } .header-right { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; } diff --git a/WordFormatUI/src/components/SettingsPage.vue b/WordFormatUI/src/components/SettingsPage.vue index 8d6a1a6..3d21b37 100644 --- a/WordFormatUI/src/components/SettingsPage.vue +++ b/WordFormatUI/src/components/SettingsPage.vue @@ -57,7 +57,7 @@ function resetDefaults() { resetSettings(); host.value = backendSettings.host; p