proposal_writer.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. """
  2. proposal_writer.py - 投标文件DOCX输出模块
  3. 将 ProposalDocument 格式化为结构化的 Word 文档(.docx),
  4. 支持封面、目录、标题层级、表格、列表、页脚页码等排版要素。
  5. """
  6. import logging
  7. import re
  8. from typing import Optional
  9. from docx import Document
  10. from docx.enum.table import WD_TABLE_ALIGNMENT
  11. from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
  12. from docx.oxml.ns import qn, nsdecls
  13. from docx.oxml import parse_xml
  14. from docx.shared import Cm, Pt, RGBColor
  15. from docx.table import _Cell
  16. from .models import ProposalDocument, ProposalSection
  17. logger = logging.getLogger(__name__)
  18. # ---- 常量 ----
  19. PAGE_WIDTH_CM = 21.0
  20. PAGE_HEIGHT_CM = 29.7
  21. MARGIN_CM = 2.0
  22. BODY_WIDTH_CM = PAGE_WIDTH_CM - 2 * MARGIN_CM # 17.0 cm
  23. FONT_NAME = "宋体"
  24. FONT_NAME_ASCII = "Times New Roman"
  25. HEADING_FONTS = {1: 16, 2: 14, 3: 12}
  26. BODY_FONT_SIZE = 12 # pt
  27. def _set_cell_border(cell: _Cell, **kwargs):
  28. """设置单元格边框。"""
  29. tc = cell._tc
  30. tcPr = tc.get_or_add_tcPr()
  31. tcBorders = parse_xml(
  32. f'<w:tcBorders {nsdecls("w")}>'
  33. ' <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  34. ' <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  35. ' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  36. ' <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  37. '</w:tcBorders>'
  38. )
  39. tcPr.append(tcBorders)
  40. def _set_paragraph_spacing(paragraph, line_spacing: float = 1.5, space_before: float = 0.5, space_after: float = 0.5):
  41. """设置段落间距。"""
  42. pf = paragraph.paragraph_format
  43. pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
  44. pf.line_spacing = line_spacing
  45. pf.space_before = Pt(space_before * BODY_FONT_SIZE)
  46. pf.space_after = Pt(space_after * BODY_FONT_SIZE)
  47. def _run_format(run, font_name: str = FONT_NAME, font_size: int = BODY_FONT_SIZE,
  48. bold: bool = False, color: Optional[str] = None):
  49. """设置 run 的字体格式。"""
  50. run.font.name = font_name
  51. run.font.size = Pt(font_size)
  52. run.font.bold = bold
  53. run._element.rPr.rFonts.set(qn("w:eastAsia"), font_name)
  54. if color:
  55. run.font.color.rgb = RGBColor(*bytes.fromhex(color.lstrip("#")))
  56. def _setup_page(doc: Document):
  57. """设置页面格式:A4、2cm页边距。"""
  58. section = doc.sections[0]
  59. section.page_width = Cm(PAGE_WIDTH_CM)
  60. section.page_height = Cm(PAGE_HEIGHT_CM)
  61. section.top_margin = Cm(MARGIN_CM)
  62. section.bottom_margin = Cm(MARGIN_CM)
  63. section.left_margin = Cm(MARGIN_CM)
  64. section.right_margin = Cm(MARGIN_CM)
  65. def _add_footer_page_number(doc: Document):
  66. """添加页脚页码。"""
  67. section = doc.sections[0]
  68. footer = section.footer
  69. footer.is_linked_to_previous = False
  70. p = footer.paragraphs[0]
  71. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  72. # "第 X 页 / 共 Y 页"
  73. run1 = p.add_run("第 ")
  74. _run_format(run1, font_size=10)
  75. fld_char1 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
  76. run2 = p.add_run()
  77. run2._element.append(fld_char1)
  78. instr = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>')
  79. run3 = p.add_run()
  80. run3._element.append(instr)
  81. fld_char2 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
  82. run4 = p.add_run()
  83. run4._element.append(fld_char2)
  84. run5 = p.add_run(" 页 / 共 ")
  85. _run_format(run5, font_size=10)
  86. fld_char3 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>')
  87. run6 = p.add_run()
  88. run6._element.append(fld_char3)
  89. instr2 = parse_xml(f'<w:instrText {nsdecls("w")} xml:space="preserve"> NUMPAGES </w:instrText>')
  90. run7 = p.add_run()
  91. run7._element.append(instr2)
  92. fld_char4 = parse_xml(f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>')
  93. run8 = p.add_run()
  94. run8._element.append(fld_char4)
  95. run9 = p.add_run(" 页")
  96. _run_format(run9, font_size=10)
  97. def _create_cover_page(doc: Document, title: str, date_str: str = ""):
  98. """创建封面页。
  99. 封面排版:居中,从上到下依次为:
  100. - "投标文件"(大号标题)
  101. - 项目名称
  102. - 编制日期
  103. """
  104. import datetime
  105. if not date_str:
  106. date_str = datetime.date.today().strftime("%Y年%m月%d日")
  107. # 上部留白
  108. for _ in range(6):
  109. doc.add_paragraph()
  110. # 大标题:投标文件
  111. p_title = doc.add_paragraph()
  112. p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER
  113. run = p_title.add_run("投 标 文 件")
  114. _run_format(run, font_name=FONT_NAME, font_size=28, bold=True)
  115. # 装饰线
  116. p_line = doc.add_paragraph()
  117. p_line.alignment = WD_ALIGN_PARAGRAPH.CENTER
  118. run_line = p_line.add_run("━" * 20)
  119. _run_format(run_line, font_size=14, color="333333")
  120. doc.add_paragraph()
  121. # 项目名称
  122. p_proj = doc.add_paragraph()
  123. p_proj.alignment = WD_ALIGN_PARAGRAPH.CENTER
  124. run_proj = p_proj.add_run(f"项目名称:{title}")
  125. _run_format(run_proj, font_size=16)
  126. doc.add_paragraph()
  127. doc.add_paragraph()
  128. # 编制日期
  129. p_date = doc.add_paragraph()
  130. p_date.alignment = WD_ALIGN_PARAGRAPH.CENTER
  131. run_date = p_date.add_run(f"编制日期:{date_str}")
  132. _run_format(run_date, font_size=14)
  133. # 分页
  134. doc.add_page_break()
  135. def _add_heading(doc: Document, text: str, level: int = 1):
  136. """添加格式化标题。
  137. 使用预定义的字体大小:Heading1=16pt, Heading2=14pt, Heading3=12pt。
  138. """
  139. heading = doc.add_heading(text, level=min(level, 3))
  140. font_size = HEADING_FONTS.get(min(level, 3), 12)
  141. for run in heading.runs:
  142. run.font.name = FONT_NAME
  143. run.font.size = Pt(font_size)
  144. run._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_NAME)
  145. return heading
  146. def _add_body_paragraph(doc: Document, text: str):
  147. """添加格式化正文段落。"""
  148. p = doc.add_paragraph()
  149. p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
  150. _set_paragraph_spacing(p)
  151. # 处理内联格式:**粗体**
  152. parts = re.split(r'(\*\*.+?\*\*)', text)
  153. for part in parts:
  154. if part.startswith("**") and part.endswith("**"):
  155. run = p.add_run(part[2:-2])
  156. _run_format(run, bold=True)
  157. else:
  158. run = p.add_run(part)
  159. _run_format(run)
  160. p.style = doc.styles["Normal"]
  161. return p
  162. def _add_bullet_list(doc: Document, items: list[str]):
  163. """添加无序列表。"""
  164. for item in items:
  165. p = doc.add_paragraph(style="List Bullet")
  166. _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
  167. p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
  168. run = p.add_run(item.strip())
  169. _run_format(run)
  170. def _add_numbered_list(doc: Document, items: list[str]):
  171. """添加有序列表。"""
  172. for item in items:
  173. p = doc.add_paragraph(style="List Number")
  174. _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
  175. p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
  176. run = p.add_run(item.strip())
  177. _run_format(run)
  178. def _parse_markdown_table(md_table: str) -> list[list[str]]:
  179. """解析Markdown表格为二维列表。
  180. Args:
  181. md_table: Markdown表格文本
  182. Returns:
  183. list[list[str]]: 二维字符串列表,第一行为表头
  184. """
  185. lines = [line.strip() for line in md_table.strip().split("\n")]
  186. lines = [line for line in lines if line and not line.startswith("| ---") and not line.startswith("|---")]
  187. if not lines:
  188. return []
  189. table_data = []
  190. for line in lines:
  191. line = line.strip()
  192. if line.startswith("|"):
  193. line = line[1:]
  194. if line.endswith("|"):
  195. line = line[:-1]
  196. cells = [cell.strip() for cell in line.split("|")]
  197. table_data.append(cells)
  198. return table_data
  199. def _write_table(doc: Document, table_lines: list[str]):
  200. """将Markdown表格写入DOCX表格。"""
  201. table_text = "\n".join(table_lines)
  202. data = _parse_markdown_table(table_text)
  203. if not data or len(data) < 2:
  204. logger.warning("表格数据不足,跳过表格写入")
  205. return
  206. rows = len(data)
  207. cols = max(len(row) for row in data)
  208. doc_table = doc.add_table(rows=rows, cols=cols)
  209. doc_table.autofit = True
  210. doc_table.alignment = WD_TABLE_ALIGNMENT.CENTER
  211. # 设置表格边框
  212. tbl = doc_table._tbl
  213. tblPr = tbl.tblPr if tbl.tblPr is not None else parse_xml(
  214. f'<w:tblPr {nsdecls("w")}/>'
  215. )
  216. borders = parse_xml(
  217. f'<w:tblBorders {nsdecls("w")}>'
  218. ' <w:top w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  219. ' <w:left w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  220. ' <w:bottom w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  221. ' <w:right w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  222. ' <w:insideH w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  223. ' <w:insideV w:val="single" w:sz="4" w:space="0" w:color="000000"/>'
  224. '</w:tblBorders>'
  225. )
  226. tblPr.append(borders)
  227. for r_idx, row_data in enumerate(data):
  228. for c_idx in range(cols):
  229. cell_text = row_data[c_idx] if c_idx < len(row_data) else ""
  230. cell = doc_table.cell(r_idx, c_idx)
  231. # 清空并重新设置内容
  232. cell.text = ""
  233. p = cell.paragraphs[0]
  234. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  235. run = p.add_run(cell_text)
  236. is_header = r_idx == 0
  237. _run_format(run, font_size=10, bold=is_header)
  238. doc.add_paragraph() # 表后空行
  239. def _write_markdown_content(doc, markdown_text: str, base_level: int = 2):
  240. """将Markdown格式的内容写入DOCX。
  241. 支持的Markdown元素:
  242. - # 标题 → Heading样式
  243. - **粗体** → 粗体
  244. - 普通段落 → Normal样式
  245. - - 列表项 → 无序列表
  246. - 1. 列表项 → 有序列表
  247. - | 表格 | → DOCX表格
  248. - 空行 → 段落分隔
  249. Args:
  250. doc: Document对象
  251. markdown_text: Markdown格式的文本
  252. base_level: 基础标题层级偏移(默认2,即# -> Heading 2)
  253. """
  254. lines = markdown_text.split("\n")
  255. i = 0
  256. in_table = False
  257. table_lines = []
  258. in_code_block = False
  259. code_lines = []
  260. while i < len(lines):
  261. line = lines[i]
  262. stripped = line.strip()
  263. # 代码块处理
  264. if stripped.startswith("```"):
  265. if in_code_block:
  266. # 结束代码块,作为普通段落写入
  267. for code_line in code_lines:
  268. _add_body_paragraph(doc, code_line)
  269. code_lines = []
  270. in_code_block = False
  271. else:
  272. in_code_block = True
  273. i += 1
  274. continue
  275. if in_code_block:
  276. code_lines.append(line)
  277. i += 1
  278. continue
  279. # 表格处理:连续以 | 开头的行
  280. # 判断是否为 Markdown 表格分隔行(如 |---|---| 或 |:---|:---:|)
  281. _is_separator = bool(re.match(r'^\|[\s\-:]+\|?$', stripped))
  282. if stripped.startswith("|") and not _is_separator:
  283. in_table = True
  284. table_lines.append(line)
  285. i += 1
  286. continue
  287. elif _is_separator:
  288. # 分隔行,跳过
  289. i += 1
  290. continue
  291. else:
  292. if in_table:
  293. # 表格结束,写入表格
  294. _write_table(doc, table_lines)
  295. table_lines = []
  296. in_table = False
  297. # 空行
  298. if not stripped:
  299. i += 1
  300. continue
  301. # 标题
  302. heading_match = re.match(r'^(#{1,6})\s+(.+)$', stripped)
  303. if heading_match:
  304. level = min(len(heading_match.group(1)) + base_level - 1, 3)
  305. # level最小为1
  306. level = max(level, 1)
  307. title_text = heading_match.group(2)
  308. # 去掉可能的内联格式标记(标题中保留文字)
  309. title_text = re.sub(r'\*\*(.+?)\*\*', r'\1', title_text)
  310. _add_heading(doc, title_text, level)
  311. i += 1
  312. continue
  313. # 无序列表
  314. if stripped.startswith("- ") or stripped.startswith("* "):
  315. items = []
  316. while i < len(lines):
  317. s = lines[i].strip()
  318. if s.startswith("- ") or s.startswith("* "):
  319. items.append(s[2:])
  320. i += 1
  321. else:
  322. break
  323. _add_bullet_list(doc, items)
  324. continue
  325. # 有序列表
  326. ordered_match = re.match(r'^\d+\.\s+(.+)$', stripped)
  327. if ordered_match:
  328. items = []
  329. while i < len(lines):
  330. s = lines[i].strip()
  331. if re.match(r'^\d+\.\s+.+$', s):
  332. items.append(re.match(r'^\d+\.\s+(.+)$', s).group(1))
  333. i += 1
  334. else:
  335. break
  336. _add_numbered_list(doc, items)
  337. continue
  338. # 普通段落
  339. paragraph_text = stripped
  340. i += 1
  341. # 合并后续非空、非特殊行
  342. while i < len(lines):
  343. next_line = lines[i]
  344. next_stripped = next_line.strip()
  345. if not next_stripped:
  346. break
  347. if next_stripped.startswith("#"):
  348. break
  349. if next_stripped.startswith("- ") or next_stripped.startswith("* "):
  350. break
  351. if re.match(r'^\d+\.\s+', next_stripped):
  352. break
  353. if next_stripped.startswith("|") and not next_stripped.startswith("| ---"):
  354. break
  355. paragraph_text += " " + next_stripped
  356. i += 1
  357. _add_body_paragraph(doc, paragraph_text)
  358. # 如果文档以表格结尾
  359. if in_table and table_lines:
  360. _write_table(doc, table_lines)
  361. def write_proposal_docx(content: ProposalDocument, output_path: str) -> str:
  362. """将投标内容写入格式化的DOCX文件。
  363. 文档结构:
  364. 1. 封面("投标文件"大标题 + 项目名称 + 日期)
  365. 2. 目录
  366. 3. 投标函(从 sections 中识别)
  367. 4. 投标概要(content.summary)
  368. 5. 正文章节(按层级组织)
  369. 6. 页脚页码
  370. Args:
  371. content: 投标文档内容(ProposalDocument)
  372. output_path: 输出文件路径(应以 .docx 结尾)
  373. Returns:
  374. str: 输出文件路径
  375. Raises:
  376. ValueError: 如果 content 为空或 output_path 不合法
  377. IOError: 如果文件写入失败
  378. """
  379. if not content:
  380. raise ValueError("投标内容不能为空")
  381. if not output_path:
  382. raise ValueError("输出路径不能为空")
  383. logger.info("开始生成DOCX文档:%s", output_path)
  384. doc = Document()
  385. _setup_page(doc)
  386. # 从 sections 中分离出投标函和内容章节
  387. cover_letter_section = None
  388. content_sections: list[ProposalSection] = []
  389. for s in content.sections:
  390. if s.title == "投标函" and cover_letter_section is None:
  391. cover_letter_section = s
  392. else:
  393. content_sections.append(s)
  394. # 1. 封面
  395. _create_cover_page(doc, content.title or content.project_name or "投标文件")
  396. # 2. 目录
  397. _add_heading(doc, "目 录", 1)
  398. toc_num = 1
  399. if cover_letter_section:
  400. p = doc.add_paragraph(f"{_to_chinese_number(toc_num)}、{cover_letter_section.title}")
  401. _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
  402. toc_num += 1
  403. if content.summary:
  404. p = doc.add_paragraph(f"{_to_chinese_number(toc_num)}、编制说明")
  405. _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
  406. toc_num += 1
  407. for s in content_sections:
  408. indent = " " * (s.level - 1) if s.level > 1 else ""
  409. p = doc.add_paragraph(f"{indent}{s.title}")
  410. _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0)
  411. doc.add_page_break()
  412. # 3. 投标函
  413. section_counter = 1
  414. if cover_letter_section:
  415. _add_heading(doc, f"{_to_chinese_number(section_counter)}、{cover_letter_section.title}", 1)
  416. section_counter += 1
  417. _write_markdown_content(doc, cover_letter_section.content, base_level=2)
  418. doc.add_page_break()
  419. # 4. 投标概要
  420. if content.summary:
  421. _add_heading(doc, f"{_to_chinese_number(section_counter)}、编制说明", 1)
  422. section_counter += 1
  423. _write_markdown_content(doc, content.summary, base_level=2)
  424. doc.add_page_break()
  425. # 5. 各章节
  426. for section in content_sections:
  427. numbered_title = f"{_to_chinese_number(section_counter)}、{section.title}"
  428. section_counter += 1
  429. _add_heading(doc, numbered_title, 1)
  430. _write_markdown_content(doc, section.content, base_level=2)
  431. # 6. 页脚页码
  432. _add_footer_page_number(doc)
  433. try:
  434. doc.save(output_path)
  435. except (OSError, IOError) as e:
  436. raise OSError(
  437. f"无法保存DOCX文件到路径: {output_path}\n"
  438. f"请检查目录是否存在、磁盘空间是否充足。\n"
  439. f"原始错误: {e}"
  440. ) from e
  441. logger.info("DOCX文档已保存至: %s", output_path)
  442. return output_path
  443. def _to_chinese_number(n: int) -> str:
  444. """将阿拉伯数字转为中文数字(一、二、三...)。
  445. Args:
  446. n: 阿拉伯数字
  447. Returns:
  448. str: 中文数字
  449. """
  450. chinese_nums = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十",
  451. "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十"]
  452. if 1 <= n <= len(chinese_nums):
  453. return chinese_nums[n]
  454. return str(n)