@deepwhale-cn/dsh-cn-doc-formatter 0.1.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.
@@ -0,0 +1,233 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ verify_output.py — 输出自检
5
+
6
+ 生成 docx / pdf 后核对是否真的符合本 skill 的排版规范。
7
+ 原先没有这一步,字体名写错、页边距没生效这类问题不会被发现。
8
+
9
+ 用法
10
+ ----
11
+ python verify_output.py --dir <输出目录> [--title <标题>]
12
+
13
+ 退出码 0 = 全部通过,1 = 有不符合项。
14
+ """
15
+ import argparse
16
+ import glob
17
+ import os
18
+ import sys
19
+
20
+ # 规范值(与 SKILL.md 保持一致)
21
+ SPEC = {
22
+ 'margin_top_cm': 3.7,
23
+ 'margin_bottom_cm': 3.5,
24
+ 'margin_left_cm': 2.8,
25
+ 'margin_right_cm': 2.6,
26
+ 'title_pt': 22, # 二号
27
+ 'h1_pt': 16, # 三号
28
+ 'body_pt': 14, # 四号
29
+ 'title_leading_pt': (33, 36),
30
+ 'body_leading_pt': 28,
31
+ 'tol': 0.15, # 容差
32
+ }
33
+
34
+ PASS, FAIL, WARN = '✅', '❌', '⚠️ '
35
+
36
+
37
+ def check_docx(path):
38
+ """核对 docx 的页面设置与字号、行距、字体"""
39
+ issues, notes = [], []
40
+ try:
41
+ from docx import Document
42
+ from docx.oxml.ns import qn
43
+ except ImportError:
44
+ return ['未安装 python-docx,跳过 docx 检查'], []
45
+
46
+ d = Document(path)
47
+ s = d.sections[0]
48
+
49
+ # 页边距
50
+ for name, got, want in (
51
+ ('上边距', s.top_margin.cm, SPEC['margin_top_cm']),
52
+ ('下边距', s.bottom_margin.cm, SPEC['margin_bottom_cm']),
53
+ ('左边距', s.left_margin.cm, SPEC['margin_left_cm']),
54
+ ('右边距', s.right_margin.cm, SPEC['margin_right_cm']),
55
+ ):
56
+ if abs(got - want) > SPEC['tol']:
57
+ issues.append(f'{name} {got:.2f}cm,应为 {want}cm')
58
+ else:
59
+ notes.append(f'{name} {got:.2f}cm ✓')
60
+
61
+ # 段落:字号 / 行距 / 字体
62
+ kinds = {}
63
+ fonts = set()
64
+ for p in d.paragraphs:
65
+ t = p.text.strip()
66
+ if not t or not p.runs:
67
+ continue
68
+ r = p.runs[0]
69
+ sz = r.font.size.pt if r.font.size else None
70
+ bold = bool(r.font.bold)
71
+ leading = None
72
+ pPr = p._element.find(qn('w:pPr'))
73
+ if pPr is not None:
74
+ sp = pPr.find(qn('w:spacing'))
75
+ if sp is not None and sp.get(qn('w:line')):
76
+ leading = int(sp.get(qn('w:line'))) / 20.0 # twip → pt
77
+ rPr = r._element.find(qn('w:rPr'))
78
+ if rPr is not None:
79
+ rf = rPr.find(qn('w:rFonts'))
80
+ if rf is not None:
81
+ ea = rf.get(qn('w:eastAsia'))
82
+ if ea:
83
+ fonts.add(ea)
84
+ kinds.setdefault((sz, bold), []).append((t[:22], leading))
85
+
86
+ # 标题:22pt 加粗
87
+ if not any(sz == SPEC['title_pt'] and b for (sz, b) in kinds):
88
+ issues.append(f'未找到 {SPEC["title_pt"]}pt 加粗的标题(二号)')
89
+ else:
90
+ notes.append(f'标题 {SPEC["title_pt"]}pt 加粗 ✓')
91
+
92
+ # 一级标题:16pt 加粗
93
+ if not any(sz == SPEC['h1_pt'] and b for (sz, b) in kinds):
94
+ issues.append(f'未找到 {SPEC["h1_pt"]}pt 加粗的一级标题(三号)')
95
+ else:
96
+ notes.append(f'一级标题 {SPEC["h1_pt"]}pt 加粗 ✓')
97
+
98
+ # 正文:14pt 不加粗,行距 28pt
99
+ body_ok = False
100
+ for (sz, b), items in kinds.items():
101
+ if sz == SPEC['body_pt'] and not b:
102
+ leads = [l for _t, l in items if l is not None]
103
+ if leads:
104
+ avg = sum(leads) / len(leads)
105
+ if abs(avg - SPEC['body_leading_pt']) > 1.5:
106
+ issues.append(f'正文行距 {avg:.1f}pt,应为 {SPEC["body_leading_pt"]}pt')
107
+ else:
108
+ notes.append(f'正文 {sz}pt / 行距 {avg:.0f}pt ✓')
109
+ body_ok = True
110
+ else:
111
+ issues.append('正文段落未设置固定行距')
112
+ break
113
+ if not body_ok and not any(i.startswith('正文') for i in issues):
114
+ issues.append(f'未找到 {SPEC["body_pt"]}pt 不加粗的正文段落(四号)')
115
+
116
+ # 字体一致性
117
+ if len(fonts) > 1:
118
+ issues.append(f'全文出现多种字体:{sorted(fonts)},规范要求统一仿宋')
119
+ elif fonts:
120
+ notes.append(f'全文统一字体:{sorted(fonts)[0]} ✓')
121
+ else:
122
+ warnings_only = True
123
+
124
+ # 字体是否本机真实存在(避免 Word 静默替换)
125
+ try:
126
+ sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
127
+ from docx_formatter import FOUND_FONTS
128
+ for f in fonts:
129
+ if f and f not in FOUND_FONTS:
130
+ issues.append(f'字体「{f}」本机不存在,Word 会替换为其他字体')
131
+ except Exception:
132
+ pass
133
+
134
+ return issues, notes
135
+
136
+
137
+ def check_pdf(path):
138
+ """核对 PDF 是否嵌入并使用仿宋"""
139
+ issues, notes = [], []
140
+ try:
141
+ from pypdf import PdfReader
142
+ except ImportError:
143
+ return ['未安装 pypdf,跳过 pdf 检查'], []
144
+
145
+ r = PdfReader(path)
146
+ notes.append(f'页数 {len(r.pages)}')
147
+ embedded = set()
148
+ for pg in r.pages:
149
+ try:
150
+ res = pg.get('/Resources')
151
+ fonts = res.get('/Font') if res else None
152
+ if fonts:
153
+ for _k, v in fonts.items():
154
+ o = v.get_object()
155
+ base = str(o.get('/BaseFont', ''))
156
+ if base:
157
+ embedded.add(base)
158
+ except Exception:
159
+ pass
160
+ if embedded:
161
+ names = ', '.join(sorted(embedded)[:6])
162
+ notes.append(f'嵌入字体 {len(embedded)} 种:{names}')
163
+ if not any('Fang' in x or '仿宋' in x or 'Song' in x for x in embedded):
164
+ issues.append(f'PDF 未使用仿宋字体,实际:{sorted(embedded)}')
165
+ else:
166
+ issues.append('PDF 未检出嵌入字体')
167
+
168
+ # 尺寸应为 A4
169
+ try:
170
+ mb = r.pages[0].mediabox
171
+ w_cm, h_cm = float(mb.width) / 28.3465, float(mb.height) / 28.3465
172
+ if abs(w_cm - 21.0) > 0.3 or abs(h_cm - 29.7) > 0.3:
173
+ issues.append(f'页面尺寸 {w_cm:.1f}×{h_cm:.1f}cm,应为 A4 21.0×29.7cm')
174
+ else:
175
+ notes.append(f'页面 A4 {w_cm:.1f}×{h_cm:.1f}cm ✓')
176
+ except Exception:
177
+ pass
178
+
179
+ return issues, notes
180
+
181
+
182
+ def main():
183
+ ap = argparse.ArgumentParser(description='排版输出自检')
184
+ ap.add_argument('--dir', required=True, help='输出目录')
185
+ a = ap.parse_args()
186
+
187
+ d = a.dir
188
+ docx = glob.glob(os.path.join(d, '*.docx'))
189
+ pdf = glob.glob(os.path.join(d, '*.pdf'))
190
+ txt = glob.glob(os.path.join(d, '*.txt'))
191
+
192
+ all_issues = []
193
+ print('════ 排版输出自检 ════')
194
+ print(f' 目录:{d}')
195
+ print(f' docx {len(docx)} / pdf {len(pdf)} / txt {len(txt)}')
196
+ print()
197
+
198
+ for f in docx:
199
+ print(f' ── {os.path.basename(f)}')
200
+ iss, notes = check_docx(f)
201
+ for n in notes:
202
+ print(f' {PASS} {n}')
203
+ for i in iss:
204
+ print(f' {FAIL} {i}')
205
+ all_issues += iss
206
+ print()
207
+
208
+ for f in pdf:
209
+ print(f' ── {os.path.basename(f)}')
210
+ iss, notes = check_pdf(f)
211
+ for n in notes:
212
+ print(f' {PASS} {n}')
213
+ for i in iss:
214
+ print(f' {FAIL} {i}')
215
+ all_issues += iss
216
+ print()
217
+
218
+ if not docx and not pdf:
219
+ print(' ⚠️ 目录下没有 docx / pdf,无法自检')
220
+ return 1
221
+
222
+ print('════ 结果 ════')
223
+ if all_issues:
224
+ print(f' {FAIL} {len(all_issues)} 项不符合规范:')
225
+ for i in all_issues:
226
+ print(f' · {i}')
227
+ return 1
228
+ print(f' {PASS} 全部符合排版规范')
229
+ return 0
230
+
231
+
232
+ if __name__ == '__main__':
233
+ sys.exit(main())