@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.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 简体字集)排版,严格遵循以下规范,且文档内不出现任何 AI 生成痕迹(无水印、无生成标记、无作者信息、无版本说明)。
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
- ## 无 AI 痕迹要求
59
-
60
- 生成的文档必须是纯粹、专业的公文,不得包含:
61
- - 任何作者署名、AI 标记、生成时间戳
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 line in args.body.strip().split('\n'):
447
- line = line.strip()
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
- rows = [r for r in table_rows
105
- if not re.match(r'^\s*\|[\s:|-]+\|\s*$', r)]
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 line in args.body.strip().split('\n'):
334
- line = line.strip()
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
- body_lines = args.body.strip().split('\n')
41
- for line in body_lines:
42
- line = line.strip()
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='输出目录')