@deepwhale-cn/dsh-cn-doc-formatter 0.1.2 → 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.2",
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": {
@@ -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='输出目录')