@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,405 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 党政机关公文排版格式生成器 — PDF 文档
4
+ 严格遵循 GB/T 9704-2012 标准
5
+ """
6
+
7
+ import argparse
8
+ import glob
9
+ import os
10
+
11
+ from reportlab.pdfbase import pdfmetrics
12
+ from reportlab.pdfbase.ttfonts import TTFont
13
+ from reportlab.lib.pagesizes import A4
14
+ from reportlab.lib.units import cm
15
+ from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, KeepTogether
16
+ from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
17
+ from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY
18
+
19
+
20
+ # 字号(磅)
21
+ FONT_SIZES = {
22
+ '二号': 22,
23
+ '三号': 16,
24
+ '四号': 14,
25
+ }
26
+
27
+ # 行距(磅)
28
+ LINE_SPACING = {
29
+ 'title': 36,
30
+ 'body': 28,
31
+ }
32
+
33
+ # 页边距(cm)
34
+ MARGINS = {
35
+ 'top': 3.7,
36
+ 'bottom': 3.5,
37
+ 'left': 2.8,
38
+ 'right': 2.6,
39
+ }
40
+
41
+ # 三号字(16pt)字符宽度(磅),用于缩进换算
42
+ CHAR_PT = 16
43
+
44
+ # 各排版元素使用的字体(注册成功后按可用性调整)
45
+ FONTS = {
46
+ 'title': 'FangBold', # 标题:仿宋加粗(二号)
47
+ 'body': 'FangSC', # 正文:仿宋(四号)
48
+ 'h1': 'FangBold', # 一级标题:仿宋加粗(三号)
49
+ 'h2': 'FangBold', # 二级标题:仿宋加粗(三号)
50
+ 'h3': 'FangBold', # 三级标题:仿宋加粗(三号)
51
+ 'h4': 'FangBold', # 四级标题:仿宋加粗(三号)
52
+ 'mi_ji': 'FangSC', # 版头:仿宋(与全文一致)
53
+ 'page': 'FangSC', # 页码:仿宋
54
+ }
55
+
56
+
57
+ def _first_file(patterns):
58
+ for pat in patterns:
59
+ hits = sorted(glob.glob(pat, recursive=True))
60
+ if hits:
61
+ return hits[0]
62
+ return None
63
+
64
+
65
+ _HERE = os.path.dirname(os.path.abspath(__file__))
66
+
67
+
68
+ def _font_search_dirs():
69
+ """用户自备字体的查找目录(按优先级,跨平台,只返回真实存在的目录)。"""
70
+ home = os.path.expanduser('~')
71
+ local = os.environ.get('LOCALAPPDATA', '')
72
+ candidates = [
73
+ os.environ.get('DSH_CN_FONT_DIR', ''), # 1) 显式指定目录
74
+ os.path.join(_HERE, '..', 'fonts'), # 2) 用户放进插件自己的 fonts/
75
+ os.path.join(home, 'Library', 'Fonts'), # 3) macOS 用户字体
76
+ os.path.join(home, '.fonts'), # Linux 用户字体
77
+ os.path.join(home, '.local', 'share', 'fonts'), # Linux 用户字体
78
+ os.path.join(local, 'Microsoft', 'Windows', 'Fonts') if local else '',
79
+ 'C:/Windows/Fonts', # Windows 系统字体
80
+ '/usr/share/fonts', # Linux 系统字体
81
+ '/usr/local/share/fonts',
82
+ ]
83
+ return [d for d in candidates if d and os.path.isdir(d)]
84
+
85
+
86
+ # 仿宋在各平台的常见文件名(不同来源命名不一)
87
+ FANGSONG_NAMES = [
88
+ 'FangS-SC.ttf', # WPS 自带(方正仿宋)
89
+ 'simfang.ttf', 'SIMFANG.TTF', # Windows 仿宋
90
+ 'FangSong.ttf', 'FangSong_GB2312.ttf',
91
+ 'STFangsong.ttf', # macOS 华文仿宋
92
+ 'FandolFang-Regular.otf', # TeX Live 开源仿宋
93
+ '仿宋.ttf', '仿宋_GB2312.ttf',
94
+ ]
95
+ FANGSONG_BOLD_NAMES = [
96
+ 'FangS-SC-Bold.ttf',
97
+ 'simfangb.ttf', 'SIMFANGB.TTF',
98
+ 'FangSong-Bold.ttf',
99
+ 'FandolFang-Bold.otf',
100
+ ]
101
+
102
+
103
+ def _find_font(names):
104
+ """在用户字体目录里按文件名找字体,返回第一个命中的绝对路径。"""
105
+ dirs = _font_search_dirs()
106
+ for d in dirs:
107
+ for name in names:
108
+ hit = _first_file([os.path.join(d, name), os.path.join(d, '**', name)])
109
+ if hit:
110
+ return hit
111
+ return None
112
+
113
+
114
+ # WPS Office 自带方正仿宋(常规体),作为最后的外部兜底
115
+ _WPS_FANGSONG = [
116
+ '/Applications/wpsoffice.app/Contents/Resources/office6/fonts/FangS-SC.ttf',
117
+ '/Applications/WPS Office.app/Contents/Resources/office6/fonts/FangS-SC.ttf',
118
+ '/Applications/WPS*.app/Contents/Resources/office6/fonts/FangS-SC.ttf',
119
+ ]
120
+
121
+
122
+ def register_fonts():
123
+ """注册字体(TTC 用 subfontIndex 指定字面)。
124
+
125
+ ⚠ 仿宋字体**不随本插件分发**。方正仿宋的授权允许在文档中使用,但不允许再分发
126
+ 字体文件本身,因此改为查找**用户自备**的字体,顺序如下:
127
+
128
+ 1. 环境变量 DSH_CN_FANGSONG / DSH_CN_FANGSONG_BOLD 直接指定文件(最高优先)
129
+ 2. 环境变量 DSH_CN_FONT_DIR 指定的目录
130
+ 3. 插件自己 fonts/ 目录(用户自行放入)
131
+ 4. 系统字体目录(macOS / Windows / Linux)
132
+ 5. WPS Office 安装目录(自带方正仿宋)
133
+
134
+ 全部找不到时退回系统宋体,并在 stderr 给出可操作的获取指引。
135
+ """
136
+ songti = _first_file([
137
+ '/System/Library/Fonts/Supplemental/Songti.ttc',
138
+ '/System/Library/Fonts/STHeiti Light.ttc',
139
+ ])
140
+
141
+ # ① 显式指定优先
142
+ explicit = os.environ.get('DSH_CN_FANGSONG', '').strip()
143
+ fangsong = _first_file([explicit]) if explicit else None
144
+ # ②③④ 按目录与文件名查找
145
+ if not fangsong:
146
+ fangsong = _find_font(FANGSONG_NAMES)
147
+ # ⑤ WPS 兜底
148
+ if not fangsong:
149
+ fangsong = _first_file(_WPS_FANGSONG)
150
+
151
+ explicit_bold = os.environ.get('DSH_CN_FANGSONG_BOLD', '').strip()
152
+ bold_fang = _first_file([explicit_bold]) if explicit_bold else None
153
+ if not bold_fang:
154
+ bold_fang = _find_font(FANGSONG_BOLD_NAMES)
155
+
156
+ ok = {}
157
+
158
+ def reg(name, path, sub=None):
159
+ try:
160
+ pdfmetrics.registerFont(
161
+ TTFont(name, path, subfontIndex=sub) if sub is not None
162
+ else TTFont(name, path))
163
+ ok[name] = True
164
+ except Exception:
165
+ ok[name] = False
166
+
167
+ reg('SongSC', songti, 6) # 宋体-简 常规体(兜底)
168
+ reg('SongSCBold', songti, 1) # 宋体-简 粗体(兜底)
169
+ if fangsong:
170
+ reg('FangSC', fangsong) # 仿宋(用户自备,TrueType)
171
+ if bold_fang:
172
+ reg('FangBold', bold_fang) # 仿宋加粗(用户自备)
173
+
174
+ # 仿宋缺失时给出可操作的提示,避免静默退宋体导致排版不达标
175
+ if not ok.get('FangSC'):
176
+ import sys as _sys
177
+ print('[提示] 未找到仿宋字体,PDF 将退回宋体,与「全文仿宋」规范不符。\n'
178
+ ' 本插件不随包分发字体(方正仿宋授权不允许再分发字体文件),\n'
179
+ ' 请自备仿宋字体,任选一种方式:\n'
180
+ ' 1) 环境变量直接指定:export DSH_CN_FANGSONG=/path/to/仿宋.ttf\n'
181
+ ' 2) 指定查找目录: export DSH_CN_FONT_DIR=~/my-fonts\n'
182
+ ' 3) 放进插件自己的: <插件目录>/fonts/\n'
183
+ ' 4) 装到系统字体目录:macOS ~/Library/Fonts/ / '
184
+ 'Windows C:\\Windows\\Fonts\\ / Linux ~/.fonts/\n'
185
+ ' 5) 安装 WPS Office(自带方正仿宋)\n'
186
+ ' 可用文件名举例:FangS-SC.ttf、simfang.ttf、STFangsong.ttf、\n'
187
+ ' FandolFang-Regular.otf(TeX Live 开源仿宋)',
188
+ file=_sys.stderr)
189
+
190
+ # 字体缺失时回退
191
+ if not ok.get('FangSC'):
192
+ for key in ('body', 'mi_ji', 'page'):
193
+ FONTS[key] = 'SongSC'
194
+ if not ok.get('FangBold'):
195
+ if ok.get('FangSC'):
196
+ # 有仿宋但没有仿宋粗体(用户自备字体时的常态):
197
+ # 标题继续用仿宋,保住「全文统一仿宋」这条硬规范。
198
+ # reportlab 不做合成粗体,这里宁可不粗也不换字体族。
199
+ for key in ('title', 'h1', 'h2', 'h3', 'h4'):
200
+ FONTS[key] = 'FangSC'
201
+ import sys as _sys
202
+ print('[提示] 未提供仿宋加粗字体,标题将使用常规仿宋(不加粗)。\n'
203
+ ' 如需加粗,用 DSH_CN_FANGSONG_BOLD 指定加粗字体文件。',
204
+ file=_sys.stderr)
205
+ else:
206
+ # 仿宋整体缺失 → 标题退宋体粗体
207
+ for key in ('title', 'h1', 'h2', 'h3', 'h4'):
208
+ FONTS[key] = 'SongSCBold' if ok.get('SongSCBold') else 'SongSC'
209
+ if not ok.get('SongSC'):
210
+ raise RuntimeError('系统缺少宋体字体,无法生成 PDF')
211
+
212
+
213
+ def make_style(font_name, font_size, alignment=TA_LEFT, leading=None,
214
+ first_line_indent=0, left_indent=0, right_indent=0):
215
+ """创建段落样式(wordWrap='CJK' 保证中文逐字换行)"""
216
+ if leading is None:
217
+ leading = font_size * 1.75
218
+ return ParagraphStyle(
219
+ f'style_{font_name}_{font_size}_{alignment}_{first_line_indent}_{left_indent}_{right_indent}',
220
+ parent=getSampleStyleSheet()['Normal'],
221
+ fontName=font_name,
222
+ fontSize=font_size,
223
+ alignment=alignment,
224
+ firstLineIndent=first_line_indent,
225
+ leftIndent=left_indent,
226
+ rightIndent=right_indent,
227
+ leading=leading,
228
+ spaceBefore=0,
229
+ spaceAfter=0,
230
+ wordWrap='CJK',
231
+ )
232
+
233
+
234
+ def markup_text(text):
235
+ """转义 XML(全文统一仿宋,不再单独换西文字体)"""
236
+ return text.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;')
237
+
238
+
239
+ def text_width_chars(text):
240
+ """估算文本宽度(三号字为单位:汉字/全角=1,半角数字字母=0.5)"""
241
+ return sum(0.5 if ord(ch) < 128 else 1.0 for ch in text)
242
+
243
+
244
+ def is_level1_title(line):
245
+ """一级标题:一、二、三……"""
246
+ return bool(line and line[0] in '一二三四五六七八九十'
247
+ and len(line) > 1 and line[1] == '、')
248
+
249
+
250
+ def is_level2_title(line):
251
+ """二级标题:(一)(二)(三)……"""
252
+ return bool(line and line.startswith('(') and len(line) >= 4
253
+ and line[1] in '一二三四五六七八九十' and line[2] == ')')
254
+
255
+
256
+ def is_level3_title(line):
257
+ """三级标题:1. 2. 3. ……(半角或全角点)"""
258
+ return bool(line and line[:1].isdigit() and len(line) >= 2
259
+ and line[1] in '..')
260
+
261
+
262
+ def is_level4_title(line):
263
+ """四级标题:(1)(2)(3)……"""
264
+ return bool(line and line.startswith('(') and len(line) >= 4
265
+ and line[1].isdigit() and line[2] == ')')
266
+
267
+
268
+ def on_page(canvas, doc):
269
+ """每页页脚页码:- n -,宋体四号,单页码居右空一字,双页码居左空一字"""
270
+ canvas.saveState()
271
+ canvas.setFont(FONTS['page'], FONT_SIZES['四号'])
272
+ text = f'- {canvas.getPageNumber()} -'
273
+ y = doc.bottomMargin * 0.55
274
+ if canvas.getPageNumber() % 2 == 1:
275
+ x = doc.pagesize[0] - doc.rightMargin - CHAR_PT
276
+ canvas.drawRightString(x, y, text)
277
+ else:
278
+ x = doc.leftMargin + CHAR_PT
279
+ canvas.drawString(x, y, text)
280
+ canvas.restoreState()
281
+
282
+
283
+ def generate_pdf(args):
284
+ """生成 PDF 文档"""
285
+ register_fonts()
286
+
287
+ output_path = args.output
288
+ if not os.path.exists(output_path):
289
+ os.makedirs(output_path)
290
+ filepath = os.path.join(output_path, args.filename or '公文.pdf')
291
+
292
+ doc = SimpleDocTemplate(
293
+ filepath,
294
+ pagesize=A4,
295
+ topMargin=MARGINS['top'] * cm,
296
+ bottomMargin=MARGINS['bottom'] * cm,
297
+ leftMargin=MARGINS['left'] * cm,
298
+ rightMargin=MARGINS['right'] * cm,
299
+ )
300
+
301
+ story = []
302
+ body_size = FONT_SIZES['四号']
303
+ heading_size = FONT_SIZES['三号']
304
+ body_leading = LINE_SPACING['body']
305
+ body_indent = int(2 * body_size) # 正文首行缩进 2 字
306
+ heading_indent = int(2 * heading_size) # 小标题左空 2 字
307
+ right4 = int(4 * body_size) # 右空 4 字(按四号字宽)
308
+
309
+ # === 版头信息 ===
310
+ for text in (args.fen_hao, args.mi_ji, args.jin_ji_chengdu):
311
+ if text:
312
+ style = make_style(FONTS['mi_ji'], heading_size, TA_LEFT,
313
+ leading=body_leading)
314
+ story.append(Paragraph(markup_text(text), style))
315
+
316
+ if args.fa_wen_zi_hao:
317
+ if args.shang_xing_wen:
318
+ style = make_style(FONTS['body'], body_size, TA_LEFT,
319
+ leading=body_leading, left_indent=CHAR_PT)
320
+ else:
321
+ style = make_style(FONTS['body'], body_size, TA_CENTER,
322
+ leading=body_leading)
323
+ story.append(Paragraph(markup_text(args.fa_wen_zi_hao), style))
324
+
325
+ # === 标题 ===
326
+ if args.title:
327
+ title_style = make_style(FONTS['title'], FONT_SIZES['二号'], TA_CENTER,
328
+ leading=LINE_SPACING['title'])
329
+ story.append(Paragraph(markup_text(args.title), title_style))
330
+
331
+ # === 正文 ===
332
+ if args.body:
333
+ for line in args.body.strip().split('\n'):
334
+ line = line.strip()
335
+ if not line:
336
+ continue
337
+ if is_level1_title(line):
338
+ font, size = FONTS['h1'], heading_size
339
+ elif is_level2_title(line):
340
+ font, size = FONTS['h2'], heading_size
341
+ elif is_level3_title(line):
342
+ font, size = FONTS['h3'], heading_size
343
+ elif is_level4_title(line):
344
+ font, size = FONTS['h4'], heading_size
345
+ else:
346
+ font, size = FONTS['body'], body_size
347
+ line_indent = heading_indent if size == heading_size else body_indent
348
+ style = make_style(font, size, TA_JUSTIFY,
349
+ first_line_indent=line_indent,
350
+ leading=body_leading)
351
+ story.append(Paragraph(markup_text(line), style))
352
+
353
+ # === 附件说明 ===
354
+ if args.attachment:
355
+ story.append(Spacer(1, body_leading))
356
+ style = make_style(FONTS['body'], body_size, TA_JUSTIFY,
357
+ first_line_indent=body_indent, leading=body_leading)
358
+ story.append(Paragraph(markup_text(args.attachment), style))
359
+
360
+ # === 发文机关署名和日期 ===
361
+ if args.sender or args.date:
362
+ sign_block = [Spacer(1, body_leading * 3)]
363
+ if args.sender and args.date:
364
+ sender_indent = max(0, round((4 + (text_width_chars(args.date)
365
+ - text_width_chars(args.sender)) / 2)
366
+ * body_size))
367
+ else:
368
+ sender_indent = right4
369
+ if args.sender:
370
+ style = make_style(FONTS['body'], body_size, TA_RIGHT,
371
+ leading=body_leading, right_indent=sender_indent)
372
+ sign_block.append(Paragraph(markup_text(args.sender), style))
373
+ if args.date:
374
+ style = make_style(FONTS['body'], body_size, TA_RIGHT,
375
+ leading=body_leading, right_indent=right4)
376
+ sign_block.append(Paragraph(markup_text(args.date), style))
377
+ story.append(KeepTogether(sign_block))
378
+
379
+ doc.build(story, onFirstPage=on_page, onLaterPages=on_page)
380
+ print(f'PDF 文档已生成: {filepath}')
381
+ return filepath
382
+
383
+
384
+ def main():
385
+ parser = argparse.ArgumentParser(description='党政机关公文排版格式生成器 (.pdf)')
386
+ parser.add_argument('--output', required=True, help='输出目录')
387
+ parser.add_argument('--filename', default='公文.pdf', help='文件名')
388
+ parser.add_argument('--title', default='', help='标题')
389
+ parser.add_argument('--body', default='', help='正文内容(多行用换行分隔)')
390
+ parser.add_argument('--sender', default='', help='发文机关署名')
391
+ parser.add_argument('--date', default='', help='成文日期')
392
+ parser.add_argument('--attachment', default='', help='附件说明')
393
+ parser.add_argument('--fen_hao', default='', help='份号')
394
+ parser.add_argument('--mi_ji', default='', help='密级')
395
+ parser.add_argument('--jin_ji_chengdu', default='', help='紧急程度')
396
+ parser.add_argument('--fa_wen_zi_hao', default='', help='发文字号')
397
+ parser.add_argument('--shang_xing_wen', action='store_true',
398
+ help='上行文(发文字号左空一字)')
399
+
400
+ args = parser.parse_args()
401
+ generate_pdf(args)
402
+
403
+
404
+ if __name__ == '__main__':
405
+ main()
@@ -0,0 +1,112 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 党政机关公文排版格式生成器 — 纯文本文件 (.txt)
4
+ 保留标题层级结构,无格式标记
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ import sys
10
+
11
+
12
+ def generate_txt(args):
13
+ """生成纯文本文件"""
14
+ output_path = args.output
15
+ if not os.path.exists(output_path):
16
+ os.makedirs(output_path)
17
+
18
+ filename = args.filename or '公文.txt'
19
+ filepath = os.path.join(output_path, filename)
20
+
21
+ lines = []
22
+
23
+ # 版头信息
24
+ if args.fen_hao:
25
+ lines.append(args.fen_hao)
26
+ if args.mi_ji:
27
+ lines.append(args.mi_ji)
28
+ if args.jin_ji_chengdu:
29
+ lines.append(args.jin_ji_chengdu)
30
+ if args.fa_wen_zi_hao:
31
+ lines.append(args.fa_wen_zi_hao)
32
+ # 标题
33
+ if args.title:
34
+ lines.append('')
35
+ lines.append(args.title)
36
+ lines.append('')
37
+
38
+ # 正文
39
+ if args.body:
40
+ body_lines = args.body.strip().split('\n')
41
+ for line in body_lines:
42
+ line = line.strip()
43
+ if not line:
44
+ continue
45
+ # 检测标题级别(与 docx/pdf 脚本保持一致的严格规则)
46
+ if is_heading(line):
47
+ lines.append(line)
48
+ lines.append('')
49
+ else:
50
+ lines.append(line)
51
+ lines.append('')
52
+
53
+ # 附件说明
54
+ if args.attachment:
55
+ lines.append('')
56
+ lines.append(args.attachment)
57
+ lines.append('')
58
+
59
+ # 发文机关署名和日期
60
+ if args.sender or args.date:
61
+ lines.append('')
62
+ lines.append('')
63
+ if args.sender:
64
+ lines.append(args.sender)
65
+ if args.date:
66
+ lines.append(args.date)
67
+
68
+ # 写入文件
69
+ with open(filepath, 'w', encoding='utf-8') as f:
70
+ f.write('\n'.join(lines))
71
+
72
+ print(f'纯文本文件已生成: {filepath}')
73
+ return filepath
74
+
75
+
76
+ def is_heading(line):
77
+ """严格检测各类标题:一、/(一)/1. /(1)"""
78
+ if line and line[0] in '一二三四五六七八九十' and len(line) > 1 and line[1] == '、':
79
+ return True
80
+ if (line and line.startswith('(') and len(line) >= 4
81
+ and line[1] in '一二三四五六七八九十' and line[2] == ')'):
82
+ return True
83
+ if line and line[:1].isdigit() and len(line) >= 2 and line[1] in '..':
84
+ return True
85
+ if (line and line.startswith('(') and len(line) >= 4
86
+ and line[1].isdigit() and line[2] == ')'):
87
+ return True
88
+ return False
89
+
90
+
91
+ def main():
92
+ parser = argparse.ArgumentParser(description='党政机关公文排版格式生成器 (.txt)')
93
+ parser.add_argument('--output', required=True, help='输出目录')
94
+ parser.add_argument('--filename', default='公文.txt', help='文件名')
95
+ parser.add_argument('--title', default='', help='标题')
96
+ parser.add_argument('--body', default='', help='正文内容(多行用换行分隔)')
97
+ parser.add_argument('--sender', default='', help='发文机关署名')
98
+ parser.add_argument('--date', default='', help='成文日期')
99
+ parser.add_argument('--attachment', default='', help='附件说明')
100
+ parser.add_argument('--fen_hao', default='', help='份号')
101
+ parser.add_argument('--mi_ji', default='', help='密级')
102
+ parser.add_argument('--jin_ji_chengdu', default='', help='紧急程度')
103
+ parser.add_argument('--fa_wen_zi_hao', default='', help='发文字号')
104
+ parser.add_argument('--shang_xing_wen', action='store_true',
105
+ help='上行文(发文字号左空一字)')
106
+
107
+ args = parser.parse_args()
108
+ generate_txt(args)
109
+
110
+
111
+ if __name__ == '__main__':
112
+ main()