_gen_bid_merge.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. # -*- coding: utf-8 -*-
  2. """把各章 Word 合成完整的《附件五 技术方案》《附件六 实施与服务方案》。
  3. 生成封面 + 自动目录,正文页码从 1 起连续编号(封面与目录不编号),
  4. 并同步导出 PDF 便于打印前校版。
  5. 用法:
  6. python doc/_gen_bid_merge.py 合并两个附件
  7. python doc/_gen_bid_merge.py 五 只合并附件五
  8. """
  9. import io
  10. import os
  11. import shutil
  12. import sys
  13. import time
  14. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  15. from docx.enum.text import WD_ALIGN_PARAGRAPH # noqa: E402
  16. from docx.oxml import OxmlElement # noqa: E402
  17. from docx.oxml.ns import qn # noqa: E402
  18. from docx.shared import Pt # noqa: E402
  19. from _bid_docx_kit import HEI, SONG, TITLE_C, _font, _spacing, new_doc # noqa
  20. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  21. HERE = os.path.dirname(os.path.abspath(__file__))
  22. SYNC_DIR = r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目招投标"
  23. PROJECT = "2026 年河南产互联制造业数据智能运营平台研发项目"
  24. PROJECT_NO = "0635-202611FW0036"
  25. BUYER = "河南产业互联网联合发展有限公司"
  26. COMPANY = "北京智造易科技有限公司"
  27. SUBMIT_DATE = "2026 年 8 月 5 日"
  28. ANNEX = {
  29. "五": ("附件五 技术方案", "技术方案", [
  30. (1, "项目需求理解及综合分析"),
  31. (2, "系统功能展示"),
  32. (3, "系统建设方案"),
  33. (4, "智能诊断与分析方案"),
  34. (5, "UAT 测试与质量保障"),
  35. ]),
  36. "六": ("附件六 实施与服务方案", "实施与服务方案", [
  37. (1, "质量与工期保障"),
  38. (2, "应急支撑方案"),
  39. (3, "售后服务方案"),
  40. (4, "项目培训方案"),
  41. ]),
  42. }
  43. # Word 常量
  44. WD_SECTION_BREAK_NEXT_PAGE = 2
  45. WD_STORY = 6
  46. WD_FORMAT_DOCX = 16
  47. WD_EXPORT_PDF = 17
  48. WD_HEADER_FOOTER_PRIMARY = 1
  49. # ----------------------------------------------------------------- 前置页
  50. def _center(doc, text, name_cn, pt, bold=False, color=None, before=0, after=0):
  51. p = doc.add_paragraph()
  52. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  53. _spacing(p, before=before, after=after, line=1.5)
  54. _font(p.add_run(text), name_cn, pt, bold=bold, color=color)
  55. return p
  56. def _clear_footer(sec):
  57. """封面与目录不编页码:断开继承并清空页脚。"""
  58. sec.footer.is_linked_to_previous = False
  59. for extra in list(sec.footer.paragraphs[1:]):
  60. extra._p.getparent().remove(extra._p)
  61. p = sec.footer.paragraphs[0]
  62. for r in list(p.runs):
  63. r._element.getparent().remove(r._element)
  64. def _toc_field(doc):
  65. """插入 TOC 域;合并后由 Word 计算条目与页码。"""
  66. p = doc.add_paragraph()
  67. _spacing(p, line=1.5)
  68. for kind, payload in (
  69. ("begin", None),
  70. # 只列到二级:三级标题过百条会撑成 7 页目录,评审翻检反而不便
  71. ("instr", ' TOC \\o "1-2" \\h \\z \\u '),
  72. ("separate", None),
  73. ("text", "右键选择“更新域”可刷新目录。"),
  74. ("end", None)):
  75. r = OxmlElement("w:r")
  76. if kind == "instr":
  77. it = OxmlElement("w:instrText")
  78. it.set(qn("xml:space"), "preserve")
  79. it.text = payload
  80. r.append(it)
  81. elif kind == "text":
  82. t = OxmlElement("w:t")
  83. t.text = payload
  84. r.append(t)
  85. else:
  86. fc = OxmlElement("w:fldChar")
  87. fc.set(qn("w:fldCharType"), kind)
  88. r.append(fc)
  89. p._p.append(r)
  90. for run in p.runs:
  91. _font(run, SONG, 12)
  92. def build_front(att):
  93. """封面 + 目录,两节独立且均不编页码。"""
  94. title, _, _ = ANNEX[att]
  95. doc = new_doc()
  96. _clear_footer(doc.sections[0])
  97. _center(doc, "响应文件", HEI, 20, color=TITLE_C, before=170, after=10)
  98. _center(doc, title, HEI, 28, color=TITLE_C, after=30)
  99. _center(doc, PROJECT, SONG, 15, bold=True, after=6)
  100. _center(doc, f"项目编号:{PROJECT_NO}", SONG, 13, after=48)
  101. _center(doc, f"采购人:{BUYER}", SONG, 14, after=8)
  102. _center(doc, f"供应商:{COMPANY}", SONG, 14, bold=True, after=8)
  103. _center(doc, SUBMIT_DATE, SONG, 14, after=0)
  104. from docx.enum.section import WD_SECTION
  105. sec = doc.add_section(WD_SECTION.NEW_PAGE)
  106. _clear_footer(sec)
  107. _center(doc, "目 录", HEI, 18, color=TITLE_C, before=6, after=18)
  108. _toc_field(doc)
  109. tmp = os.path.join(HERE, f"_tmp_front_{att}.docx")
  110. doc.save(tmp)
  111. return tmp
  112. # ----------------------------------------------------------------- 合并
  113. def chapter_paths(att):
  114. _, kind, chapters = ANNEX[att]
  115. out = []
  116. for no, name in chapters:
  117. p = os.path.join(HERE, f"附件{att}-{kind}-第{no}章 {name}.docx")
  118. if not os.path.exists(p):
  119. raise SystemExit(f"缺少章节文档:{os.path.basename(p)}")
  120. out.append(p)
  121. return out
  122. def merge(att, word):
  123. front = build_front(att)
  124. paths = chapter_paths(att)
  125. doc = word.Documents.Open(front)
  126. doc.Activate()
  127. sel = word.Selection
  128. for p in paths:
  129. sel.EndKey(WD_STORY)
  130. sel.InsertBreak(WD_SECTION_BREAK_NEXT_PAGE)
  131. sel.EndKey(WD_STORY)
  132. sel.InsertFile(p)
  133. # 封面、目录不编号;正文首节起从 1 连续编号
  134. body_start = 3
  135. for i in range(body_start, doc.Sections.Count + 1):
  136. pn = doc.Sections(i).Footers(WD_HEADER_FOOTER_PRIMARY).PageNumbers
  137. pn.RestartNumberingAtSection = (i == body_start)
  138. if i == body_start:
  139. pn.StartingNumber = 1
  140. for _ in range(2): # 目录页数变化会影响自身,刷两遍
  141. if doc.TablesOfContents.Count:
  142. doc.TablesOfContents(1).Update()
  143. doc.Fields.Update()
  144. title = ANNEX[att][0].replace(" ", "-")
  145. out = os.path.join(HERE, f"{title}(完整版).docx")
  146. pdf = os.path.join(HERE, "_bid_preview", os.path.basename(out)[:-5] + ".pdf")
  147. os.makedirs(os.path.dirname(pdf), exist_ok=True)
  148. for f in (out, pdf):
  149. if os.path.exists(f):
  150. os.remove(f)
  151. doc.SaveAs2(out, FileFormat=WD_FORMAT_DOCX)
  152. doc.ExportAsFixedFormat(pdf, WD_EXPORT_PDF)
  153. pages = doc.ComputeStatistics(2) # wdStatisticPages
  154. toc_items = (doc.TablesOfContents(1).Range.Paragraphs.Count
  155. if doc.TablesOfContents.Count else 0)
  156. doc.Close(False)
  157. os.remove(front)
  158. print(f"[完成] {os.path.basename(out)} 共 {pages} 页 目录 {toc_items} 条")
  159. if os.path.isdir(SYNC_DIR):
  160. shutil.copy2(out, os.path.join(SYNC_DIR, os.path.basename(out)))
  161. return out, pdf
  162. def main():
  163. want = [a for a in sys.argv[1:] if a in ANNEX] or list(ANNEX)
  164. import win32com.client as win32
  165. word = None
  166. for attempt in range(4):
  167. try:
  168. word = win32.gencache.EnsureDispatch("Word.Application")
  169. word.Visible = False
  170. word.DisplayAlerts = 0
  171. break
  172. except Exception as e: # Word COM 偶发拒绝调用
  173. print(f" 启动 Word 重试 {attempt + 1}/4:{e}")
  174. time.sleep(4)
  175. if word is None:
  176. raise SystemExit("无法启动 Word")
  177. try:
  178. for att in want:
  179. merge(att, word)
  180. finally:
  181. word.Quit()
  182. if __name__ == "__main__":
  183. main()