gongwen-skill 2.1.0 → 2.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +5 -5
- package/dsh/index.js +1 -1
- package/engine/core/document/markdown_converter.py +56 -2
- package/engine/core/document/modifier.py +19 -5
- package/engine/core/document/parser.py +29 -5
- package/engine/core/rules/checker.py +229 -13
- package/engine/inject.py +26 -8
- package/gongwen/__init__.py +1 -1
- package/gongwen/_legacy.py +31 -12
- package/gongwen/md2docx_render.py +509 -0
- package/package.json +1 -1
- package/prompts/usage-prompts.md +1 -1
- package/pyproject.toml +1 -1
- package/rules/official/_common.yaml +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@
|
|
|
4
4
|
Licensed under the MIT License. See the LICENSE file for details.
|
|
5
5
|
-->
|
|
6
6
|
|
|
7
|
+
## v2.2.0 (2026-08-28)
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
- **内容要素检查规则真正生效**:修复 37 条 `content.*` 规则(通知事项/会议要素/请示理由等)此前"定义但从不执行"的问题,新增 `_check_content_field` 按字段名映射要素关键词做宽松检查,消除每次 check 的"未支持检查字段"告警
|
|
11
|
+
- **版头检查规则生效**:修复 `header.*` 规则(CHK-CM002 令号检查、CHK-R003 主送机关检查)此前无分发分支的问题,新增 `_check_header_field`
|
|
12
|
+
- **成文日期"右空四字"**:md2docx 渲染日期段新增右缩进 4em(GB/T 9704 规范),消除 CHK-C017 长期存在的日期右空四字检查项
|
|
13
|
+
|
|
14
|
+
### Changed
|
|
15
|
+
- **signature 段落选择器修正**:`_select_paragraphs` 的 `signature` target 不再包含 `date` 段落——修复 FIX-C013(署名居中)与 FIX-C013b(18pt)误把成文日期强制居中/放大字号的问题,日期段独立按"右对齐 + 右空四字 + 16pt"处理
|
|
16
|
+
- **页码重复注入幂等化**:`_inject_even_page_footer_direct` 重写为幂等版,多次注入仅保留一个 even footerReference 与完整关系,杜绝 Word 打开报"文档损坏"(BUG-1)
|
|
17
|
+
- **加粗+链接/代码组合不再整段加粗**:markdown_converter 单片段按自身 bold 标志处理,重建片段继承原 run 字体字号(BUG-2)
|
|
18
|
+
- **标题启发式误判修复**:黑体/楷体/仿宋加粗正文句不再误判为标题(新增 `_SENTENCE_END` 句末标点保护,Level 1/2/3 三级判定)
|
|
19
|
+
- **CHK-C030 整段加粗误报修复**:忽略纯标点 run、单句段落不报,多句整段加粗仍正确检出
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
- `is_title` vs `is_heading` 属性引用错误:`_check_ending`/`_check_content_field` 此前用不存在的 `is_title` 属性,导致标题段被误纳入正文统计
|
|
23
|
+
- 一是/二是 领句段改用仿宋_GB2312(原误用楷体触发标题误判/正文字体误报)
|
|
24
|
+
- 令号正则 `\d` 被 JS 转义破坏(`d`),导致含令号命令误报 CHK-CM002
|
|
25
|
+
|
|
7
26
|
## v2.1.0 (2026-08-20)
|
|
8
27
|
|
|
9
28
|
### Added
|
package/README.md
CHANGED
|
@@ -337,7 +337,7 @@ DSH 采用 **Cordis 模块化微内核架构**:技能体系基于本地文件
|
|
|
337
337
|
git clone https://github.com/linhut/gongwen-skill.git
|
|
338
338
|
cd gongwen-skill
|
|
339
339
|
pip install -r requirements.txt # 或 pip install gongwen-skill(已上 PyPI)
|
|
340
|
-
python -m gongwen --version # 检验:gongwen-skill v2.
|
|
340
|
+
python -m gongwen --version # 检验:gongwen-skill v2.2.0
|
|
341
341
|
```
|
|
342
342
|
|
|
343
343
|
### 方式一:作为 DSH Skill 注册(基于本地文件系统)
|
|
@@ -393,7 +393,7 @@ pnpm add -w gongwen-skill
|
|
|
393
393
|
"dependencies": {
|
|
394
394
|
"@deepseek-ai/dsh-base": "...",
|
|
395
395
|
"@deepseek-ai/dsh-web-app": "...",
|
|
396
|
-
"gongwen-skill": "^2.
|
|
396
|
+
"gongwen-skill": "^2.2.0"
|
|
397
397
|
},
|
|
398
398
|
"dsh": {
|
|
399
399
|
"profile": {
|
|
@@ -448,9 +448,9 @@ dsh --profile web
|
|
|
448
448
|
| CLI 独立可执行(`python -m gongwen <命令>`) | ✅ |
|
|
449
449
|
| PyPI 上架(`pip install gongwen-skill`) | ✅ |
|
|
450
450
|
| 零外部运行时依赖(仅 python-docx/pydantic/pyyaml) | ✅ |
|
|
451
|
-
| DSH 配置化排版参数(页边距/行距/字体/默认模板版本) | ✅ v2.
|
|
451
|
+
| DSH 配置化排版参数(页边距/行距/字体/默认模板版本) | ✅ v2.2.0+ |
|
|
452
452
|
|
|
453
|
-
### DSH 插件配置化(v2.
|
|
453
|
+
### DSH 插件配置化(v2.2.0+)
|
|
454
454
|
|
|
455
455
|
DSH 插件支持通过配置文件管理排版参数,Agent 调用时自动注入,纯 CLI 用户不受影响。
|
|
456
456
|
|
|
@@ -574,7 +574,7 @@ pip install -r requirements.txt
|
|
|
574
574
|
用户:帮我优化这份会议通知的第二章节措辞
|
|
575
575
|
|
|
576
576
|
Agent:📋 合规自检报告
|
|
577
|
-
Skill 版本: v2.
|
|
577
|
+
Skill 版本: v2.2.0(多渠道自检已确认最新)
|
|
578
578
|
路径判定: B(内容优化)
|
|
579
579
|
依据: 用户指定了已有文档,且要求"优化措辞"
|
|
580
580
|
命令调用: 1. python -m gongwen optimize-content 会议通知.docx --changes changes.json --apply --paragraphs "5-8"
|
package/dsh/index.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// 公文全流程处理工具 - DSH plugin bridge (gongwen-skill, v2.
|
|
1
|
+
// 公文全流程处理工具 - DSH plugin bridge (gongwen-skill, v2.2.0+)
|
|
2
2
|
// (c) 2026 Jose AI (https://www.linhut.cn)
|
|
3
3
|
// https://github.com/linhut/gongwen-skill
|
|
4
4
|
// Licensed under the MIT License. See the LICENSE file for details.
|
|
@@ -51,6 +51,32 @@ _MD_CODE_BLOCK_RE = re.compile(r'^`{3,}')
|
|
|
51
51
|
# 行内代码 `code`
|
|
52
52
|
_MD_INLINE_CODE_RE = re.compile(r'`([^`]+)`')
|
|
53
53
|
|
|
54
|
+
# 加粗标记(**text** 或 __text__)
|
|
55
|
+
_MD_BOLD_SEG_RE = re.compile(r'(\*\*[^*]+\*\*|__[^_]+__)')
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _reconstruct_bold_segments(raw: str, cleaned: str):
|
|
59
|
+
"""把含 **..** 的原始文本映射为 [(文本, 是否加粗), ...] 片段。
|
|
60
|
+
|
|
61
|
+
片段拼接结果与 cleaned 一致时才返回片段列表;否则(含链接/代码等被清理、
|
|
62
|
+
或文本被折叠)回退为整段(避免丢失文本)。
|
|
63
|
+
"""
|
|
64
|
+
if not raw or ('**' not in raw and '__' not in raw):
|
|
65
|
+
return [(cleaned or "", False)]
|
|
66
|
+
segments = []
|
|
67
|
+
for part in _MD_BOLD_SEG_RE.split(raw):
|
|
68
|
+
if not part:
|
|
69
|
+
continue
|
|
70
|
+
if part.startswith('**') and part.endswith('**') and len(part) > 4:
|
|
71
|
+
segments.append((part[2:-2], True))
|
|
72
|
+
elif part.startswith('__') and part.endswith('__') and len(part) > 4:
|
|
73
|
+
segments.append((part[2:-2], True))
|
|
74
|
+
else:
|
|
75
|
+
segments.append((part, False))
|
|
76
|
+
if ''.join(s[0] for s in segments).strip() == (cleaned or "").strip():
|
|
77
|
+
return segments
|
|
78
|
+
return [(cleaned or "", False)]
|
|
79
|
+
|
|
54
80
|
|
|
55
81
|
def convert_markdown(model: DocumentModel) -> int:
|
|
56
82
|
"""
|
|
@@ -201,6 +227,7 @@ def convert_markdown(model: DocumentModel) -> int:
|
|
|
201
227
|
# --- 识别加粗标记 **text** ---
|
|
202
228
|
|
|
203
229
|
has_bold = False
|
|
230
|
+
raw_before_bold = text # 保存剥离 ** 前的原始文本,用于加粗段重构
|
|
204
231
|
if _MD_BOLD_RE.search(text) or _MD_BOLD_UNDER_RE.search(text):
|
|
205
232
|
has_bold = True
|
|
206
233
|
text = _MD_BOLD_RE.sub(r'\1', text)
|
|
@@ -257,8 +284,35 @@ def convert_markdown(model: DocumentModel) -> int:
|
|
|
257
284
|
r.text = ""
|
|
258
285
|
|
|
259
286
|
if has_bold and not para.is_heading:
|
|
260
|
-
|
|
261
|
-
|
|
287
|
+
# 仅加粗 **..** 标记的片段,避免整段误加粗(CHK-C030 修复)
|
|
288
|
+
_segments = _reconstruct_bold_segments(raw_before_bold, para.text)
|
|
289
|
+
if len(_segments) > 1:
|
|
290
|
+
# 重建片段时继承原 run 的字体/字号,避免丢失字体(I20 修复)
|
|
291
|
+
_base_format = None
|
|
292
|
+
for _r in para.runs:
|
|
293
|
+
if _r.text and (_r.format.font_name or _r.format.font_size_pt):
|
|
294
|
+
_base_format = _r.format
|
|
295
|
+
break
|
|
296
|
+
_new_runs = []
|
|
297
|
+
for _t, _b in _segments:
|
|
298
|
+
if not _t:
|
|
299
|
+
continue
|
|
300
|
+
_nr = Run(index=len(_new_runs), text=_t,
|
|
301
|
+
format=RunFormat(
|
|
302
|
+
font_name=_base_format.font_name if _base_format else None,
|
|
303
|
+
font_size_pt=_base_format.font_size_pt if _base_format else 16.0,
|
|
304
|
+
))
|
|
305
|
+
if _b:
|
|
306
|
+
_nr.format.bold = True
|
|
307
|
+
_new_runs.append(_nr)
|
|
308
|
+
para.runs = _new_runs
|
|
309
|
+
else:
|
|
310
|
+
# 单片段:按其自身 bold 标志处理——纯粗体段(**全段**)整段加粗;
|
|
311
|
+
# raw/cleaned 不匹配回退的单片段 bold=False 保持不加粗(I20 修复:
|
|
312
|
+
# 此前把回退也整段加粗,导致"加粗+链接/代码"段落整段变粗)
|
|
313
|
+
if _segments and _segments[0][1]:
|
|
314
|
+
for r in para.runs:
|
|
315
|
+
r.format.bold = True
|
|
262
316
|
|
|
263
317
|
if is_list and list_indent_pt > 0 and not para.is_heading:
|
|
264
318
|
para.format.left_indent_pt = list_indent_pt
|
|
@@ -74,19 +74,33 @@ def _select_paragraphs(model: DocumentModel, target: str) -> list[Paragraph]:
|
|
|
74
74
|
return [p for p in model.paragraphs
|
|
75
75
|
if not p.is_heading and p.text.strip() and id(p) not in sig_set]
|
|
76
76
|
elif target == "signature":
|
|
77
|
-
#
|
|
78
|
-
|
|
77
|
+
# P2-24 修复:signature target 只匹配署名段(role='signature'),不再包含 date——
|
|
78
|
+
# 此前匹配 role in ('signature','date') 会把落款日期段一并选中,
|
|
79
|
+
# 导致 FIX-C013(署名居中)把日期改成 center、FIX-C013b(18pt)把日期改成 18pt,
|
|
80
|
+
# 违反 GB/T 9704 成文日期"右空四字、三号仿宋(16pt)"的规范。
|
|
81
|
+
# 日期段由 target="date" 分支独立处理(右对齐 + right_indent 保留)。
|
|
82
|
+
role_sig = [p for p in model.paragraphs if p.role == 'signature']
|
|
79
83
|
if role_sig:
|
|
80
84
|
return role_sig
|
|
81
|
-
|
|
82
|
-
|
|
85
|
+
# 仅当无署名 role 时,才允许位置回退(末两段:署名+日期);
|
|
86
|
+
# 回退时仍只修署名语义的段落,不影响日期段对齐
|
|
87
|
+
non_empty = [p for p in model.paragraphs if p.text.strip() and p.role != 'date']
|
|
88
|
+
if len(non_empty) >= 2:
|
|
89
|
+
last = non_empty[-1].text.strip()
|
|
90
|
+
if re.match(r'^\d{4}年\d{1,2}月\d{1,2}日$', last) or re.match(r'^\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}$', last):
|
|
91
|
+
return non_empty[-1:]
|
|
92
|
+
return []
|
|
83
93
|
elif target == "date":
|
|
84
94
|
# 同 signature 的处理逻辑
|
|
85
95
|
role_date = [p for p in model.paragraphs if p.role == 'date']
|
|
86
96
|
if role_date:
|
|
87
97
|
return role_date
|
|
88
98
|
non_empty = [p for p in model.paragraphs if p.text.strip()]
|
|
89
|
-
|
|
99
|
+
if non_empty:
|
|
100
|
+
last = non_empty[-1].text.strip()
|
|
101
|
+
if re.match(r'^\d{4}年\d{1,2}月\d{1,2}日$', last) or re.match(r'^\d{4}[.\-/]\d{1,2}[.\-/]\d{1,2}$', last):
|
|
102
|
+
return non_empty[-1:]
|
|
103
|
+
return []
|
|
90
104
|
elif target in ('salutation', 'introduction', 'transition', 'meeting_date', 'numbered_body'):
|
|
91
105
|
# N2: 段落类型 target —— 使用 detect_paragraph_type 内容匹配
|
|
92
106
|
return [p for p in model.paragraphs if detect_paragraph_type(p.text, p.role) == target]
|
|
@@ -51,6 +51,10 @@ _H1_WESTERN_PATTERN = re.compile(r'^[IVXLCDM]+\.\s+')
|
|
|
51
51
|
_H2_WESTERN_PATTERN = re.compile(r'^[A-Z]\.\s+')
|
|
52
52
|
_H4_WESTERN_PATTERN = re.compile(r'^[a-z]\.\s+')
|
|
53
53
|
|
|
54
|
+
# 句末标点(P2-16 修复:标题不以这些标点结尾,正文强调句/引用句通常会以它们结尾,
|
|
55
|
+
# 用于排除"黑体/楷体/仿宋加粗的正文短句"被误判为标题)
|
|
56
|
+
_SENTENCE_END = ('。', '!', '?', ':', ';', ',', '.', '!', '?', ':', ';', ',')
|
|
57
|
+
|
|
54
58
|
|
|
55
59
|
def _detect_heading_heuristic(
|
|
56
60
|
text: str, runs: list[Run], para_format: ParagraphFormat
|
|
@@ -103,7 +107,13 @@ def _detect_heading_heuristic(
|
|
|
103
107
|
|
|
104
108
|
# --- Level 1: 一级标题(黑体)---
|
|
105
109
|
if has_font_signal and ("黑体" in font_lower or font_lower in ("simhei",)):
|
|
106
|
-
|
|
110
|
+
# P2-16 修复:排除以句末标点结尾的正文强调句(如"重要提示:…")。
|
|
111
|
+
# 一级标题通常较短且不以句号/冒号等结尾。
|
|
112
|
+
if alignment == "center":
|
|
113
|
+
return True, 1
|
|
114
|
+
if len(text_stripped) < 30 and not text_stripped.endswith(_SENTENCE_END):
|
|
115
|
+
return True, 1
|
|
116
|
+
if is_bold and not text_stripped.endswith(_SENTENCE_END):
|
|
107
117
|
return True, 1
|
|
108
118
|
|
|
109
119
|
# 格式信号:"一、" + 加粗或黑体
|
|
@@ -115,9 +125,15 @@ def _detect_heading_heuristic(
|
|
|
115
125
|
if _H1_WESTERN_PATTERN.match(text_stripped) and len(text_stripped) < 50:
|
|
116
126
|
return True, 1
|
|
117
127
|
|
|
118
|
-
# --- Level 2:
|
|
119
|
-
|
|
120
|
-
|
|
128
|
+
# --- Level 2: 二级标题(楷体,无需加粗)---
|
|
129
|
+
# GB/T 9704 二级标题为楷体,不加粗。若段落字体为楷体(含 楷体_GB2312),
|
|
130
|
+
# 即使内容为 "2.1" 等数字编号,也优先识别为二级标题而非三级标题,
|
|
131
|
+
# 避免 converter 的 "###"→二级标题与 check 的 "2.1"→三级标题启发式冲突。
|
|
132
|
+
# P2-16 修复:增加"不以句末标点结尾"约束,排除正文楷体引用句
|
|
133
|
+
# (如"会议指出,…。")被误判为二级标题。
|
|
134
|
+
if has_font_signal and ("楷体" in font_lower or font_lower in ("kaiti", "楷体_gb2312")):
|
|
135
|
+
if len(text_stripped) < 40 and not text_stripped.endswith(_SENTENCE_END):
|
|
136
|
+
return True, 2
|
|
121
137
|
|
|
122
138
|
# "(一)" 格式(无论字体如何,此模式足够唯一)
|
|
123
139
|
if _H2_PATTERN.match(text_stripped) and len(text_stripped) < 50:
|
|
@@ -128,7 +144,8 @@ def _detect_heading_heuristic(
|
|
|
128
144
|
|
|
129
145
|
# --- Level 3: 三级标题(仿宋加粗 或 "1." + 加粗)---
|
|
130
146
|
if has_font_signal and ("仿宋" in font_lower or font_lower in ("fangsong", "仿宋_gb2312")) and is_bold:
|
|
131
|
-
|
|
147
|
+
# P2-16 修复:排除以句末标点结尾的仿宋加粗正文短句被误判为三级标题
|
|
148
|
+
if len(text_stripped) < 50 and not text_stripped.endswith(_SENTENCE_END):
|
|
132
149
|
return True, 3
|
|
133
150
|
|
|
134
151
|
if _H3_PATTERN.match(text_stripped) and is_bold and len(text_stripped) < 60:
|
|
@@ -340,6 +357,13 @@ def _assign_paragraph_roles(paragraphs: list[Paragraph]) -> None:
|
|
|
340
357
|
if not para.role and _ATTACHMENT_RE.match(para.text.strip()):
|
|
341
358
|
para.role = 'attachment'
|
|
342
359
|
|
|
360
|
+
# AI 声明段(末尾批注,如"(内容由GongWen-skill-AI生成,仅供参考)")
|
|
361
|
+
# 标记为 annotation 角色,避免 check 将其误判为正文并报格式违规
|
|
362
|
+
_AI_DECL_MARKERS = ("由GongWen-skill-AI生成", "由AI生成", "仅供参考")
|
|
363
|
+
for idx, para in non_empty:
|
|
364
|
+
if not para.role and any(m in para.text for m in _AI_DECL_MARKERS):
|
|
365
|
+
para.role = 'annotation'
|
|
366
|
+
|
|
343
367
|
# 其余非空段落默认为 body
|
|
344
368
|
for idx, para in non_empty:
|
|
345
369
|
if not para.role:
|
|
@@ -92,6 +92,16 @@ def check_document(model: DocumentModel, rules: dict[str, Any]) -> list[CheckIss
|
|
|
92
92
|
elif field_path.startswith("ending."):
|
|
93
93
|
# FIX-V153-02:ending.check 结语检查(CHK-N001/R001/RPT001/RP002/L002 等)
|
|
94
94
|
issues.extend(_check_ending(model, rule_id, severity, name, expected, message))
|
|
95
|
+
elif field_path.startswith("content."):
|
|
96
|
+
# P2-22 修复:content.* 内容要素检查(CHK-N003 等 37 条规则此前无分发分支,
|
|
97
|
+
# 全部"定义但从不执行"且每次 check 刷"未支持字段"告警)
|
|
98
|
+
issues.extend(_check_content_field(model, rule_id, severity, name, field_path,
|
|
99
|
+
expected, message))
|
|
100
|
+
elif field_path.startswith("header."):
|
|
101
|
+
# P2-23 修复:header.* 版头检查(CHK-CM002 令号、CHK-R003 主送机关)
|
|
102
|
+
# 此前无分发分支,规则定义但从不执行
|
|
103
|
+
issues.extend(_check_header_field(model, rule_id, severity, name, field_path,
|
|
104
|
+
expected, message))
|
|
95
105
|
else:
|
|
96
106
|
# P1-6 修复:删除 generic else 中的硬编码索引逻辑(model.paragraphs[0]/[1]
|
|
97
107
|
# 不一定是标题/正文,检查结果会指向错误段落),未识别的 field 直接 skip + warning
|
|
@@ -313,10 +323,32 @@ def _check_heading_level(model, rule_id, severity, name, field_path, expected, m
|
|
|
313
323
|
return issues
|
|
314
324
|
|
|
315
325
|
|
|
326
|
+
# P2-17:CHK-C030 辅助——纯标点 run 判定(不含任何正文内容的 run)
|
|
327
|
+
# 用字符集判断替代正则,避免 r'...\[...]' 无效转义 SyntaxWarning,且匹配更可靠
|
|
328
|
+
_PUNCT_ONLY_CHARS = set('。!?:;,、.…!?:;,()()""''《》〈〉【】[]')
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _is_punct_only(text: str) -> bool:
|
|
332
|
+
"""判断 run 是否只含标点/空白(用于整段加粗判定时忽略标点 run)。"""
|
|
333
|
+
t = (text or "").strip()
|
|
334
|
+
if not t:
|
|
335
|
+
return True
|
|
336
|
+
return all(ch in _PUNCT_ONLY_CHARS or ch.isspace() for ch in t)
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _count_sentence_end(text: str) -> int:
|
|
340
|
+
"""统计句末标点(。!? 以及英文 . ! ?)的数量,用于判断单句/多句段落。"""
|
|
341
|
+
if not text:
|
|
342
|
+
return 0
|
|
343
|
+
return sum(1 for ch in text if ch in '。!?.!?')
|
|
344
|
+
|
|
345
|
+
|
|
316
346
|
def _check_body(model, rule_id, severity, name, field_path, expected, message) -> list[CheckIssue]:
|
|
317
|
-
"""Check body paragraph formatting (excluding signature/date)."""
|
|
347
|
+
"""Check body paragraph formatting (excluding signature/date/annotation/recipient)."""
|
|
318
348
|
issues = []
|
|
319
|
-
|
|
349
|
+
# 顶格左对齐的段落(主送机关/称呼段、署名、日期、AI 声明批注)不属于正文,
|
|
350
|
+
# 不应套用正文的缩进/对齐/字体检查
|
|
351
|
+
_EXCLUDE_ROLES = {'signature', 'date', 'annotation', 'notes', 'recipient', 'salutation'}
|
|
320
352
|
body_paras = [p for p in model.paragraphs
|
|
321
353
|
if not p.is_heading and p.text.strip() and p.role not in _EXCLUDE_ROLES]
|
|
322
354
|
if not body_paras:
|
|
@@ -403,13 +435,23 @@ def _check_body(model, rule_id, severity, name, field_path, expected, message) -
|
|
|
403
435
|
elif sub_field == "bold_range":
|
|
404
436
|
# 检查正文段落是否整段加粗(通常只有首句/点题词应加粗)
|
|
405
437
|
if para.runs and para.text.strip():
|
|
406
|
-
|
|
438
|
+
# P2-17 修复:忽略纯标点 run(句号/逗号等不含正文的 run),
|
|
439
|
+
# 避免"首句加粗含句号"时标点 run 加粗触发整段加粗误报
|
|
440
|
+
_CONTENT_RUNS = [r for r in para.runs
|
|
441
|
+
if r.text.strip() and not _is_punct_only(r.text)]
|
|
442
|
+
if not _CONTENT_RUNS:
|
|
443
|
+
continue
|
|
444
|
+
all_bold = all(r.format.bold for r in _CONTENT_RUNS)
|
|
407
445
|
if all_bold:
|
|
408
446
|
# B-09(方案三):排除不应加粗的段落类型(称呼/导语/过渡/署名/会议日期等),
|
|
409
447
|
# 避免这些段落被误标为 body 后报告"整段加粗"问题造成噪音
|
|
410
448
|
from engine.core.document.modifier import should_bold_first_sentence
|
|
411
449
|
if not should_bold_first_sentence(para.text, para.role):
|
|
412
450
|
continue
|
|
451
|
+
# P2-17 修复:单句段落(仅 1 个句末标点)的首句加粗=整段加粗,
|
|
452
|
+
# 属于合理排版(首句即整段),不报;多句段落整段加粗才报
|
|
453
|
+
if _count_sentence_end(para.text) <= 1:
|
|
454
|
+
continue
|
|
413
455
|
issues.append(CheckIssue(
|
|
414
456
|
rule_id=rule_id, check_type="content", severity=severity,
|
|
415
457
|
name=name, location=f"paragraph:{para.index}",
|
|
@@ -463,17 +505,32 @@ def _check_page_setup(model, rule_id, severity, name, field_path, expected, mess
|
|
|
463
505
|
|
|
464
506
|
|
|
465
507
|
def _check_signature_area(model, rule_id, severity, name, field_path, expected, message, rules) -> list[CheckIssue]:
|
|
466
|
-
"""Check signature/date area formatting.
|
|
508
|
+
"""Check signature/date area formatting.
|
|
509
|
+
|
|
510
|
+
仅检查落款/日期段落(默认取最后 2 个非空段落):
|
|
511
|
+
- signature.* 字段只检查署名段(角色 signature,通常是倒数第 2 段)
|
|
512
|
+
- date.* 字段只检查日期段(角色 date,通常是最后 1 段)
|
|
513
|
+
避免把署名/日期规则同时套在两个段落上造成误报。
|
|
514
|
+
"""
|
|
467
515
|
issues = []
|
|
468
|
-
paras = [p for p in model.paragraphs if not p.is_heading and p.text.strip()
|
|
516
|
+
paras = [p for p in model.paragraphs if not p.is_heading and p.text.strip()
|
|
517
|
+
and p.role not in ('annotation', 'notes')]
|
|
469
518
|
if not paras:
|
|
470
519
|
return issues
|
|
471
520
|
|
|
472
|
-
# Signature area: only last 2 paragraphs (落款单位 + 日期)
|
|
473
|
-
sig_paras = paras[-2:] if len(paras) >= 2 else paras
|
|
474
521
|
sub_field = field_path.split(".", 1)[1] if "." in field_path else ""
|
|
522
|
+
is_date = field_path.startswith("date.")
|
|
475
523
|
|
|
476
|
-
|
|
524
|
+
# 按角色取签名段/日期段。仅当文档确实存在落款/日期时才检查,
|
|
525
|
+
# 避免把无落款的正文末段误判为签名/日期造成误报(位置回退不再使用)。
|
|
526
|
+
if is_date:
|
|
527
|
+
target = [p for p in paras if p.role == 'date']
|
|
528
|
+
else:
|
|
529
|
+
target = [p for p in paras if p.role == 'signature']
|
|
530
|
+
if not target:
|
|
531
|
+
return issues
|
|
532
|
+
|
|
533
|
+
for para in target:
|
|
477
534
|
if sub_field == "align":
|
|
478
535
|
if para.format.alignment and para.format.alignment != str(expected).lower():
|
|
479
536
|
issues.append(CheckIssue(
|
|
@@ -561,8 +618,11 @@ def _check_paragraph_type_field(model, rule_id, severity, name, field_path, expe
|
|
|
561
618
|
))
|
|
562
619
|
break
|
|
563
620
|
elif sub_field == "bold":
|
|
564
|
-
|
|
565
|
-
|
|
621
|
+
# 编号正文(一是/二是…):仅要求首句(首 run)加粗,其余正文不应加粗。
|
|
622
|
+
# 其余段落类型:按首 run 判断即可(段落级加粗风格由 bold_range 规则另行检查)。
|
|
623
|
+
_runs_to_check = para.runs[:1] if target == 'numbered_body' else para.runs[:1]
|
|
624
|
+
for run in _runs_to_check:
|
|
625
|
+
if run.format.bold is not None and bool(run.format.bold) != bool(expected):
|
|
566
626
|
issues.append(CheckIssue(
|
|
567
627
|
rule_id=rule_id, check_type="format", severity=severity,
|
|
568
628
|
name=name, location=f"paragraph:{para.index}",
|
|
@@ -646,8 +706,9 @@ def _check_ending(model, rule_id: str, severity: str, name: str,
|
|
|
646
706
|
text = text.strip()
|
|
647
707
|
role = getattr(p, 'role', '') or ''
|
|
648
708
|
# 排除标题、落款(signature/date)、批注(annotation)段——这些不是正文结语
|
|
649
|
-
|
|
650
|
-
|
|
709
|
+
# P2-22 修复:模型属性是 is_heading 而非 is_title,标题段不应纳入结语统计
|
|
710
|
+
if text and not getattr(p, 'is_heading', False) and role not in (
|
|
711
|
+
'signature', 'date', 'annotation'):
|
|
651
712
|
body_paras.append(text)
|
|
652
713
|
|
|
653
714
|
if not body_paras:
|
|
@@ -684,6 +745,159 @@ def _check_ending(model, rule_id: str, severity: str, name: str,
|
|
|
684
745
|
return issues
|
|
685
746
|
|
|
686
747
|
|
|
748
|
+
# P2-22:content.* 内容要素检查——字段名 → 要素关键词组(宽松匹配,高召回低误报)
|
|
749
|
+
_CONTENT_FIELD_KEYWORDS = {
|
|
750
|
+
"notice_items": ["时间", "地点", "要求", "请", "须", "要", "参加", "召开", "举办",
|
|
751
|
+
"组织", "开展", "落实", "遵守", "上报", "报送", "日期", "人员", "对象"],
|
|
752
|
+
"scope": ["范围", "适用于", "各", "单位", "部门", "地区", "辖区"],
|
|
753
|
+
"effective_date": ["自", "起施行", "施行", "生效", "即日", "之日起", "起执行"],
|
|
754
|
+
"validity": ["有效", "期限", "至", "自", "起"],
|
|
755
|
+
"meeting_elements": ["会议", "时间", "地点", "参加", "人员", "议题", "议程"],
|
|
756
|
+
"meeting_info": ["会议", "时间", "地点", "参加", "纪要", "议题"],
|
|
757
|
+
"reason": ["因", "由于", "为了", "鉴于", "依据", "根据"],
|
|
758
|
+
"basis": ["依据", "根据", "按照", "遵照"],
|
|
759
|
+
"purpose": ["为了", "为", "目的", "促进", "推动"],
|
|
760
|
+
"measures": ["措施", "办法", "方案", "要求", "应当", "应", "须"],
|
|
761
|
+
"proposer": ["提出", "建议", "提议", "呈报", "申报"],
|
|
762
|
+
"facts": ["事实", "情况", "经查", "查明", "核实"],
|
|
763
|
+
"items": ["事项", "内容", "如下", "包括", "如下"],
|
|
764
|
+
"legal_basis": ["依据", "根据", "依照", "按照", "法规", "条例"],
|
|
765
|
+
"decision_items": ["决定", "如下", "事项", "内容"],
|
|
766
|
+
"clauses": ["条", "款", "项", "规定", "如下"],
|
|
767
|
+
"structure": ["结构", "如下", "部分", "章节"],
|
|
768
|
+
"data": ["数据", "统计", "指标", "数字", "情况"],
|
|
769
|
+
"suggestions": ["建议", "意见", "应", "应当", "建议如下"],
|
|
770
|
+
"report_items": ["情况", "报告", "如下", "内容"],
|
|
771
|
+
"reply_to": ["关于", "收悉", "来函", "贵", "你"],
|
|
772
|
+
"attitude": ["同意", "不同意", "原则同意", "批准"],
|
|
773
|
+
"single_topic": ["一", "单一", "专项"],
|
|
774
|
+
"resolution_items": ["决定", "如下", "事项"],
|
|
775
|
+
"procedure": ["程序", "步骤", "按照", "流程"],
|
|
776
|
+
"background": ["背景", "概述", "现状", "问题"],
|
|
777
|
+
"alternatives": ["方案", "备选", "比较", "选项"],
|
|
778
|
+
"implementation_plan": ["实施", "计划", "进度", "安排", "时间表"],
|
|
779
|
+
"objectives": ["目标", "目的", "要求"],
|
|
780
|
+
"timeline": ["时间", "阶段", "进度", "月", "年", "日"],
|
|
781
|
+
"responsibilities": ["责任", "负责", "分工", "单位"],
|
|
782
|
+
"lead": ["导语", "开头", "首先"],
|
|
783
|
+
"source": ["来源", "据", "报道"],
|
|
784
|
+
"report_section": ["部分", "章节", "如下"],
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
def _check_content_field(model, rule_id: str, severity: str, name: str,
|
|
789
|
+
field_path: str, expected: str, message: str) -> list[CheckIssue]:
|
|
790
|
+
"""P2-22 修复:content.* 内容要素检查(此前所有该前缀规则被跳过并告警)。
|
|
791
|
+
|
|
792
|
+
策略:按字段名尾部映射要素关键词组,检查正文(body 段落)是否包含任一组关键词。
|
|
793
|
+
- 宽松匹配:命中任一关键词即通过(避免误报)
|
|
794
|
+
- 无法映射的字段名保持跳过(返回空列表,不产生告警噪音)
|
|
795
|
+
"""
|
|
796
|
+
issues = []
|
|
797
|
+
field_name = field_path.split(".", 1)[1] if "." in field_path else ""
|
|
798
|
+
keywords = _CONTENT_FIELD_KEYWORDS.get(field_name)
|
|
799
|
+
if not keywords:
|
|
800
|
+
return issues
|
|
801
|
+
|
|
802
|
+
try:
|
|
803
|
+
# 收集正文文本(排除标题/落款/日期/批注/称呼段)
|
|
804
|
+
body_texts = []
|
|
805
|
+
for p in model.paragraphs:
|
|
806
|
+
text = (getattr(p, 'text', '') or '').strip()
|
|
807
|
+
role = getattr(p, 'role', '') or ''
|
|
808
|
+
# P2-22 修复:模型属性是 is_heading 而非 is_title——原 is_title 永远为
|
|
809
|
+
# False,导致标题段被误纳入正文统计(标题中的"召开/组织"等词会使
|
|
810
|
+
# 空壳通知误判为"有要素"而漏报 CHK-N003)
|
|
811
|
+
if text and not getattr(p, 'is_heading', False) and role not in (
|
|
812
|
+
'signature', 'date', 'annotation', 'recipient', 'salutation'):
|
|
813
|
+
body_texts.append(text)
|
|
814
|
+
joined = ''.join(body_texts)
|
|
815
|
+
if not joined:
|
|
816
|
+
return issues
|
|
817
|
+
|
|
818
|
+
found = any(kw in joined for kw in keywords)
|
|
819
|
+
if not found:
|
|
820
|
+
issues.append(CheckIssue(
|
|
821
|
+
rule_id=rule_id,
|
|
822
|
+
check_type="content",
|
|
823
|
+
severity=severity,
|
|
824
|
+
name=name,
|
|
825
|
+
location="正文",
|
|
826
|
+
original_text=joined[-80:] if joined else "",
|
|
827
|
+
suggested_fix=expected,
|
|
828
|
+
reason=message or f"文档正文缺少相关要素(期望: {expected})",
|
|
829
|
+
))
|
|
830
|
+
except Exception as e:
|
|
831
|
+
logger.warning(f"_check_content_field 检查失败: {e}")
|
|
832
|
+
|
|
833
|
+
return issues
|
|
834
|
+
|
|
835
|
+
|
|
836
|
+
def _check_header_field(model, rule_id: str, severity: str, name: str,
|
|
837
|
+
field_path: str, expected: str, message: str) -> list[CheckIssue]:
|
|
838
|
+
"""P2-23 修复:header.* 版头检查(此前规则定义但从不执行)。
|
|
839
|
+
|
|
840
|
+
支持字段:
|
|
841
|
+
- header.doc_number(CHK-CM002 命令):检查文档是否标注令号(如"〔2026〕1号"、"第1号")
|
|
842
|
+
- header.recipient(CHK-R003 请示):检查是否含主送机关段,且只写一个主送机关
|
|
843
|
+
"""
|
|
844
|
+
import re
|
|
845
|
+
issues = []
|
|
846
|
+
field_name = field_path.split(".", 1)[1] if "." in field_path else ""
|
|
847
|
+
|
|
848
|
+
try:
|
|
849
|
+
if field_name == "doc_number":
|
|
850
|
+
# 令号模式:〔2026〕1号 / 第1号 / (2026)1号 / 2026年1号 等
|
|
851
|
+
all_text = ''.join((getattr(p, 'text', '') or '') for p in model.paragraphs)
|
|
852
|
+
has_doc_number = re.search(
|
|
853
|
+
r'[〔((]?\d{4}[〕))]?\s*\d+号|[第]\d+号|令\d+号|\d+号令',
|
|
854
|
+
all_text,
|
|
855
|
+
) is not None
|
|
856
|
+
if not has_doc_number:
|
|
857
|
+
issues.append(CheckIssue(
|
|
858
|
+
rule_id=rule_id, check_type="content", severity=severity,
|
|
859
|
+
name=name, location="文档开头",
|
|
860
|
+
original_text=all_text[:60] if all_text else "",
|
|
861
|
+
suggested_fix=expected,
|
|
862
|
+
reason=message or "命令(令)应标注令号",
|
|
863
|
+
))
|
|
864
|
+
elif field_name == "recipient":
|
|
865
|
+
# 主送机关:存在 recipient 角色的段,且应只有一个主送机关
|
|
866
|
+
recips = [p for p in model.paragraphs
|
|
867
|
+
if getattr(p, 'role', '') == 'recipient' and (p.text or '').strip()]
|
|
868
|
+
if not recips:
|
|
869
|
+
issues.append(CheckIssue(
|
|
870
|
+
rule_id=rule_id, check_type="content", severity=severity,
|
|
871
|
+
name=name, location="文档开头",
|
|
872
|
+
original_text="",
|
|
873
|
+
suggested_fix=expected,
|
|
874
|
+
reason=message or "请示应写明主送机关",
|
|
875
|
+
))
|
|
876
|
+
return issues
|
|
877
|
+
# 只写一个主送机关:一个 recipient 段 + 段内无多个机关分隔符
|
|
878
|
+
first = recips[0].text
|
|
879
|
+
if len(recips) > 1:
|
|
880
|
+
issues.append(CheckIssue(
|
|
881
|
+
rule_id=rule_id, check_type="content", severity=severity,
|
|
882
|
+
name=name, location=f"paragraph:{recips[0].index}",
|
|
883
|
+
original_text=first[:60],
|
|
884
|
+
suggested_fix="仅保留一个主送机关",
|
|
885
|
+
reason="请示一般只写一个主送机关(发现多个主送机关段)",
|
|
886
|
+
))
|
|
887
|
+
elif re.search(r'[、,,s]{2,}', first.strip().rstrip('::')) and len(first.strip().rstrip('::')) > 5:
|
|
888
|
+
issues.append(CheckIssue(
|
|
889
|
+
rule_id=rule_id, check_type="content", severity=severity,
|
|
890
|
+
name=name, location=f"paragraph:{recips[0].index}",
|
|
891
|
+
original_text=first[:60],
|
|
892
|
+
suggested_fix="仅保留一个主送机关",
|
|
893
|
+
reason="请示一般只写一个主送机关(段内含多个机关)",
|
|
894
|
+
))
|
|
895
|
+
except Exception as e:
|
|
896
|
+
logger.warning(f"_check_header_field 检查失败: {e}")
|
|
897
|
+
|
|
898
|
+
return issues
|
|
899
|
+
|
|
900
|
+
|
|
687
901
|
def _check_page_number(model, rule_id, severity, name, field_path, expected, message) -> list[CheckIssue]:
|
|
688
902
|
"""检查页脚页码域格式(P3-10:CHK-C023/024/029)。
|
|
689
903
|
|
|
@@ -711,7 +925,9 @@ def _check_page_number(model, rule_id, severity, name, field_path, expected, mes
|
|
|
711
925
|
break
|
|
712
926
|
elif sub_field == "alignment" or sub_field == "align":
|
|
713
927
|
actual = para.format.alignment
|
|
714
|
-
|
|
928
|
+
# GB/T 9704 允许"居中"或"翻页模式(单右双左,默认 footer 为 right/left)"。
|
|
929
|
+
# 翻页模式下默认 footer 对齐为 right(单页右空一字),不再强制 center。
|
|
930
|
+
if actual and actual not in ('center', 'left', 'right'):
|
|
715
931
|
issues.append(CheckIssue(
|
|
716
932
|
rule_id=rule_id, check_type="format", severity=severity,
|
|
717
933
|
name=name, location=f"footer:{hf.section_index}:paragraph:{para.index}",
|
package/engine/inject.py
CHANGED
|
@@ -485,7 +485,13 @@ def _apply_page_number_elements(para_elem, elements: list) -> None:
|
|
|
485
485
|
|
|
486
486
|
|
|
487
487
|
def _inject_even_page_footer_direct(output_path: str, fmt: str, font_name: str, size_pt: int) -> None:
|
|
488
|
-
"""直接操作 ZIP,添加偶数页页脚和 evenAndOddHeaders(单双页奇偶排版)。
|
|
488
|
+
"""直接操作 ZIP,添加偶数页页脚和 evenAndOddHeaders(单双页奇偶排版)。
|
|
489
|
+
|
|
490
|
+
幂等性(I19 修复):重复调用(如 md2docx 后再跑 pagenum/optimize --layout)
|
|
491
|
+
不会累积 even footerReference / 关系条目 / footer2.xml 重复条目——
|
|
492
|
+
此前重复调用会在 document.xml 留下多个 even footerReference、rels 关系缺失、
|
|
493
|
+
ZIP 出现重复 word/footer2.xml,导致 Word 报"文档损坏"。
|
|
494
|
+
"""
|
|
489
495
|
import zipfile
|
|
490
496
|
import io
|
|
491
497
|
from lxml import etree
|
|
@@ -502,6 +508,7 @@ def _inject_even_page_footer_direct(output_path: str, fmt: str, font_name: str,
|
|
|
502
508
|
elems = _build_page_number_xml(fmt, font_name, size_pt)
|
|
503
509
|
_apply_page_number_elements(even_p, elems)
|
|
504
510
|
|
|
511
|
+
even_rid = 'rIdFtrEven'
|
|
505
512
|
buf = io.BytesIO()
|
|
506
513
|
with zipfile.ZipFile(output_path, 'r') as zin:
|
|
507
514
|
with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zout:
|
|
@@ -516,8 +523,12 @@ def _inject_even_page_footer_direct(output_path: str, fmt: str, font_name: str,
|
|
|
516
523
|
root = etree.fromstring(data)
|
|
517
524
|
ns_w = 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'
|
|
518
525
|
ns_r = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'
|
|
526
|
+
# I19 幂等:先移除所有已有的 even footerReference,避免重复累积
|
|
527
|
+
for sp in root.findall('.//{%s}sectPr' % ns_w):
|
|
528
|
+
for ref in list(sp.findall('{%s}footerReference' % ns_w)):
|
|
529
|
+
if ref.get('{%s}type' % ns_w) == 'even':
|
|
530
|
+
sp.remove(ref)
|
|
519
531
|
# NS11 修复:动态分配偶数页页脚关系 ID,避免与已有 ID 冲突
|
|
520
|
-
even_rid = 'rIdFtrEven'
|
|
521
532
|
used_ids = {ref.get('{%s}id' % ns_r) for sp in root.findall('.//{%s}sectPr' % ns_w)
|
|
522
533
|
for ref in sp.findall('{%s}footerReference' % ns_w)}
|
|
523
534
|
n = 0
|
|
@@ -549,14 +560,21 @@ def _inject_even_page_footer_direct(output_path: str, fmt: str, font_name: str,
|
|
|
549
560
|
# I15 修复:结构化 XML 编辑(替代纯文本替换,避免破坏结构)
|
|
550
561
|
rel_root = etree.fromstring(data)
|
|
551
562
|
rel_ns = 'http://schemas.openxmlformats.org/package/2006/relationships'
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
rel.
|
|
556
|
-
|
|
557
|
-
|
|
563
|
+
# I19 幂等:清理本工具遗留的偶数页页脚关系(rIdFtrEven / rIdFtrEvenN),
|
|
564
|
+
# 避免残留关系堆积;再按实际 even_rid 写入唯一关系
|
|
565
|
+
for rel in list(rel_root):
|
|
566
|
+
rid = rel.get('Id') or ''
|
|
567
|
+
if rid == 'rIdFtrEven' or rid.startswith('rIdFtrEven'):
|
|
568
|
+
rel_root.remove(rel)
|
|
569
|
+
rel = etree.SubElement(rel_root, '{%s}Relationship' % rel_ns)
|
|
570
|
+
rel.set('Id', even_rid)
|
|
571
|
+
rel.set('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/footer')
|
|
572
|
+
rel.set('Target', 'footer2.xml')
|
|
558
573
|
zout.writestr(item.filename,
|
|
559
574
|
etree.tostring(rel_root, xml_declaration=True, encoding='UTF-8', standalone=True))
|
|
575
|
+
elif item.filename == 'word/footer2.xml':
|
|
576
|
+
# I19 幂等:跳过旧 footer2.xml,避免 ZIP 出现重复条目
|
|
577
|
+
continue
|
|
560
578
|
else:
|
|
561
579
|
zout.writestr(item.filename, data)
|
|
562
580
|
zout.writestr('word/footer2.xml',
|
package/gongwen/__init__.py
CHANGED
package/gongwen/_legacy.py
CHANGED
|
@@ -45,7 +45,7 @@ from gongwen.cli.helpers import (
|
|
|
45
45
|
parse_config_overrides as _parse_config_overrides,
|
|
46
46
|
load_rules_with_overrides as _load_rules_with_overrides,
|
|
47
47
|
)
|
|
48
|
-
__version__ = "2.
|
|
48
|
+
__version__ = "2.2.0"
|
|
49
49
|
# 版本号应与 gongwen/__init__.py 保持一致,每次发版同步更新
|
|
50
50
|
"""
|
|
51
51
|
中文公文全流程处理工具 —— 基于 GB/T 9704《党政机关公文格式》国家标准。
|
|
@@ -329,7 +329,10 @@ def cmd_md2docx(args):
|
|
|
329
329
|
- doc_type: 公文类型(默认 notice)
|
|
330
330
|
"""
|
|
331
331
|
from datetime import date as _dt
|
|
332
|
-
|
|
332
|
+
# 既定方案:md2docx 改用 python-docx 直接按 GB/T 9704 生成初稿,
|
|
333
|
+
# 不再走 DocumentModel → generate_docx 管线(规避技能版本不一致导入错误,
|
|
334
|
+
# 并保证初稿正文即三号仿宋 16pt,而非回退 11pt)
|
|
335
|
+
from gongwen.md2docx_render import render_model_to_docx as _render_docx
|
|
333
336
|
from engine.core.document.models import (
|
|
334
337
|
DocumentModel, DocumentMetadata, PageSetup,
|
|
335
338
|
Paragraph, ParagraphFormat, Run, RunFormat,
|
|
@@ -455,8 +458,11 @@ def cmd_md2docx(args):
|
|
|
455
458
|
|
|
456
459
|
# 领句加粗(路径 C 生成公文时,Markdown 中的"一是/二是/第一/第二"等领句自动加粗)
|
|
457
460
|
_BOLD_LEADIN = {
|
|
458
|
-
|
|
459
|
-
|
|
461
|
+
# P2-18 修复:一是/二是 领句段是"编号列举正文",字体保持仿宋_GB2312(正文字体),
|
|
462
|
+
# 仅领句加粗;不再使用楷体(楷体是二级标题字体,正文领句用楷体会触发
|
|
463
|
+
# parser 楷体标题判定 / CHK-C004 正文字体误报)
|
|
464
|
+
'一是': '仿宋_GB2312', '二是': '仿宋_GB2312', '三是': '仿宋_GB2312',
|
|
465
|
+
'四是': '仿宋_GB2312', '五是': '仿宋_GB2312',
|
|
460
466
|
'第一,': '仿宋_GB2312', '第二,': '仿宋_GB2312', '第三,': '仿宋_GB2312',
|
|
461
467
|
'一要': '仿宋_GB2312', '二要': '仿宋_GB2312', '三要': '仿宋_GB2312',
|
|
462
468
|
}
|
|
@@ -475,9 +481,11 @@ def cmd_md2docx(args):
|
|
|
475
481
|
para.runs[0].format.bold = True
|
|
476
482
|
para.runs[0].format.font_name = _BOLD_LEADIN[matched]
|
|
477
483
|
para.runs[0].format.font_size_pt = 16.0
|
|
484
|
+
# 领句加粗的余下部分沿用领句字体(一律仿宋_GB2312 正文字体),
|
|
485
|
+
# 使整段字体统一且保持正文字体,符合 CHK-C004 正文字体要求
|
|
478
486
|
para.runs.append(Run(
|
|
479
487
|
index=len(para.runs), text=remaining,
|
|
480
|
-
format=RunFormat(font_name=
|
|
488
|
+
format=RunFormat(font_name=_BOLD_LEADIN[matched], font_size_pt=16.0),
|
|
481
489
|
))
|
|
482
490
|
|
|
483
491
|
# 落款与日期(P10: 署名前增加2个空行;P4: 署名段居中 18pt)
|
|
@@ -527,18 +535,29 @@ def cmd_md2docx(args):
|
|
|
527
535
|
else:
|
|
528
536
|
today = _dt.today().strftime("%Y-%m-%d")
|
|
529
537
|
out = Path(f"修订版+{doc_type}-草稿+{today}+v1.docx")
|
|
530
|
-
|
|
538
|
+
# 既定方案:直接按 GB/T 9704 渲染初稿(python-docx 直写,不走通用管线)
|
|
539
|
+
_render_docx(model, str(out), rules=rules,
|
|
540
|
+
no_ai_declaration=getattr(args, "no_ai_declaration", False))
|
|
531
541
|
|
|
532
|
-
# FIX-B002:md2docx
|
|
542
|
+
# FIX-B002:md2docx 补充页码注入(默认从 rules 读取,回退到省筹委会规范)
|
|
533
543
|
try:
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
"enabled": True,
|
|
544
|
+
# 页码默认样式取自 rules(FIX-C025 修复规则的值),若 rules 未定义则用硬编码回退
|
|
545
|
+
_pn = {
|
|
537
546
|
"font": "宋体",
|
|
538
547
|
"size": 14,
|
|
539
|
-
"alignment": "
|
|
548
|
+
"alignment": "right", # 翻页模式(单右双左),GB/T 9704
|
|
540
549
|
"format": "- {PAGE} -",
|
|
541
|
-
}
|
|
550
|
+
}
|
|
551
|
+
for fr in (rules or {}).get("fix_rules", []):
|
|
552
|
+
if fr.get("action") == "set_page_number" and isinstance(fr.get("value"), dict):
|
|
553
|
+
_pv = fr["value"]
|
|
554
|
+
_pn["font"] = _pv.get("font", _pn["font"])
|
|
555
|
+
_pn["size"] = int(str(_pv.get("size", _pn["size"])).replace("pt", ""))
|
|
556
|
+
_pn["alignment"] = _pv.get("alignment", _pn["alignment"])
|
|
557
|
+
_pn["format"] = _pv.get("format", _pn["format"])
|
|
558
|
+
break
|
|
559
|
+
from inject import inject_page_number
|
|
560
|
+
inject_page_number(str(out), {"enabled": True, **_pn})
|
|
542
561
|
except Exception as e:
|
|
543
562
|
print(f" ⚠️ 页码注入失败({e}),跳过", file=sys.stderr)
|
|
544
563
|
|
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# (c) 2026 Jose AI (https://www.linhut.cn)
|
|
4
|
+
# https://github.com/linhut/gongwen-skill
|
|
5
|
+
# Licensed under the MIT License. See the LICENSE file for details.
|
|
6
|
+
#
|
|
7
|
+
# 本模块仅服务于 gongwen 的 md2docx 子命令。
|
|
8
|
+
"""md2docx 渲染器:直接用 python-docx 按 GB/T 9704 生成公文初稿。
|
|
9
|
+
|
|
10
|
+
既定方案(改造说明)
|
|
11
|
+
====================
|
|
12
|
+
原 cmd_md2docx 走「DocumentModel → generate_docx」通用管线:
|
|
13
|
+
1) 该管线跨模块引用(如 engine.core.document.parser 等),存在
|
|
14
|
+
「技能版本不一致」导致的导入错误风险;
|
|
15
|
+
2) generate_docx 的 docDefaults 未显式设置字号,正文回退为 11pt,
|
|
16
|
+
不符合 GB/T 9704「正文用三号仿宋_GB2312(16pt)」;
|
|
17
|
+
3) 初稿本身并不合规,需完全依赖后续 optimize 兜底。
|
|
18
|
+
|
|
19
|
+
本模块改为用 python-docx 直接构建初稿:页面 A4 + 省筹委会页边距、大标题二号
|
|
20
|
+
方正小标宋、黑体一级标题、楷体二级标题、仿宋 16pt 正文、落款/日期、AI 声明段。
|
|
21
|
+
初稿按 rules 配置逐项排版,输出后仍由外部 optimize 做合规兜底修复。
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import re
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from docx import Document
|
|
29
|
+
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
|
30
|
+
from docx.oxml import OxmlElement
|
|
31
|
+
from docx.oxml.ns import qn
|
|
32
|
+
from docx.shared import Mm, Cm, Pt, RGBColor
|
|
33
|
+
|
|
34
|
+
from engine.core.document.font_utils import set_run_font, BODY_FONT, LATIN_FONT
|
|
35
|
+
|
|
36
|
+
# ---------------------------------------------------------------------------
|
|
37
|
+
# 小工具
|
|
38
|
+
# ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
_ALIGN_MAP = {
|
|
41
|
+
"left": WD_ALIGN_PARAGRAPH.LEFT,
|
|
42
|
+
"center": WD_ALIGN_PARAGRAPH.CENTER,
|
|
43
|
+
"right": WD_ALIGN_PARAGRAPH.RIGHT,
|
|
44
|
+
"justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _to_pt(value: Any, default: float = 16.0) -> float:
|
|
49
|
+
"""把 '16pt' / 16 / '16磅' 统一转成磅值。"""
|
|
50
|
+
if value is None:
|
|
51
|
+
return default
|
|
52
|
+
if isinstance(value, bool):
|
|
53
|
+
return default
|
|
54
|
+
if isinstance(value, (int, float)):
|
|
55
|
+
return float(value)
|
|
56
|
+
m = re.match(r"^\s*([\d.]+)\s*(?:pt|磅)?\s*$", str(value))
|
|
57
|
+
if m:
|
|
58
|
+
return float(m.group(1))
|
|
59
|
+
return default
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _align(align: str | None):
|
|
63
|
+
return _ALIGN_MAP.get((align or "").lower(), WD_ALIGN_PARAGRAPH.JUSTIFY)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# 中文公文编号标题模式(与 engine parser 的内容信号启发式对齐)
|
|
67
|
+
_H1_RE = re.compile(r"^[一二三四五六七八九十]+、[^、].{0,30}$")
|
|
68
|
+
_H2_RE = re.compile(r"^([一二三四五六七八九十]+).{0,40}$")
|
|
69
|
+
_H3_RE = re.compile(r"^\d+[.、].{0,40}$")
|
|
70
|
+
_H4_RE = re.compile(r"^(\d+).{0,40}$")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _detect_heading_level(text: str) -> int | None:
|
|
74
|
+
"""对未用 # 标记的正文段落做标题层级内容检测。
|
|
75
|
+
|
|
76
|
+
返回 1/2/3/4 标题层级;若文本过长或不像标题则返回 None。
|
|
77
|
+
与 parser._detect_heading_heuristic 的"内容信号"路径保持一致,
|
|
78
|
+
使 md2docx 初稿即使不写 # 也能套用黑体/楷体等标题格式。
|
|
79
|
+
"""
|
|
80
|
+
t = (text or "").strip()
|
|
81
|
+
if not t or len(t) > 40:
|
|
82
|
+
return None
|
|
83
|
+
if _H1_RE.match(t):
|
|
84
|
+
return 1
|
|
85
|
+
if _H2_RE.match(t):
|
|
86
|
+
return 2
|
|
87
|
+
if _H3_RE.match(t):
|
|
88
|
+
return 3
|
|
89
|
+
if _H4_RE.match(t):
|
|
90
|
+
return 4
|
|
91
|
+
return None
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _set_exact_line_spacing(para, pt: float) -> None:
|
|
95
|
+
"""固定行距(GB/T 9704 统一 33 磅)。"""
|
|
96
|
+
pf = para.paragraph_format
|
|
97
|
+
pf.line_spacing = Pt(max(6, min(200, pt)))
|
|
98
|
+
pf.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _apply_doc_defaults(doc: Document, body_pt: float = 16.0) -> None:
|
|
102
|
+
"""设置文档级默认字体与字号,避免正文回退 11pt。
|
|
103
|
+
|
|
104
|
+
docDefaults 需同时写入 rFonts(中文字体)与 sz(字号,半磅值)。
|
|
105
|
+
"""
|
|
106
|
+
try:
|
|
107
|
+
styles_element = doc.styles.element
|
|
108
|
+
doc_defaults = styles_element.find(qn("w:docDefaults"))
|
|
109
|
+
if doc_defaults is None:
|
|
110
|
+
doc_defaults = OxmlElement("w:docDefaults")
|
|
111
|
+
styles_element.insert(0, doc_defaults)
|
|
112
|
+
|
|
113
|
+
rPrDefault = doc_defaults.find(qn("w:rPrDefault"))
|
|
114
|
+
if rPrDefault is None:
|
|
115
|
+
rPrDefault = OxmlElement("w:rPrDefault")
|
|
116
|
+
doc_defaults.append(rPrDefault)
|
|
117
|
+
rPr = rPrDefault.find(qn("w:rPr"))
|
|
118
|
+
if rPr is None:
|
|
119
|
+
rPr = OxmlElement("w:rPr")
|
|
120
|
+
rPrDefault.append(rPr)
|
|
121
|
+
|
|
122
|
+
rFonts = rPr.find(qn("w:rFonts"))
|
|
123
|
+
if rFonts is None:
|
|
124
|
+
rFonts = OxmlElement("w:rFonts")
|
|
125
|
+
rPr.insert(0, rFonts)
|
|
126
|
+
rFonts.set(qn("w:ascii"), LATIN_FONT)
|
|
127
|
+
rFonts.set(qn("w:hAnsi"), LATIN_FONT)
|
|
128
|
+
rFonts.set(qn("w:eastAsia"), BODY_FONT)
|
|
129
|
+
rFonts.set(qn("w:cs"), LATIN_FONT)
|
|
130
|
+
|
|
131
|
+
# 字号(半磅):三号 = 16pt → 32
|
|
132
|
+
sz = rPr.find(qn("w:sz"))
|
|
133
|
+
if sz is None:
|
|
134
|
+
sz = OxmlElement("w:sz")
|
|
135
|
+
rPr.append(sz)
|
|
136
|
+
sz.set(qn("w:val"), str(int(body_pt * 2)))
|
|
137
|
+
szCs = rPr.find(qn("w:szCs"))
|
|
138
|
+
if szCs is None:
|
|
139
|
+
szCs = OxmlElement("w:szCs")
|
|
140
|
+
rPr.append(szCs)
|
|
141
|
+
szCs.set(qn("w:val"), str(int(body_pt * 2)))
|
|
142
|
+
except Exception:
|
|
143
|
+
# 失败不影响主流程,仍按逐段显式格式输出
|
|
144
|
+
pass
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _apply_section(doc: Document, page_setup) -> None:
|
|
148
|
+
"""应用页面设置(A4 + 页边距 + 页眉/页脚距离)。"""
|
|
149
|
+
if not doc.sections:
|
|
150
|
+
return
|
|
151
|
+
sec = doc.sections[0]
|
|
152
|
+
w = getattr(page_setup, "paper_width_mm", None) or 210
|
|
153
|
+
h = getattr(page_setup, "paper_height_mm", None) or 297
|
|
154
|
+
sec.page_width = Mm(w)
|
|
155
|
+
sec.page_height = Mm(h)
|
|
156
|
+
sec.top_margin = Mm(getattr(page_setup, "margin_top_mm", None) or 28.0)
|
|
157
|
+
sec.bottom_margin = Mm(getattr(page_setup, "margin_bottom_mm", None) or 28.0)
|
|
158
|
+
sec.left_margin = Mm(getattr(page_setup, "margin_left_mm", None) or 27.0)
|
|
159
|
+
sec.right_margin = Mm(getattr(page_setup, "margin_right_mm", None) or 27.0)
|
|
160
|
+
sec.header_distance = Cm(getattr(page_setup, "header_distance_cm", None) or 1.5)
|
|
161
|
+
sec.footer_distance = Cm(getattr(page_setup, "footer_distance_cm", None) or 2.3)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
# ---------------------------------------------------------------------------
|
|
165
|
+
# 段落排版(按角色 / 标题层级套用 GB/T 9704 默认)
|
|
166
|
+
# ---------------------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def _role_style(rules: dict, para) -> dict:
|
|
169
|
+
"""根据段落角色/标题层级返回默认排版字典。"""
|
|
170
|
+
cfg = rules or {}
|
|
171
|
+
if getattr(para, "is_heading", False) and para.heading_level == 0:
|
|
172
|
+
c = cfg.get("doc_title", {})
|
|
173
|
+
return {
|
|
174
|
+
"font": c.get("font", "方正小标宋简体"),
|
|
175
|
+
"size": _to_pt(c.get("size", 22)),
|
|
176
|
+
"bold": bool(c.get("bold", False)),
|
|
177
|
+
"align": c.get("align", "center"),
|
|
178
|
+
"indent_em": 0,
|
|
179
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
180
|
+
}
|
|
181
|
+
if getattr(para, "is_heading", False) and para.heading_level == 1:
|
|
182
|
+
c = cfg.get("heading_1", {})
|
|
183
|
+
return {
|
|
184
|
+
"font": c.get("font", "黑体"),
|
|
185
|
+
"size": _to_pt(c.get("size", 16)),
|
|
186
|
+
"bold": bool(c.get("bold", False)),
|
|
187
|
+
"align": "left",
|
|
188
|
+
"indent_em": 2,
|
|
189
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
190
|
+
}
|
|
191
|
+
if getattr(para, "is_heading", False) and para.heading_level == 2:
|
|
192
|
+
c = cfg.get("heading_2", {})
|
|
193
|
+
return {
|
|
194
|
+
"font": c.get("font", "楷体_GB2312"),
|
|
195
|
+
"size": _to_pt(c.get("size", 16)),
|
|
196
|
+
"bold": bool(c.get("bold", False)),
|
|
197
|
+
"align": "left",
|
|
198
|
+
"indent_em": 2,
|
|
199
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
200
|
+
}
|
|
201
|
+
if getattr(para, "is_heading", False) and para.heading_level == 3:
|
|
202
|
+
c = cfg.get("heading_3", {})
|
|
203
|
+
return {
|
|
204
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
205
|
+
"size": _to_pt(c.get("size", 16)),
|
|
206
|
+
"bold": bool(c.get("bold", True)),
|
|
207
|
+
"align": "left",
|
|
208
|
+
"indent_em": 2,
|
|
209
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
role = para.role
|
|
213
|
+
if role == "recipient":
|
|
214
|
+
c = cfg.get("salutation", {})
|
|
215
|
+
return {
|
|
216
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
217
|
+
"size": _to_pt(c.get("size", 16)),
|
|
218
|
+
"bold": False,
|
|
219
|
+
"align": c.get("align", "left"),
|
|
220
|
+
"indent_em": 0,
|
|
221
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
222
|
+
}
|
|
223
|
+
if role == "salutation":
|
|
224
|
+
c = cfg.get("salutation", {})
|
|
225
|
+
return {
|
|
226
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
227
|
+
"size": _to_pt(c.get("size", 16)),
|
|
228
|
+
"bold": False,
|
|
229
|
+
"align": c.get("align", "left"),
|
|
230
|
+
"indent_em": 0,
|
|
231
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
232
|
+
}
|
|
233
|
+
if role == "signature":
|
|
234
|
+
c = cfg.get("signature", {})
|
|
235
|
+
return {
|
|
236
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
237
|
+
"size": _to_pt(c.get("size", 18), 18),
|
|
238
|
+
"bold": bool(c.get("bold", False)),
|
|
239
|
+
"align": c.get("align", "center"),
|
|
240
|
+
"indent_em": 0,
|
|
241
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
242
|
+
}
|
|
243
|
+
if role == "date":
|
|
244
|
+
c = cfg.get("date", {})
|
|
245
|
+
# P2-19 修复:GB/T 9704 成文日期"右空四字"——日期右对齐且右侧留 4 字空位
|
|
246
|
+
# 规则值可能是数字 4 或字符串 "4em",统一解析为 em 数值
|
|
247
|
+
_ri_raw = c.get("right_indent", 4)
|
|
248
|
+
try:
|
|
249
|
+
_ri_em = float(str(_ri_raw).replace("em", "").strip())
|
|
250
|
+
except (ValueError, TypeError):
|
|
251
|
+
_ri_em = 4.0
|
|
252
|
+
return {
|
|
253
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
254
|
+
"size": _to_pt(c.get("size", 16)),
|
|
255
|
+
"bold": False,
|
|
256
|
+
"align": c.get("align", "right"),
|
|
257
|
+
"indent_em": 0,
|
|
258
|
+
"right_indent_em": _ri_em,
|
|
259
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
260
|
+
}
|
|
261
|
+
if role == "attachment":
|
|
262
|
+
c = cfg.get("body", {})
|
|
263
|
+
return {
|
|
264
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
265
|
+
"size": _to_pt(c.get("size", 16)),
|
|
266
|
+
"bold": False,
|
|
267
|
+
"align": c.get("align", "justify"),
|
|
268
|
+
"indent_em": 2,
|
|
269
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
# 默认正文
|
|
273
|
+
c = cfg.get("body", {})
|
|
274
|
+
return {
|
|
275
|
+
"font": c.get("font", "仿宋_GB2312"),
|
|
276
|
+
"size": _to_pt(c.get("size", 16)),
|
|
277
|
+
"bold": False,
|
|
278
|
+
"align": c.get("align", "justify"),
|
|
279
|
+
"indent_em": 2,
|
|
280
|
+
"line": _to_pt(c.get("line_spacing", 33), 33),
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _add_paragraph(doc: Document, para, rules: dict):
|
|
285
|
+
"""把模型段落写入 docx(返回 python-docx Paragraph 对象)。"""
|
|
286
|
+
style = _role_style(rules, para)
|
|
287
|
+
|
|
288
|
+
# 未用 # 标记的编号标题内容检测(一、/(一)/1./(1)):
|
|
289
|
+
# 即便 markdown 未写 ##,也套用黑体/楷体等标题格式,与 check 的启发式一致
|
|
290
|
+
if not getattr(para, "is_heading", False) and para.role in (None, "body", "recipient"):
|
|
291
|
+
_detected = _detect_heading_level(para.text)
|
|
292
|
+
if _detected == 1:
|
|
293
|
+
_c = (rules or {}).get("heading_1", {})
|
|
294
|
+
style = {"font": _c.get("font", "黑体"), "size": _to_pt(_c.get("size", 16)),
|
|
295
|
+
"bold": False, "align": "left", "indent_em": 2,
|
|
296
|
+
"line": _to_pt(_c.get("line_spacing", 33), 33)}
|
|
297
|
+
elif _detected == 2:
|
|
298
|
+
_c = (rules or {}).get("heading_2", {})
|
|
299
|
+
style = {"font": _c.get("font", "楷体_GB2312"), "size": _to_pt(_c.get("size", 16)),
|
|
300
|
+
"bold": False, "align": "left", "indent_em": 2,
|
|
301
|
+
"line": _to_pt(_c.get("line_spacing", 33), 33)}
|
|
302
|
+
elif _detected == 3:
|
|
303
|
+
_c = (rules or {}).get("heading_3", {})
|
|
304
|
+
style = {"font": _c.get("font", "仿宋_GB2312"), "size": _to_pt(_c.get("size", 16)),
|
|
305
|
+
"bold": True, "align": "left", "indent_em": 2,
|
|
306
|
+
"line": _to_pt(_c.get("line_spacing", 33), 33)}
|
|
307
|
+
elif _detected == 4:
|
|
308
|
+
_c = (rules or {}).get("heading_4", {})
|
|
309
|
+
style = {"font": _c.get("font", "仿宋_GB2312"), "size": _to_pt(_c.get("size", 16)),
|
|
310
|
+
"bold": False, "align": "left", "indent_em": 2,
|
|
311
|
+
"line": _to_pt(_c.get("line_spacing", 33), 33)}
|
|
312
|
+
|
|
313
|
+
p = doc.add_paragraph()
|
|
314
|
+
pf = p.paragraph_format
|
|
315
|
+
|
|
316
|
+
# 主送机关/称呼段:顶格左对齐、无首行缩进(GB/T 9704)
|
|
317
|
+
# 通过 role 或文本特征(短文本以 :/: 结尾)识别,兼容 recipient 放正文的草稿
|
|
318
|
+
_is_salutation = para.role in ("recipient", "salutation") or (
|
|
319
|
+
para.role in (None, "body")
|
|
320
|
+
and len((para.text or "").strip()) <= 50
|
|
321
|
+
and (para.text or "").strip().endswith((":", ":"))
|
|
322
|
+
)
|
|
323
|
+
|
|
324
|
+
# 对齐:主送机关/称呼段强制左对齐;其余优先模型显式值
|
|
325
|
+
if _is_salutation:
|
|
326
|
+
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
|
327
|
+
pf.first_line_indent = Pt(0)
|
|
328
|
+
elif para.format.alignment:
|
|
329
|
+
p.alignment = _align(para.format.alignment)
|
|
330
|
+
# 首行缩进:2em = 2 × 字号
|
|
331
|
+
if para.format.first_line_indent_pt is not None and para.format.first_line_indent_pt > 0:
|
|
332
|
+
pf.first_line_indent = Pt(para.format.first_line_indent_pt)
|
|
333
|
+
elif style["indent_em"] > 0 and para.text.strip():
|
|
334
|
+
pf.first_line_indent = Pt(style["indent_em"] * style["size"])
|
|
335
|
+
else:
|
|
336
|
+
pf.first_line_indent = Pt(0)
|
|
337
|
+
else:
|
|
338
|
+
p.alignment = _align(style["align"])
|
|
339
|
+
if style["indent_em"] > 0 and para.text.strip():
|
|
340
|
+
pf.first_line_indent = Pt(style["indent_em"] * style["size"])
|
|
341
|
+
else:
|
|
342
|
+
pf.first_line_indent = Pt(0)
|
|
343
|
+
|
|
344
|
+
# 左缩进(列表等)
|
|
345
|
+
if para.format.left_indent_pt is not None:
|
|
346
|
+
pf.left_indent = Pt(para.format.left_indent_pt)
|
|
347
|
+
|
|
348
|
+
# P2-19:右缩进(成文日期"右空四字"等)
|
|
349
|
+
_right_em = style.get("right_indent_em")
|
|
350
|
+
if _right_em:
|
|
351
|
+
pf.right_indent = Pt(float(_right_em) * float(style["size"]))
|
|
352
|
+
|
|
353
|
+
# 行距
|
|
354
|
+
if para.format.line_spacing_pt is not None:
|
|
355
|
+
_set_exact_line_spacing(p, para.format.line_spacing_pt)
|
|
356
|
+
else:
|
|
357
|
+
_set_exact_line_spacing(p, style["line"])
|
|
358
|
+
|
|
359
|
+
# runs
|
|
360
|
+
runs = para.runs if para.runs else []
|
|
361
|
+
if not runs and para.text:
|
|
362
|
+
# 无 run 时按段落默认创建单 run
|
|
363
|
+
run = p.add_run(para.text)
|
|
364
|
+
set_run_font(run, style["font"])
|
|
365
|
+
run.font.size = Pt(style["size"])
|
|
366
|
+
if style["bold"]:
|
|
367
|
+
run.font.bold = True
|
|
368
|
+
else:
|
|
369
|
+
for r in runs:
|
|
370
|
+
if not r.text:
|
|
371
|
+
continue
|
|
372
|
+
run = p.add_run(r.text)
|
|
373
|
+
fmt = r.format
|
|
374
|
+
font = fmt.font_name or style["font"]
|
|
375
|
+
size = fmt.font_size_pt if fmt.font_size_pt is not None else style["size"]
|
|
376
|
+
set_run_font(run, font)
|
|
377
|
+
run.font.size = Pt(size)
|
|
378
|
+
if fmt.bold is not None:
|
|
379
|
+
run.font.bold = fmt.bold
|
|
380
|
+
elif style["bold"]:
|
|
381
|
+
run.font.bold = True
|
|
382
|
+
if fmt.italic is not None:
|
|
383
|
+
run.font.italic = fmt.italic
|
|
384
|
+
if fmt.strikethrough is True:
|
|
385
|
+
run.font.strike = True
|
|
386
|
+
if fmt.color:
|
|
387
|
+
try:
|
|
388
|
+
rgb = str(fmt.color).lstrip("#")
|
|
389
|
+
run.font.color.rgb = RGBColor(int(rgb[0:2], 16), int(rgb[2:4], 16), int(rgb[4:6], 16))
|
|
390
|
+
except Exception:
|
|
391
|
+
pass
|
|
392
|
+
return p
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def _add_table(doc: Document, tbl, anchor_elem) -> None:
|
|
396
|
+
"""创建 Word 表格并插到锚点段落之后。"""
|
|
397
|
+
if tbl.rows <= 0 or tbl.cols <= 0:
|
|
398
|
+
return
|
|
399
|
+
table = doc.add_table(rows=tbl.rows, cols=tbl.cols)
|
|
400
|
+
table.style = "Table Grid"
|
|
401
|
+
# 把表格移动到锚点元素之后
|
|
402
|
+
if anchor_elem is not None:
|
|
403
|
+
anchor_elem.addnext(table._tbl)
|
|
404
|
+
|
|
405
|
+
cells_by = {}
|
|
406
|
+
for c in tbl.cells:
|
|
407
|
+
cells_by[(c.row, c.col)] = c
|
|
408
|
+
|
|
409
|
+
for row in range(tbl.rows):
|
|
410
|
+
for col in range(tbl.cols):
|
|
411
|
+
cell_model = cells_by.get((row, col))
|
|
412
|
+
text = cell_model.text if cell_model else ""
|
|
413
|
+
cell = table.cell(row, col)
|
|
414
|
+
# 清空默认段落,写入一个 run
|
|
415
|
+
cell.text = ""
|
|
416
|
+
para = cell.paragraphs[0]
|
|
417
|
+
is_header = row == 0
|
|
418
|
+
run = para.add_run(text)
|
|
419
|
+
if is_header:
|
|
420
|
+
set_run_font(run, "黑体")
|
|
421
|
+
run.font.size = Pt(12)
|
|
422
|
+
run.font.bold = True
|
|
423
|
+
para.alignment = _align("center")
|
|
424
|
+
else:
|
|
425
|
+
set_run_font(run, "仿宋_GB2312")
|
|
426
|
+
run.font.size = Pt(12)
|
|
427
|
+
para.alignment = _align("left")
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def _add_ai_declaration(doc: Document) -> None:
|
|
431
|
+
"""文档末尾追加 AI 声明段(楷体 9pt 居中,pStyle 标记为 Annotation)。"""
|
|
432
|
+
ai_text = "(内容由GongWen-skill-AI生成,仅供参考)"
|
|
433
|
+
para = doc.add_paragraph()
|
|
434
|
+
para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
435
|
+
p_elem = para._element
|
|
436
|
+
pPr = p_elem.get_or_add_pPr()
|
|
437
|
+
pStyle = pPr.find(qn("w:pStyle"))
|
|
438
|
+
if pStyle is None:
|
|
439
|
+
pStyle = OxmlElement("w:pStyle")
|
|
440
|
+
pPr.insert(0, pStyle)
|
|
441
|
+
pStyle.set(qn("w:val"), "Annotation")
|
|
442
|
+
run = para.add_run(ai_text)
|
|
443
|
+
set_run_font(run, "楷体_GB2312")
|
|
444
|
+
run.font.size = Pt(9)
|
|
445
|
+
run.font.color.rgb = RGBColor(0x00, 0x00, 0x00)
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
# ---------------------------------------------------------------------------
|
|
449
|
+
# 主入口
|
|
450
|
+
# ---------------------------------------------------------------------------
|
|
451
|
+
|
|
452
|
+
def render_model_to_docx(model, output_path, rules: dict | None = None,
|
|
453
|
+
no_ai_declaration: bool = False):
|
|
454
|
+
"""用 python-docx 直接按 GB/T 9704 把 DocumentModel 渲染为 .docx。
|
|
455
|
+
|
|
456
|
+
替代 generate_docx(不依赖 engine.core.document.parser 等管线模块,
|
|
457
|
+
直接显式排版,保证初稿即合规)。
|
|
458
|
+
"""
|
|
459
|
+
import os
|
|
460
|
+
doc = Document()
|
|
461
|
+
|
|
462
|
+
_apply_doc_defaults(doc, body_pt=16.0)
|
|
463
|
+
_apply_section(doc, model.page_setup)
|
|
464
|
+
|
|
465
|
+
# 清理源文中的 AI 声明段落(如 "*(内容由AI生成,仅供参考)*"),
|
|
466
|
+
# 避免正文中遗留重复声明,统一由 _add_ai_declaration 追加格式化版本
|
|
467
|
+
_AI_PATTERNS = ("由GongWen-skill-AI生成", "由AI生成", "仅供参考")
|
|
468
|
+
for pi in range(len(model.paragraphs) - 1, -1, -1):
|
|
469
|
+
txt = (model.paragraphs[pi].text or "").replace("*", "").strip()
|
|
470
|
+
if any(m in txt for m in _AI_PATTERNS):
|
|
471
|
+
del model.paragraphs[pi]
|
|
472
|
+
|
|
473
|
+
# 表格按锚点段落索引分组
|
|
474
|
+
table_map: dict[int, list] = {}
|
|
475
|
+
for t in model.tables:
|
|
476
|
+
table_map.setdefault(t.insert_after_index, []).append(t)
|
|
477
|
+
|
|
478
|
+
# 渲染段落;表格插到其锚点段落之后
|
|
479
|
+
last_elem = None
|
|
480
|
+
for i, para in enumerate(model.paragraphs):
|
|
481
|
+
p = _add_paragraph(doc, para, rules)
|
|
482
|
+
last_elem = p._element
|
|
483
|
+
for t in table_map.get(i, []):
|
|
484
|
+
_add_table(doc, t, last_elem)
|
|
485
|
+
|
|
486
|
+
# 锚点为 -1(文档开头)的表格
|
|
487
|
+
for t in table_map.get(-1, []):
|
|
488
|
+
if doc.paragraphs:
|
|
489
|
+
_add_table(doc, t, doc.paragraphs[0]._element)
|
|
490
|
+
else:
|
|
491
|
+
_add_table(doc, t, None)
|
|
492
|
+
|
|
493
|
+
# 锚点超出末尾(最后一个段落之后)
|
|
494
|
+
for t in table_map.get(len(model.paragraphs), []):
|
|
495
|
+
_add_table(doc, t, last_elem)
|
|
496
|
+
|
|
497
|
+
# AI 声明
|
|
498
|
+
if not no_ai_declaration:
|
|
499
|
+
_add_ai_declaration(doc)
|
|
500
|
+
|
|
501
|
+
if isinstance(output_path, str):
|
|
502
|
+
out = output_path
|
|
503
|
+
parent = os.path.dirname(os.path.abspath(out))
|
|
504
|
+
if parent:
|
|
505
|
+
os.makedirs(parent, exist_ok=True)
|
|
506
|
+
else:
|
|
507
|
+
out = output_path
|
|
508
|
+
doc.save(out)
|
|
509
|
+
return output_path
|
package/package.json
CHANGED
package/prompts/usage-prompts.md
CHANGED
package/pyproject.toml
CHANGED
|
@@ -538,7 +538,7 @@ check_rules:
|
|
|
538
538
|
severity: P1
|
|
539
539
|
field: page_number.alignment
|
|
540
540
|
expected: "center"
|
|
541
|
-
message: "
|
|
541
|
+
message: "页码应居中标注,或多页文档采用翻页模式(单页码居右、双页码居左),格式为- X -(半角短横线)(GB/T 9704标准)"
|
|
542
542
|
|
|
543
543
|
- id: CHK-C029
|
|
544
544
|
name: "页码字号检查"
|
|
@@ -792,8 +792,8 @@ fix_rules:
|
|
|
792
792
|
value:
|
|
793
793
|
font: "宋体"
|
|
794
794
|
size: 14pt
|
|
795
|
-
alignment: "
|
|
796
|
-
format: "- {PAGE} -" # 改动4:半角短横线(PDF
|
|
795
|
+
alignment: "right"
|
|
796
|
+
format: "- {PAGE} -" # 改动4:半角短横线(PDF 规范),原全角破折号;翻页模式(单右双左)
|
|
797
797
|
|
|
798
798
|
# --- 段落类型格式修复 (P4/P5/P8: FIX-C013b/FIX-C041~C044) ---
|
|
799
799
|
- id: FIX-C013b
|