@deepwhale-cn/dsh-cn-doc-formatter 0.1.1 → 0.1.3
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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@deepwhale-cn/dsh-cn-doc-formatter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.3",
|
|
4
4
|
"description": "中文公文排版:按 GB/T 9704-2012 生成 .docx / .pdf / .txt,附输出自检。不含字体文件,字体由用户自备。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"./package.json": "./package.json"
|
|
37
37
|
},
|
|
38
38
|
"engines": {
|
|
39
|
-
"dsh": ">=0.1.0-rc.6"
|
|
39
|
+
"dsh": ">=0.1.0-rc.6 <0.3.0"
|
|
40
40
|
},
|
|
41
41
|
"dsh": {
|
|
42
42
|
"bundle": {
|
|
@@ -5,7 +5,7 @@ description: 中文文书排版生成器。当用户需要生成 Word(.docx)、P
|
|
|
5
5
|
|
|
6
6
|
# 中文文书排版技能
|
|
7
7
|
|
|
8
|
-
生成排版规范的中文常规文书(通知、报告、函、文件等)。输出 .docx / .pdf / .txt 三种文件,全文统一使用仿宋(GB2312
|
|
8
|
+
生成排版规范的中文常规文书(通知、报告、函、文件等)。输出 .docx / .pdf / .txt 三种文件,全文统一使用仿宋(GB2312 简体字集)排版,严格遵循以下规范。文档应保持洁净:不添加 AI 模型署名、水印、生成时间戳、版本说明等内容以外的噪音;**若文档内容由 AI 生成,则按第十条说明添加相应标识**。
|
|
9
9
|
|
|
10
10
|
## 字体字号规范
|
|
11
11
|
|
|
@@ -55,15 +55,27 @@ description: 中文文书排版生成器。当用户需要生成 Word(.docx)、P
|
|
|
55
55
|
|
|
56
56
|
所有文件保存在用户指定的输出目录(默认为桌面 `公文排版输出/` 目录)。
|
|
57
57
|
|
|
58
|
-
##
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
-
|
|
62
|
-
-
|
|
63
|
-
-
|
|
64
|
-
-
|
|
65
|
-
|
|
66
|
-
|
|
58
|
+
## 排版洁净要求
|
|
59
|
+
|
|
60
|
+
生成的文档应格式专业、干净,仅包含用户要求的内容本身,不主动添加:
|
|
61
|
+
- AI 模型的署名或水印
|
|
62
|
+
- 生成时间戳、版本说明
|
|
63
|
+
- 与内容无关的解释性文字、注释或说明
|
|
64
|
+
- 与内容无关的文档结构说明或元信息
|
|
65
|
+
|
|
66
|
+
以上是**排版层面的洁净要求**:公文规范不接受署名与水印,所以本技能不主动添加这类噪音。
|
|
67
|
+
|
|
68
|
+
> **与 AI 标识的关系(重要)**
|
|
69
|
+
>
|
|
70
|
+
> 本技能只做排版,不改变内容的生成属性。上述"不添加"清单**只约束本技能主动添加的内容**,
|
|
71
|
+
> 绝不意味着要隐藏内容的 AI 生成属性。
|
|
72
|
+
>
|
|
73
|
+
> **若文档内容由 AI 生成,应按《人工智能生成合成内容标识办法》与 GB 45438—2025
|
|
74
|
+
> 的要求添加相应标识,该要求优先于上述清单。** 两者不冲突:
|
|
75
|
+
> 前者是不主动添加噪音,后者是法定义务。
|
|
76
|
+
>
|
|
77
|
+
> 同时,不得提供任何用于删除、篡改、伪造、隐匿标识的功能或服务
|
|
78
|
+
> (《人工智能生成合成内容标识办法》第十条第二款,适用于任何组织和个人)。
|
|
67
79
|
|
|
68
80
|
## 字体:本插件不随包分发,需用户自备
|
|
69
81
|
|
|
@@ -443,8 +443,11 @@ def generate_docx(args):
|
|
|
443
443
|
add_title(doc, args.title)
|
|
444
444
|
|
|
445
445
|
if args.body:
|
|
446
|
-
for
|
|
447
|
-
|
|
446
|
+
for kind, payload in split_table_blocks(args.body):
|
|
447
|
+
if kind == 'table':
|
|
448
|
+
add_table(doc, payload)
|
|
449
|
+
continue
|
|
450
|
+
line = payload.strip()
|
|
448
451
|
if not line:
|
|
449
452
|
continue
|
|
450
453
|
if is_level1_title(line):
|
|
@@ -476,6 +479,91 @@ def generate_docx(args):
|
|
|
476
479
|
return filepath
|
|
477
480
|
|
|
478
481
|
|
|
482
|
+
def _table_sep(line):
|
|
483
|
+
"""markdown 表格的分隔行(|---|---|)。"""
|
|
484
|
+
import re as _re
|
|
485
|
+
return bool(_re.match(r'^\s*\|[\s:|-]+\|\s*$', line))
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def split_table_blocks(body):
|
|
489
|
+
"""把正文切成 [('line', 文本) | ('table', [[单元格,…], …])]。
|
|
490
|
+
|
|
491
|
+
约定:**连续以 | 开头**的行视为一个 markdown 表格(含可选分隔行)。
|
|
492
|
+
这样 md_adapter 原样透传 md 表格时,下游就能渲染成真表格;
|
|
493
|
+
不在表格里的 | 行也照样按表格处理(旧行为是拍平,新行为更好)。
|
|
494
|
+
"""
|
|
495
|
+
blocks, buf = [], []
|
|
496
|
+
|
|
497
|
+
def flush():
|
|
498
|
+
if not buf:
|
|
499
|
+
return
|
|
500
|
+
rows = []
|
|
501
|
+
for r in buf:
|
|
502
|
+
if _table_sep(r):
|
|
503
|
+
continue
|
|
504
|
+
cells = [c.strip() for c in r.strip().strip('|').split('|')]
|
|
505
|
+
if any(cells):
|
|
506
|
+
rows.append(cells)
|
|
507
|
+
if rows:
|
|
508
|
+
blocks.append(('table', rows))
|
|
509
|
+
|
|
510
|
+
for raw in body.split('\n'):
|
|
511
|
+
if raw.strip().startswith('|'):
|
|
512
|
+
buf.append(raw.rstrip())
|
|
513
|
+
continue
|
|
514
|
+
flush()
|
|
515
|
+
buf = []
|
|
516
|
+
blocks.append(('line', raw))
|
|
517
|
+
flush()
|
|
518
|
+
return blocks
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
def add_table(doc, rows):
|
|
522
|
+
"""把二维数组渲染成**真 Word 表格**(带边框、仿宋、五号 10.5pt、表头加粗)。
|
|
523
|
+
|
|
524
|
+
与全文风格一致:字体用同一个仿宋族(CN_FONT),行距用固定值保持紧凑。
|
|
525
|
+
单元格里换行会出现孤立标点的问题由上游 md_adapter 负责合并。
|
|
526
|
+
"""
|
|
527
|
+
from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
528
|
+
from docx.shared import Pt
|
|
529
|
+
from docx.oxml.ns import qn
|
|
530
|
+
from docx.oxml import OxmlElement
|
|
531
|
+
from docx.enum.text import WD_LINE_SPACING
|
|
532
|
+
|
|
533
|
+
if not rows:
|
|
534
|
+
return
|
|
535
|
+
ncol = max(len(r) for r in rows)
|
|
536
|
+
table = doc.add_table(rows=0, cols=ncol)
|
|
537
|
+
table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
538
|
+
for ri, row in enumerate(rows):
|
|
539
|
+
cells = table.add_row().cells
|
|
540
|
+
for ci in range(ncol):
|
|
541
|
+
txt = row[ci] if ci < len(row) else ''
|
|
542
|
+
cell = cells[ci]
|
|
543
|
+
cell.text = ''
|
|
544
|
+
p = cell.paragraphs[0]
|
|
545
|
+
p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
|
546
|
+
p.paragraph_format.line_spacing = Pt(16)
|
|
547
|
+
run = p.add_run(txt)
|
|
548
|
+
run.font.name = CN_FONT
|
|
549
|
+
run.font.size = Pt(10.5)
|
|
550
|
+
run.bold = (ri == 0)
|
|
551
|
+
try:
|
|
552
|
+
run._element.rPr.rFonts.set(qn('w:eastAsia'), CN_FONT)
|
|
553
|
+
except Exception:
|
|
554
|
+
pass
|
|
555
|
+
tcPr = cell._tc.get_or_add_tcPr()
|
|
556
|
+
borders = OxmlElement('w:tcBorders')
|
|
557
|
+
for edge in ('top', 'left', 'bottom', 'right'):
|
|
558
|
+
el = OxmlElement('w:' + edge)
|
|
559
|
+
el.set(qn('w:val'), 'single')
|
|
560
|
+
el.set(qn('w:sz'), '6')
|
|
561
|
+
el.set(qn('w:color'), '808080')
|
|
562
|
+
borders.append(el)
|
|
563
|
+
tcPr.append(borders)
|
|
564
|
+
add_body_text(doc, '')
|
|
565
|
+
|
|
566
|
+
|
|
479
567
|
def main():
|
|
480
568
|
parser = argparse.ArgumentParser(description='党政机关公文排版格式生成器 (.docx)')
|
|
481
569
|
parser.add_argument('--output', required=True, help='输出目录')
|
|
@@ -101,37 +101,12 @@ def md_to_body(md: str, drop_meta: bool = False) -> str:
|
|
|
101
101
|
nonlocal table_rows
|
|
102
102
|
if not table_rows:
|
|
103
103
|
return
|
|
104
|
-
|
|
105
|
-
|
|
104
|
+
# ── 表格:**原样保留**(markdown 管道形式)──────────────────────
|
|
105
|
+
# 原来这里把表格拍平成「表头:值;表头:值。」—— 那是本 skill 早期没有
|
|
106
|
+
# 表格支持时的权宜做法。2026-10-02 起 docx / pdf / txt 三个 formatter
|
|
107
|
+
# 都会把连续 | 行渲染成**真表格**,所以这里透传即可(拍平反而丢信息)。
|
|
108
|
+
out.extend(table_rows)
|
|
106
109
|
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
110
|
|
|
136
111
|
for raw in lines:
|
|
137
112
|
line = raw.rstrip()
|
|
@@ -330,8 +330,12 @@ def generate_pdf(args):
|
|
|
330
330
|
|
|
331
331
|
# === 正文 ===
|
|
332
332
|
if args.body:
|
|
333
|
-
for
|
|
334
|
-
|
|
333
|
+
for kind, payload in split_table_blocks(args.body):
|
|
334
|
+
if kind == 'table':
|
|
335
|
+
story.append(make_table(payload, make_style, body_size, markup_text))
|
|
336
|
+
story.append(Spacer(1, body_leading * 0.5))
|
|
337
|
+
continue
|
|
338
|
+
line = payload.strip()
|
|
335
339
|
if not line:
|
|
336
340
|
continue
|
|
337
341
|
if is_level1_title(line):
|
|
@@ -381,6 +385,83 @@ def generate_pdf(args):
|
|
|
381
385
|
return filepath
|
|
382
386
|
|
|
383
387
|
|
|
388
|
+
def _table_sep(line):
|
|
389
|
+
"""markdown 表格的分隔行(|---|---|)。"""
|
|
390
|
+
import re as _re
|
|
391
|
+
return bool(_re.match(r'^\s*\|[\s:|-]+\|\s*$', line))
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def split_table_blocks(body):
|
|
395
|
+
"""把正文切成 [('line', 文本) | ('table', [[单元格,…], …])]。
|
|
396
|
+
|
|
397
|
+
约定:**连续以 | 开头**的行视为一个 markdown 表格(含可选分隔行)。
|
|
398
|
+
这样 md_adapter 原样透传 md 表格时,下游就能渲染成真表格;
|
|
399
|
+
不在表格里的 | 行也照样按表格处理(旧行为是拍平,新行为更好)。
|
|
400
|
+
"""
|
|
401
|
+
blocks, buf = [], []
|
|
402
|
+
|
|
403
|
+
def flush():
|
|
404
|
+
if not buf:
|
|
405
|
+
return
|
|
406
|
+
rows = []
|
|
407
|
+
for r in buf:
|
|
408
|
+
if _table_sep(r):
|
|
409
|
+
continue
|
|
410
|
+
cells = [c.strip() for c in r.strip().strip('|').split('|')]
|
|
411
|
+
if any(cells):
|
|
412
|
+
rows.append(cells)
|
|
413
|
+
if rows:
|
|
414
|
+
blocks.append(('table', rows))
|
|
415
|
+
|
|
416
|
+
for raw in body.split('\n'):
|
|
417
|
+
if raw.strip().startswith('|'):
|
|
418
|
+
buf.append(raw.rstrip())
|
|
419
|
+
continue
|
|
420
|
+
flush()
|
|
421
|
+
buf = []
|
|
422
|
+
blocks.append(('line', raw))
|
|
423
|
+
flush()
|
|
424
|
+
return blocks
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def make_table(rows, make_style, body_size, markup_text):
|
|
428
|
+
"""把二维数组渲染成**真 PDF 表格**(可跨页、带边框、表头加粗效果)。"""
|
|
429
|
+
from reportlab.platypus import Table, TableStyle
|
|
430
|
+
from reportlab.lib import colors
|
|
431
|
+
from reportlab.lib.units import cm
|
|
432
|
+
|
|
433
|
+
if not rows:
|
|
434
|
+
return Spacer(1, 1)
|
|
435
|
+
ncol = max(len(r) for r in rows)
|
|
436
|
+
# 列宽按内容长度加权(越长的列越宽),避免窄列挤成一团
|
|
437
|
+
weights = []
|
|
438
|
+
for ci in range(ncol):
|
|
439
|
+
w = max((len(r[ci]) for r in rows if ci < len(r)), default=4)
|
|
440
|
+
weights.append(max(w, 5))
|
|
441
|
+
total = float(sum(weights))
|
|
442
|
+
avail = A4[0] - 2.8 * cm - 2.6 * cm
|
|
443
|
+
widths = [avail * w / total for w in weights]
|
|
444
|
+
|
|
445
|
+
cell_style = make_style(FONTS['body'], 9.5, TA_JUSTIFY, leading=13)
|
|
446
|
+
data = []
|
|
447
|
+
for ri, row in enumerate(rows):
|
|
448
|
+
cells = []
|
|
449
|
+
for ci in range(ncol):
|
|
450
|
+
txt = row[ci] if ci < len(row) else ''
|
|
451
|
+
cells.append(Paragraph(markup_text(txt), cell_style))
|
|
452
|
+
data.append(cells)
|
|
453
|
+
t = Table(data, colWidths=widths, repeatRows=1)
|
|
454
|
+
t.setStyle(TableStyle([
|
|
455
|
+
('GRID', (0, 0), (-1, -1), 0.4, colors.HexColor('#9aa3ad')),
|
|
456
|
+
('VALIGN', (0, 0), (-1, -1), 'TOP'),
|
|
457
|
+
('LEFTPADDING', (0, 0), (-1, -1), 4),
|
|
458
|
+
('RIGHTPADDING', (0, 0), (-1, -1), 4),
|
|
459
|
+
('TOPPADDING', (0, 0), (-1, -1), 3),
|
|
460
|
+
('BOTTOMPADDING', (0, 0), (-1, -1), 3),
|
|
461
|
+
]))
|
|
462
|
+
return t
|
|
463
|
+
|
|
464
|
+
|
|
384
465
|
def main():
|
|
385
466
|
parser = argparse.ArgumentParser(description='党政机关公文排版格式生成器 (.pdf)')
|
|
386
467
|
parser.add_argument('--output', required=True, help='输出目录')
|
|
@@ -37,9 +37,28 @@ def generate_txt(args):
|
|
|
37
37
|
|
|
38
38
|
# 正文
|
|
39
39
|
if args.body:
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
40
|
+
for kind, payload in split_table_blocks(args.body):
|
|
41
|
+
if kind == 'table':
|
|
42
|
+
# 纯文本表格:按列宽对齐(中文字符按 2 宽计算)
|
|
43
|
+
ncol = max(len(r) for r in payload)
|
|
44
|
+
widths = [0] * ncol
|
|
45
|
+
for row in payload:
|
|
46
|
+
for ci, c in enumerate(row):
|
|
47
|
+
w = sum(2 if ord(ch) > 127 else 1 for ch in c)
|
|
48
|
+
widths[ci] = max(widths[ci], w)
|
|
49
|
+
|
|
50
|
+
def pad(s, w):
|
|
51
|
+
n = sum(2 if ord(ch) > 127 else 1 for ch in s)
|
|
52
|
+
return s + ' ' * max(0, w - n)
|
|
53
|
+
|
|
54
|
+
for ri, row in enumerate(payload):
|
|
55
|
+
cells = [pad(row[ci] if ci < len(row) else '', widths[ci]) for ci in range(ncol)]
|
|
56
|
+
lines.append(' ' + ' '.join(cells).rstrip())
|
|
57
|
+
if ri == 0:
|
|
58
|
+
lines.append(' ' + '-' * (sum(widths) + 2 * (ncol - 1)))
|
|
59
|
+
lines.append('')
|
|
60
|
+
continue
|
|
61
|
+
line = payload.strip()
|
|
43
62
|
if not line:
|
|
44
63
|
continue
|
|
45
64
|
# 检测标题级别(与 docx/pdf 脚本保持一致的严格规则)
|
|
@@ -88,6 +107,45 @@ def is_heading(line):
|
|
|
88
107
|
return False
|
|
89
108
|
|
|
90
109
|
|
|
110
|
+
def _table_sep(line):
|
|
111
|
+
"""markdown 表格的分隔行(|---|---|)。"""
|
|
112
|
+
import re as _re
|
|
113
|
+
return bool(_re.match(r'^\s*\|[\s:|-]+\|\s*$', line))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def split_table_blocks(body):
|
|
117
|
+
"""把正文切成 [('line', 文本) | ('table', [[单元格,…], …])]。
|
|
118
|
+
|
|
119
|
+
约定:**连续以 | 开头**的行视为一个 markdown 表格(含可选分隔行)。
|
|
120
|
+
这样 md_adapter 原样透传 md 表格时,下游就能渲染成真表格;
|
|
121
|
+
不在表格里的 | 行也照样按表格处理(旧行为是拍平,新行为更好)。
|
|
122
|
+
"""
|
|
123
|
+
blocks, buf = [], []
|
|
124
|
+
|
|
125
|
+
def flush():
|
|
126
|
+
if not buf:
|
|
127
|
+
return
|
|
128
|
+
rows = []
|
|
129
|
+
for r in buf:
|
|
130
|
+
if _table_sep(r):
|
|
131
|
+
continue
|
|
132
|
+
cells = [c.strip() for c in r.strip().strip('|').split('|')]
|
|
133
|
+
if any(cells):
|
|
134
|
+
rows.append(cells)
|
|
135
|
+
if rows:
|
|
136
|
+
blocks.append(('table', rows))
|
|
137
|
+
|
|
138
|
+
for raw in body.split('\n'):
|
|
139
|
+
if raw.strip().startswith('|'):
|
|
140
|
+
buf.append(raw.rstrip())
|
|
141
|
+
continue
|
|
142
|
+
flush()
|
|
143
|
+
buf = []
|
|
144
|
+
blocks.append(('line', raw))
|
|
145
|
+
flush()
|
|
146
|
+
return blocks
|
|
147
|
+
|
|
148
|
+
|
|
91
149
|
def main():
|
|
92
150
|
parser = argparse.ArgumentParser(description='党政机关公文排版格式生成器 (.txt)')
|
|
93
151
|
parser.add_argument('--output', required=True, help='输出目录')
|