Files
PlantAI-rag-new/sdk/app/services/docgen/md2docx.py
T
jerryandClaude Fable 5 5db3ead73e feat(docgen): doc_writer 能力全链——vstore 独立服务 + BFF docgen 路由 + 前端文档工作台
- vstore 独立安装包(8094):解析/嵌入/加密 vault/faiss 检索,存储路径支持本地 file 与 http(s):// URL,启动握手校验,集群化预留(embed 独立模块)
- BFF docgen 路由:上传入库/generate/编辑/导出/下载/主库管理,意图护栏(与前端同词典),vstore 失败兜底,quota 507 与 TTL 惰性清理
- 前端:自然触发意图路由、拖拽上传 chips、能力徽标、右侧文档 TAB(WYSIWYG 编辑/prompt 修改/PDF+DOCX 导出下载)、右栏拖宽+手柄仿左栏
- 批量导入工具 docgen_import.py(并发 8、导入即加密+删源明文)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-09-03 21:13:37 +08:00

156 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""services/docgen/md2docx.py —— Markdown → Word(行级状态机)。
- 行级状态机 → Word 内建样式:
Normal / Heading 1-3 / List Bullet / List Number / Table Grid / Quote
- 中文:正文宋体 / 标题黑体;font.name 与 w:eastAsia 同时设置
- 表格表头加粗灰底
"""
from __future__ import annotations
import re
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
from docx.shared import Pt, RGBColor
# 字体:正文宋体 / 标题黑体(Windows 中文环境标准;wps-office 渲染兼容)
_FONT_BODY = "宋体"
_FONT_HEADING = "黑体"
_HEADING_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_LIST_BULLET_RE = re.compile(r"^\s*[-*+]\s+(.*)$")
_LIST_NUMBER_RE = re.compile(r"^\s*(\d+)[.)、]\s*(.*)$")
_QUOTE_RE = re.compile(r"^\s*>\s?(.*)$")
_TABLE_ROW_RE = re.compile(r"^\s*\|(.+)\|\s*$")
def _set_run_font(run, font_name: str, size_pt: float | None = None,
bold: bool | None = None) -> None:
"""设置西文 + 中文字体(font.name 与 w:eastAsia 同时设置)。"""
run.font.name = font_name
rpr = run._element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = OxmlElement("w:rFonts")
rpr.append(rfonts)
rfonts.set(qn("w:eastAsia"), font_name)
if size_pt is not None:
run.font.size = Pt(size_pt)
if bold is not None:
run.font.bold = bold
def _shade_cell(cell, fill: str) -> None:
"""单元格底色(表头灰底)。"""
tc_pr = cell._tc.get_or_add_tcPr()
shd = OxmlElement("w:shd")
shd.set(qn("w:val"), "clear")
shd.set(qn("w:color"), "auto")
shd.set(qn("w:fill"), fill)
tc_pr.append(shd)
def _parse_table_row(line: str) -> list[str]:
"""| a | b | → [a, b](去首尾空格)。"""
cells = line.strip().strip("|").split("|")
return [c.strip() for c in cells]
def _is_separator_row(cells: list[str]) -> bool:
"""|---|---| 分隔行。"""
return all(re.fullmatch(r":?-{2,}:?", c) for c in cells if c.strip() != "") and any(c.strip() for c in cells)
def docx_from_markdown(markdown: str, out_path: str | Path) -> Path:
"""Markdown → docx(行级状态机)。返回写入路径。
- 标题:#/##/### → Heading 1/2/3(更深的折叠到 3)
- 列表:- / * / + → List Bullet;1. / 1) → List Number
- 引用:> → Quote
- 表格:| 行 → Table Grid;首行为表头(加粗灰底)
- 其余 → Normal
"""
doc = Document()
lines = (markdown or "").split("\n")
# 表格缓冲(连续 | 行)
table_lines: list[str] = []
def _flush_table() -> None:
if not table_lines:
return
rows = [_parse_table_row(line) for line in table_lines]
rows = [r for r in rows if not _is_separator_row(r)]
if rows:
width = max(len(r) for r in rows)
rows = [r + [""] * (width - len(r)) for r in rows]
table = doc.add_table(rows=len(rows), cols=width)
table.style = "Table Grid"
for r_idx, row in enumerate(rows):
for c_idx, cell_text in enumerate(row):
cell = table.cell(r_idx, c_idx)
cell.text = ""
para = cell.paragraphs[0]
run = para.add_run(cell_text)
_set_run_font(run, _FONT_BODY, 10.5)
if r_idx == 0: # 表头:加粗灰底
_set_run_font(run, _FONT_BODY, 10.5, bold=True)
_shade_cell(cell, "D9D9D9")
table_lines.clear()
def _flush_and(block: list[str]) -> None:
if block:
_flush_table()
block.clear()
for raw in lines:
line = raw.rstrip()
# 表格行缓冲
if _TABLE_ROW_RE.match(line):
table_lines.append(line)
continue
_flush_table()
if not line.strip():
continue
m = _HEADING_RE.match(line)
if m:
level = min(len(m.group(1)), 3) # 更深标题折叠到 Heading 3
para = doc.add_heading("", level=level)
para.style = doc.styles[f"Heading {level}"]
run = para.add_run(m.group(2).strip())
_set_run_font(run, _FONT_HEADING, {1: 16, 2: 14, 3: 12}[level], bold=True)
para.paragraph_format.space_before = Pt(10)
continue
m = _LIST_BULLET_RE.match(line)
if m:
para = doc.add_paragraph(style="List Bullet")
run = para.add_run(m.group(1).strip())
_set_run_font(run, _FONT_BODY, 12)
continue
m = _LIST_NUMBER_RE.match(line)
if m:
para = doc.add_paragraph(style="List Number")
run = para.add_run(m.group(2).strip())
_set_run_font(run, _FONT_BODY, 12)
continue
m = _QUOTE_RE.match(line)
if m:
para = doc.add_paragraph(style="Quote")
run = para.add_run(m.group(1).strip())
_set_run_font(run, _FONT_BODY, 12)
continue
# 普通段落
para = doc.add_paragraph()
run = para.add_run(line.strip())
_set_run_font(run, _FONT_BODY, 12)
_flush_table()
out = Path(out_path)
out.parent.mkdir(parents=True, exist_ok=True)
doc.save(out)
return out