@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.
|
|
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
|
|
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='输出目录')
|