- 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>
156 lines
5.3 KiB
Python
156 lines
5.3 KiB
Python
"""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
|