@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,500 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ 党政机关公文排版格式生成器 — Word 文档 (.docx)
4
+ 严格遵循 GB/T 9704-2012 标准
5
+ """
6
+
7
+ import argparse
8
+ import os
9
+ import subprocess
10
+
11
+ from docx import Document
12
+ from docx.shared import Pt, Cm
13
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
14
+ from docx.oxml.ns import qn
15
+ from docx.oxml import OxmlElement
16
+
17
+
18
+ # 字号对照(磅 → pt)
19
+ FONT_SIZES = {
20
+ '二号': Pt(22),
21
+ '三号': Pt(16),
22
+ '四号': Pt(14),
23
+ }
24
+
25
+ # 全文统一仿宋。按优先级探测,取第一个本机真实存在的字体族名。
26
+ #
27
+ # ⚠ 2026-09-22 修正(原实现有两个缺陷,导致 docx 声明的字体在任何系统都找不到):
28
+ # 1. 原 _font_installed() 用 `fc-list` 探测 —— macOS 没有这个命令,
29
+ # 异常被吞掉后恒返回 False,于是永远走回退分支。已改为直接读字体文件的
30
+ # name 表(fontTools),macOS / Windows / Linux 通用。
31
+ # 2. 原回退名写作 'FandolFang',而该字体真实族名是 'FandolFang R'(带空格与 R)。
32
+ # 写错的名字在任何系统都匹配不到,Word 打开会静默替换字体。
33
+ # 现在改为逐个候选名实测,避免再写错。
34
+ FONT_CANDIDATES = [
35
+ '仿宋_GB2312', # Windows 标准公文仿宋
36
+ 'FangS-SC', # 方正仿宋(WPS 自带;本插件不随包分发,需用户自备)
37
+ '仿宋', # Windows 自带仿宋的另一族名
38
+ 'FandolFang R', # macOS 常见仿宋替代(注意真实族名带空格与 R)
39
+ 'STFangsong', # macOS 华文仿宋
40
+ 'Songti SC', # 最后回退:宋体
41
+ ]
42
+
43
+ # 本插件**不随包分发字体**(方正仿宋授权不允许再分发字体文件),因此这里既扫系统
44
+ # 字体目录,也认用户显式指定的目录 —— 与 pdf_formatter.py 的查找顺序保持一致。
45
+ _FONT_SEARCH_DIRS = [
46
+ os.environ.get('DSH_CN_FONT_DIR', ''), # 显式指定目录
47
+ os.path.join(os.path.dirname(os.path.abspath(__file__)), '..', 'fonts'), # 用户放进插件
48
+ os.path.expanduser('~/Library/Fonts'), # macOS 用户字体
49
+ os.path.expanduser('~/.fonts'), # Linux 用户字体
50
+ os.path.expanduser('~/.local/share/fonts'),
51
+ os.path.join(os.environ.get('LOCALAPPDATA', '') or '', 'Microsoft', 'Windows', 'Fonts'),
52
+ 'C:\\Windows\\Fonts',
53
+ '/System/Library/Fonts',
54
+ '/System/Library/Fonts/Supplemental',
55
+ '/Library/Fonts',
56
+ '/usr/share/fonts',
57
+ '/usr/local/share/fonts',
58
+ ]
59
+
60
+
61
+ def _iter_font_files():
62
+ exts = ('.ttf', '.otf', '.ttc', '.TTF', '.OTF', '.TTC')
63
+ for d in _FONT_SEARCH_DIRS:
64
+ if not os.path.isdir(d):
65
+ continue
66
+ for root, _dirs, files in os.walk(d):
67
+ for fn in files:
68
+ if fn.endswith(exts):
69
+ yield os.path.join(root, fn)
70
+
71
+
72
+ def _installed_families():
73
+ """扫描本机字体文件,返回所有真实存在的字体族名。
74
+
75
+ 直接读文件而不依赖 fc-list,因此在 macOS 上也能正确工作。
76
+ 结果带缓存,避免重复解析。
77
+ """
78
+ global _FAMILY_CACHE
79
+ if _FAMILY_CACHE is not None:
80
+ return _FAMILY_CACHE
81
+ fams = set()
82
+ try:
83
+ from fontTools.ttLib import TTFont, TTCollection
84
+ except ImportError:
85
+ _FAMILY_CACHE = fams
86
+ return fams
87
+ for path in _iter_font_files():
88
+ try:
89
+ if path.lower().endswith('.ttc'):
90
+ fonts = TTCollection(path, lazy=True).fonts
91
+ else:
92
+ fonts = [TTFont(path, lazy=True)]
93
+ for t in fonts:
94
+ for r in t['name'].names:
95
+ if r.nameID in (1, 16): # 族名 / 排版族名
96
+ try:
97
+ fams.add(r.toUnicode().strip())
98
+ except Exception:
99
+ pass
100
+ except Exception:
101
+ continue
102
+ _FAMILY_CACHE = fams
103
+ return fams
104
+
105
+
106
+ _FAMILY_CACHE = None
107
+
108
+ FOUND_FONTS = _installed_families()
109
+ CN_FONT = next((f for f in FONT_CANDIDATES if f in FOUND_FONTS), FONT_CANDIDATES[1])
110
+
111
+ # 首选字体缺失时给出可操作的提示(不静默降级为宋体,否则排版规范不达标)
112
+ if CN_FONT != FONT_CANDIDATES[0]:
113
+ _missing = FONT_CANDIDATES[0]
114
+ if _missing not in FOUND_FONTS:
115
+ import sys as _sys
116
+ print(f'[提示] 未找到标准公文仿宋「{_missing}」,已改用「{CN_FONT}」。'
117
+ f'如需完全符合 GB2312 规范,请安装仿宋_GB2312 字体。', file=_sys.stderr)
118
+
119
+
120
+ # 字体映射:(西文字体, 中文字体) — 全文统一仿宋
121
+ FONT_MAP = {
122
+ 'title': (CN_FONT, CN_FONT), # 标题:仿宋加粗
123
+ 'body': (CN_FONT, CN_FONT), # 正文:仿宋
124
+ 'h1': (CN_FONT, CN_FONT), # 一级标题:仿宋加粗
125
+ 'h2': (CN_FONT, CN_FONT), # 二级标题:仿宋加粗
126
+ 'h3': (CN_FONT, CN_FONT), # 三级标题:仿宋加粗
127
+ 'h4': (CN_FONT, CN_FONT), # 四级标题:仿宋加粗
128
+ 'mi_ji': (CN_FONT, CN_FONT), # 版头:仿宋
129
+ 'attachment': (CN_FONT, CN_FONT), # 附件说明:仿宋
130
+ 'issuer': (CN_FONT, CN_FONT), # 发文机关署名:仿宋
131
+ 'page_num': (CN_FONT, CN_FONT), # 页码:仿宋
132
+ }
133
+
134
+ # 行距(磅)— 固定值,正文 28 磅,标题 36 磅
135
+ LINE_SPACING = {
136
+ 'title': 36,
137
+ 'body': 28,
138
+ }
139
+
140
+ # 三号字(16pt)的字符宽度(磅),用于缩进换算
141
+ CHAR_PT = 16
142
+
143
+
144
+ def set_font(run, font_key, font_size, bold=False):
145
+ """设置 run 字体:西文用 Times New Roman,中文用映射字体"""
146
+ ascii_font, east_asia_font = FONT_MAP[font_key]
147
+ rPr = run._element.get_or_add_rPr()
148
+ for rFonts in rPr.findall(qn('w:rFonts')):
149
+ rPr.remove(rFonts)
150
+ rFonts = OxmlElement('w:rFonts')
151
+ rFonts.set(qn('w:ascii'), ascii_font)
152
+ rFonts.set(qn('w:hAnsi'), ascii_font)
153
+ rFonts.set(qn('w:eastAsia'), east_asia_font)
154
+ rFonts.set(qn('w:cs'), east_asia_font)
155
+ rPr.insert(0, rFonts)
156
+ run.font.size = font_size
157
+ run.font.bold = bold
158
+
159
+
160
+ def set_line_spacing_exact(para, pt):
161
+ """设置固定值行距(28/36 磅),并清除段前段后距"""
162
+ pPr = para._element.get_or_add_pPr()
163
+ spacing = pPr.find(qn('w:spacing'))
164
+ if spacing is None:
165
+ spacing = OxmlElement('w:spacing')
166
+ pPr.append(spacing)
167
+ spacing.set(qn('w:line'), str(int(pt * 20))) # 磅 → twip
168
+ spacing.set(qn('w:lineRule'), 'exact')
169
+ spacing.set(qn('w:before'), '0')
170
+ spacing.set(qn('w:after'), '0')
171
+
172
+
173
+ def _clear_ind(pPr):
174
+ for ind in pPr.findall(qn('w:ind')):
175
+ pPr.remove(ind)
176
+
177
+
178
+ def set_indent(para, first_line_chars=0, left_chars=0, right_chars=0,
179
+ char_pt=CHAR_PT):
180
+ """按“字符数”设置缩进(Word 优先按 firstLineChars/leftChars/rightChars 计算)"""
181
+ pPr = para._element.get_or_add_pPr()
182
+ _clear_ind(pPr)
183
+ ind = OxmlElement('w:ind')
184
+ if first_line_chars:
185
+ ind.set(qn('w:firstLine'), str(int(first_line_chars * char_pt * 20)))
186
+ ind.set(qn('w:firstLineChars'), str(int(first_line_chars * 100)))
187
+ if left_chars:
188
+ ind.set(qn('w:left'), str(int(left_chars * char_pt * 20)))
189
+ ind.set(qn('w:leftChars'), str(int(left_chars * 100)))
190
+ if right_chars:
191
+ ind.set(qn('w:right'), str(int(right_chars * char_pt * 20)))
192
+ ind.set(qn('w:rightChars'), str(int(right_chars * 100)))
193
+ pPr.append(ind)
194
+
195
+
196
+ def add_styled_paragraph(doc, text, font_key, font_size,
197
+ line_spacing_pt=28, bold=False,
198
+ align=WD_ALIGN_PARAGRAPH.JUSTIFY,
199
+ first_line_chars=None, left_chars=None,
200
+ right_chars=None):
201
+ """添加带格式的段落"""
202
+ para = doc.add_paragraph()
203
+ para.alignment = align
204
+ set_line_spacing_exact(para, line_spacing_pt)
205
+ if first_line_chars or left_chars or right_chars:
206
+ set_indent(para, first_line_chars=first_line_chars or 0,
207
+ left_chars=left_chars or 0,
208
+ right_chars=right_chars or 0,
209
+ char_pt=font_size.pt)
210
+ run = para.add_run(text)
211
+ set_font(run, font_key, font_size, bold)
212
+ return para
213
+
214
+
215
+ def add_title(doc, text, font_size=FONT_SIZES['二号']):
216
+ """标题:居中,二号加粗,小标宋"""
217
+ return add_styled_paragraph(
218
+ doc, text, 'title', font_size,
219
+ line_spacing_pt=LINE_SPACING['title'],
220
+ align=WD_ALIGN_PARAGRAPH.CENTER,
221
+ bold=True,
222
+ )
223
+
224
+
225
+ def add_body_text(doc, text):
226
+ """正文段落:首行缩进 2 字,四号仿宋"""
227
+ return add_styled_paragraph(
228
+ doc, text, 'body', FONT_SIZES['四号'],
229
+ line_spacing_pt=LINE_SPACING['body'],
230
+ first_line_chars=2,
231
+ )
232
+
233
+
234
+ def add_level1_title(doc, text):
235
+ """一级标题:黑体,三号加粗,左空二字"""
236
+ return add_styled_paragraph(
237
+ doc, text, 'h1', FONT_SIZES['三号'],
238
+ line_spacing_pt=LINE_SPACING['body'],
239
+ first_line_chars=2,
240
+ bold=True,
241
+ )
242
+
243
+
244
+ def add_level2_title(doc, text):
245
+ """二级标题:楷体,三号加粗,左空二字"""
246
+ return add_styled_paragraph(
247
+ doc, text, 'h2', FONT_SIZES['三号'],
248
+ line_spacing_pt=LINE_SPACING['body'],
249
+ first_line_chars=2,
250
+ bold=True,
251
+ )
252
+
253
+
254
+ def add_level3_title(doc, text):
255
+ """三级标题:仿宋,三号加粗,左空二字"""
256
+ return add_styled_paragraph(
257
+ doc, text, 'h3', FONT_SIZES['三号'],
258
+ line_spacing_pt=LINE_SPACING['body'],
259
+ first_line_chars=2,
260
+ bold=True,
261
+ )
262
+
263
+
264
+ def add_level4_title(doc, text):
265
+ """四级标题:仿宋,三号加粗,左空二字"""
266
+ return add_styled_paragraph(
267
+ doc, text, 'h4', FONT_SIZES['三号'],
268
+ line_spacing_pt=LINE_SPACING['body'],
269
+ first_line_chars=2,
270
+ bold=True,
271
+ )
272
+
273
+
274
+ def add_attachment(doc, text):
275
+ """附件说明:仿宋,四号,首行缩进 2 字"""
276
+ return add_styled_paragraph(
277
+ doc, text, 'attachment', FONT_SIZES['四号'],
278
+ line_spacing_pt=LINE_SPACING['body'],
279
+ first_line_chars=2,
280
+ )
281
+
282
+
283
+ def add_signature(doc, issuer, date_text):
284
+ """发文机关署名和成文日期:距正文下空 3 行;日期右空 4 字,署名以日期为准居中"""
285
+ for _ in range(3):
286
+ p = doc.add_paragraph()
287
+ set_line_spacing_exact(p, LINE_SPACING['body'])
288
+ if issuer and date_text:
289
+ sender_chars = max(0, round(4 + (text_width_chars(date_text)
290
+ - text_width_chars(issuer)) / 2))
291
+ else:
292
+ sender_chars = 4
293
+ if issuer:
294
+ add_styled_paragraph(doc, issuer, 'issuer', FONT_SIZES['四号'],
295
+ align=WD_ALIGN_PARAGRAPH.RIGHT,
296
+ right_chars=sender_chars)
297
+ if date_text:
298
+ add_styled_paragraph(doc, date_text, 'issuer', FONT_SIZES['四号'],
299
+ align=WD_ALIGN_PARAGRAPH.RIGHT, right_chars=4)
300
+
301
+
302
+ def text_width_chars(text):
303
+ """估算文本宽度(三号字为单位:汉字/全角=1,半角数字字母=0.5)"""
304
+ return sum(0.5 if ord(ch) < 128 else 1.0 for ch in text)
305
+
306
+
307
+ def set_page_setup(doc):
308
+ """页面参数 + 文档网格(每面 22 行,28 磅行距)"""
309
+ section = doc.sections[0]
310
+ section.page_width = Cm(21)
311
+ section.page_height = Cm(29.7)
312
+ section.top_margin = Cm(3.7)
313
+ section.bottom_margin = Cm(3.5)
314
+ section.left_margin = Cm(2.8)
315
+ section.right_margin = Cm(2.6)
316
+ section.footer_distance = Cm(2.8)
317
+
318
+ sectPr = section._sectPr
319
+ # 移除模板自带的默认 docGrid,避免重复
320
+ for grid in sectPr.findall(qn('w:docGrid')):
321
+ sectPr.remove(grid)
322
+ docGrid = OxmlElement('w:docGrid')
323
+ docGrid.set(qn('w:type'), 'lines')
324
+ docGrid.set(qn('w:linePitch'), '560') # 28 磅 = 560 twip
325
+ sectPr.append(docGrid)
326
+
327
+
328
+ def add_header_info(doc, fen_hao='', mi_ji='', jin_ji_chengdu='',
329
+ fa_wen_zi_hao='', shang_xing_wen=False):
330
+ """可选版头信息(红头文件用):份号/密级/紧急程度黑体;发文字号仿宋"""
331
+ if not any([fen_hao, mi_ji, jin_ji_chengdu, fa_wen_zi_hao]):
332
+ return
333
+ for text in (fen_hao, mi_ji, jin_ji_chengdu):
334
+ if text:
335
+ add_styled_paragraph(doc, text, 'mi_ji', FONT_SIZES['三号'],
336
+ line_spacing_pt=LINE_SPACING['body'],
337
+ align=WD_ALIGN_PARAGRAPH.LEFT)
338
+ if fa_wen_zi_hao:
339
+ if shang_xing_wen:
340
+ add_styled_paragraph(doc, fa_wen_zi_hao, 'issuer',
341
+ FONT_SIZES['三号'],
342
+ line_spacing_pt=LINE_SPACING['body'],
343
+ align=WD_ALIGN_PARAGRAPH.LEFT,
344
+ left_chars=1)
345
+ else:
346
+ add_styled_paragraph(doc, fa_wen_zi_hao, 'issuer',
347
+ FONT_SIZES['三号'],
348
+ line_spacing_pt=LINE_SPACING['body'],
349
+ align=WD_ALIGN_PARAGRAPH.CENTER)
350
+
351
+
352
+ def _add_page_field_run(para, font_key, font_size, result_text='1'):
353
+ """在段落中插入 PAGE 域,格式:- {PAGE} -"""
354
+ run = para.add_run()
355
+ set_font(run, font_key, font_size)
356
+ r = run._element
357
+
358
+ def el(tag, **attrs):
359
+ e = OxmlElement(tag)
360
+ for k, v in attrs.items():
361
+ e.set(qn(k), v)
362
+ return e
363
+
364
+ r.append(el('w:fldChar', **{'w:fldCharType': 'begin'}))
365
+ instr = el('w:instrText', **{'xml:space': 'preserve'})
366
+ instr.text = 'PAGE'
367
+ r.append(instr)
368
+ r.append(el('w:fldChar', **{'w:fldCharType': 'separate'}))
369
+ t = OxmlElement('w:t')
370
+ t.text = result_text
371
+ r.append(t)
372
+ r.append(el('w:fldChar', **{'w:fldCharType': 'end'}))
373
+ return run
374
+
375
+
376
+ def add_page_number(doc):
377
+ """页脚页码:- 1 -,宋体四号;单页码居右空一字,双页码居左空一字"""
378
+ section = doc.sections[0]
379
+ doc.settings.odd_and_even_pages_header_footer = True
380
+
381
+ odd_footer = section.footer
382
+ odd_footer.is_linked_to_previous = False
383
+ para = odd_footer.paragraphs[0]
384
+ para.alignment = WD_ALIGN_PARAGRAPH.RIGHT
385
+ set_line_spacing_exact(para, LINE_SPACING['body'])
386
+ set_indent(para, right_chars=1)
387
+ _build_page_number_paragraph(para)
388
+
389
+ even_footer = section.even_page_footer
390
+ even_footer.is_linked_to_previous = False
391
+ epara = even_footer.paragraphs[0]
392
+ epara.alignment = WD_ALIGN_PARAGRAPH.LEFT
393
+ set_line_spacing_exact(epara, LINE_SPACING['body'])
394
+ set_indent(epara, left_chars=1)
395
+ _build_page_number_paragraph(epara)
396
+
397
+
398
+ def _build_page_number_paragraph(para):
399
+ r1 = para.add_run('- ')
400
+ set_font(r1, 'page_num', FONT_SIZES['四号'])
401
+ _add_page_field_run(para, 'page_num', FONT_SIZES['四号'])
402
+ r3 = para.add_run(' -')
403
+ set_font(r3, 'page_num', FONT_SIZES['四号'])
404
+
405
+
406
+ def is_level1_title(line):
407
+ """一级标题:一、二、三……"""
408
+ return bool(line and line[0] in '一二三四五六七八九十'
409
+ and len(line) > 1 and line[1] == '、')
410
+
411
+
412
+ def is_level2_title(line):
413
+ """二级标题:(一)(二)(三)……"""
414
+ return bool(line and line.startswith('(') and len(line) >= 4
415
+ and line[1] in '一二三四五六七八九十' and line[2] == ')')
416
+
417
+
418
+ def is_level3_title(line):
419
+ """三级标题:1. 2. 3. ……(半角或全角点)"""
420
+ return bool(line and line[:1].isdigit() and len(line) >= 2
421
+ and line[1] in '..')
422
+
423
+
424
+ def is_level4_title(line):
425
+ """四级标题:(1)(2)(3)……"""
426
+ return bool(line and line.startswith('(') and len(line) >= 4
427
+ and line[1].isdigit() and line[2] == ')')
428
+
429
+
430
+ def generate_docx(args):
431
+ """生成 Word 文档"""
432
+ doc = Document()
433
+ set_page_setup(doc)
434
+
435
+ add_header_info(doc,
436
+ fen_hao=args.fen_hao,
437
+ mi_ji=args.mi_ji,
438
+ jin_ji_chengdu=args.jin_ji_chengdu,
439
+ fa_wen_zi_hao=args.fa_wen_zi_hao,
440
+ shang_xing_wen=args.shang_xing_wen)
441
+
442
+ if args.title:
443
+ add_title(doc, args.title)
444
+
445
+ if args.body:
446
+ for line in args.body.strip().split('\n'):
447
+ line = line.strip()
448
+ if not line:
449
+ continue
450
+ if is_level1_title(line):
451
+ add_level1_title(doc, line)
452
+ elif is_level2_title(line):
453
+ add_level2_title(doc, line)
454
+ elif is_level3_title(line):
455
+ add_level3_title(doc, line)
456
+ elif is_level4_title(line):
457
+ add_level4_title(doc, line)
458
+ else:
459
+ add_body_text(doc, line)
460
+
461
+ if args.attachment:
462
+ doc.add_paragraph()
463
+ add_attachment(doc, args.attachment)
464
+
465
+ if args.sender or args.date:
466
+ add_signature(doc, args.sender or '', args.date or '')
467
+
468
+ add_page_number(doc)
469
+
470
+ output_path = args.output
471
+ if not os.path.exists(output_path):
472
+ os.makedirs(output_path)
473
+ filepath = os.path.join(output_path, args.filename or '公文.docx')
474
+ doc.save(filepath)
475
+ print(f'Word 文档已生成: {filepath}')
476
+ return filepath
477
+
478
+
479
+ def main():
480
+ parser = argparse.ArgumentParser(description='党政机关公文排版格式生成器 (.docx)')
481
+ parser.add_argument('--output', required=True, help='输出目录')
482
+ parser.add_argument('--filename', default='公文.docx', help='文件名')
483
+ parser.add_argument('--title', default='', help='标题')
484
+ parser.add_argument('--body', default='', help='正文内容(多行用换行分隔)')
485
+ parser.add_argument('--sender', default='', help='发文机关署名')
486
+ parser.add_argument('--date', default='', help='成文日期')
487
+ parser.add_argument('--attachment', default='', help='附件说明')
488
+ parser.add_argument('--fen_hao', default='', help='份号')
489
+ parser.add_argument('--mi_ji', default='', help='密级')
490
+ parser.add_argument('--jin_ji_chengdu', default='', help='紧急程度')
491
+ parser.add_argument('--fa_wen_zi_hao', default='', help='发文字号')
492
+ parser.add_argument('--shang_xing_wen', action='store_true',
493
+ help='上行文(发文字号左空一字)')
494
+
495
+ args = parser.parse_args()
496
+ generate_docx(args)
497
+
498
+
499
+ if __name__ == '__main__':
500
+ main()
@@ -0,0 +1,199 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ md_adapter.py — Markdown → 党政机关公文正文格式 转换器
5
+
6
+ 背景
7
+ ----
8
+ 本 skill 原有的 docx/pdf 脚本只接受「已按公文层级排好」的纯文本
9
+ (一、/(一)/ 1. /(1))。实际使用中内容常以 Markdown 写就,
10
+ 直接喂进去会整篇被当作正文,层级丢失。
11
+
12
+ 本模块把 Markdown 转成脚本可识别的公文正文格式:
13
+ # 标题 → 由调用方作为 --title 传入(此处丢弃)
14
+ ## 一、小节 → 一、小节 (一级标题)
15
+ ### 1.1 小节 → (一)小节 (二级标题)
16
+ #### 更深 → 1. 小节 (三级标题)
17
+ - 列表项 → 正文(保留缩进)
18
+ | 表格 | → 逐行转成「表头:值」的正文句子
19
+ > 引用块 → 正文(连续行自动合并,避免断句处出现孤立标点)
20
+ 其余 → 正文
21
+
22
+ 用法
23
+ ----
24
+ from md_adapter import md_to_body
25
+ body = md_to_body(open('x.md', encoding='utf-8').read())
26
+ """
27
+ import re
28
+
29
+ # 一级标题中文序号
30
+ CN = ['〇', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十',
31
+ '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
32
+ '二十一', '二十二', '二十三', '二十四', '二十五', '二十六', '二十七', '二十八', '二十九', '三十']
33
+ # 二级标题中文序号(括号内)
34
+ SUBCN = ['', '一', '二', '三', '四', '五', '六', '七', '八', '九', '十',
35
+ '十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
36
+ '二十一', '二十二', '二十三', '二十四', '二十五', '二十六', '二十七', '二十八', '二十九', '三十']
37
+
38
+
39
+ def clean_inline(t: str) -> str:
40
+ """去掉行内 Markdown 标记,保留纯文本"""
41
+ t = re.sub(r'\*\*([^*]+)\*\*', r'\1', t)
42
+ t = re.sub(r'__([^_]+)__', r'\1', t)
43
+ t = re.sub(r'\*([^*\n]+)\*', r'\1', t)
44
+ t = re.sub(r'`([^`]+)`', r'\1', t)
45
+ t = re.sub(r'\[([^\]]+)\]\([^)]+\)', r'\1', t)
46
+ return t.strip()
47
+
48
+
49
+ def join_soft_breaks(md: str) -> str:
50
+ """合并软换行:同一段的连续普通行拼成一行。
51
+
52
+ 源文档常在同一段内手动折行;若逐行当独立段落,
53
+ 会在断句处留下孤立标点(例如单独一行的「、」)。
54
+ 引用块(> 开头)同样并入上一段。
55
+ """
56
+ out, buf = [], ''
57
+ special = re.compile(r'^(#{1,6}\s|\s*[-*+]\s|\s*\d+[.、]\s|\s*\||\s*---|\s*```)')
58
+
59
+ def flush():
60
+ nonlocal buf
61
+ if buf:
62
+ out.append(buf)
63
+ buf = ''
64
+
65
+ for raw in md.split('\n'):
66
+ line = raw.rstrip()
67
+ if not line.strip():
68
+ flush()
69
+ out.append('')
70
+ continue
71
+ if line.lstrip().startswith('>'):
72
+ txt = line.lstrip()[1:].strip()
73
+ if not txt:
74
+ flush()
75
+ continue
76
+ buf = (buf + txt) if buf else txt
77
+ continue
78
+ if special.match(line):
79
+ flush()
80
+ out.append(line)
81
+ continue
82
+ buf = (buf + line.strip()) if buf else line.strip()
83
+ flush()
84
+ return '\n'.join(out)
85
+
86
+
87
+ def md_to_body(md: str, drop_meta: bool = False) -> str:
88
+ """Markdown → 公文正文。
89
+
90
+ drop_meta=True 时丢弃开头的元信息行(用途/编制日期等),
91
+ 用于「文档内不出现自指性说明」的场景。
92
+ """
93
+ md = join_soft_breaks(md)
94
+ lines = md.split('\n')
95
+ out = []
96
+ h1 = h2 = 0
97
+ table_rows = []
98
+ head_seen = False
99
+
100
+ def flush_table():
101
+ nonlocal table_rows
102
+ if not table_rows:
103
+ return
104
+ rows = [r for r in table_rows
105
+ if not re.match(r'^\s*\|[\s:|-]+\|\s*$', r)]
106
+ table_rows = []
107
+ if not rows:
108
+ return
109
+ cells = [[c.strip() for c in r.strip().strip('|').split('|')] for r in rows]
110
+ head = cells[0]
111
+ generic = {'#', '序号', '编号'}
112
+ for row in cells[1:]:
113
+ if not any(row):
114
+ continue
115
+ # 两列表:左列即键(如「项 / 内容」表里的 运营主体、官网 …)
116
+ if len(row) == 2:
117
+ k, v = clean_inline(row[0]), clean_inline(row[1])
118
+ if k and v:
119
+ out.append(f'  {k}:{v}。')
120
+ elif v:
121
+ out.append('  ' + v + '。')
122
+ continue
123
+ # 多列表:表头属于 generic 的整列跳过,其余用「表头:值」
124
+ parts = []
125
+ for i, c in enumerate(row):
126
+ if not c:
127
+ continue
128
+ key = (head[i] if i < len(head) else '').strip()
129
+ if key in generic:
130
+ continue
131
+ val = clean_inline(c)
132
+ parts.append(f'{clean_inline(key)}:{val}' if key else val)
133
+ if parts:
134
+ out.append('  ' + ';'.join(parts) + '。')
135
+
136
+ for raw in lines:
137
+ line = raw.rstrip()
138
+
139
+ if line.strip().startswith('|'):
140
+ table_rows.append(line)
141
+ continue
142
+ elif table_rows:
143
+ flush_table()
144
+
145
+ if not line.strip() or re.match(r'^\s*---+\s*$', line):
146
+ continue
147
+ if line.strip().startswith('```'):
148
+ continue
149
+
150
+ m = re.match(r'^(#{1,6})\s+(.*)$', line)
151
+ if m:
152
+ level, text = len(m.group(1)), clean_inline(m.group(2))
153
+ if level == 1:
154
+ continue # 作为文档标题另行传入
155
+ if level == 2:
156
+ h1 += 1
157
+ h2 = 0
158
+ text = re.sub(r'^[〇一二三四五六七八九十]+、\s*', '', text)
159
+ out.append(f'{CN[h1] if h1 < len(CN) else h1}、{text}')
160
+ elif level == 3:
161
+ h2 += 1
162
+ text = re.sub(r'^\d+(\.\d+)*\s*', '', text)
163
+ out.append(f'({SUBCN[h2] if h2 < len(SUBCN) else h2}){text}')
164
+ else:
165
+ text = re.sub(r'^\d+(\.\d+)*\s*', '', text)
166
+ out.append(f'1. {text}')
167
+ continue
168
+
169
+ m = re.match(r'^\s*[-*+]\s+(.*)$', line) or re.match(r'^\s*\d+[.、]\s+(.*)$', line)
170
+ if m:
171
+ t = clean_inline(m.group(1))
172
+ if t:
173
+ out.append('  ' + t)
174
+ continue
175
+
176
+ t = clean_inline(line)
177
+ if t:
178
+ # 元信息(用途/编制日期/编制说明/说明)在 drop_meta 时丢弃
179
+ if drop_meta and re.match(r'^(用途|编制日期|编制说明|说明)\s*[::]', t):
180
+ continue
181
+ out.append('  ' + t)
182
+
183
+ flush_table()
184
+ return '\n'.join(out)
185
+
186
+
187
+ if __name__ == '__main__':
188
+ import argparse
189
+ ap = argparse.ArgumentParser(description='Markdown → 公文正文格式')
190
+ ap.add_argument('--input', required=True, help='输入 .md 文件')
191
+ ap.add_argument('--output', help='输出 .txt(省略则打印到 stdout)')
192
+ ap.add_argument('--drop-meta', action='store_true', help='丢弃开头的元信息行')
193
+ a = ap.parse_args()
194
+ body = md_to_body(open(a.input, encoding='utf-8').read(), drop_meta=a.drop_meta)
195
+ if a.output:
196
+ open(a.output, 'w', encoding='utf-8').write(body)
197
+ print(f'已写出: {a.output}')
198
+ else:
199
+ print(body)