_gen_bid_final.py 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. # -*- coding: utf-8 -*-
  2. """把承诺书、软著一览表、团队表与技术/实施方案各章并入响应文件,成一册交付。
  3. 响应文件由 _gen_bid_response.py 生成,其中留有 @@MERGE:key@@ 占位段;本脚本用
  4. Word 定位每个占位段,就地插入对应文件内容,然后统一页码、刷新目录并导出 PDF。
  5. 用法:
  6. python doc/_gen_bid_final.py 合并全部
  7. python doc/_gen_bid_final.py --list 只列出占位标记与对应文件,不改文档
  8. """
  9. import glob
  10. import io
  11. import os
  12. import shutil
  13. import sys
  14. import time
  15. sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
  16. sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8")
  17. HERE = os.path.dirname(os.path.abspath(__file__))
  18. SYNC_DIR = r"C:\Users\skygu\OneDrive\Projects\AIDOP\项目\项目招投标"
  19. MERGE_DIR = os.path.join(HERE, "_bid_merge_parts")
  20. # Word 常量
  21. WD_STORY = 6
  22. WD_SECTION_BREAK_NEXT_PAGE = 2
  23. WD_SECTION_BREAK_CONTINUOUS = 3
  24. WD_FIND_STOP = 0
  25. WD_COLLAPSE_START = 1
  26. WD_FORMAT_DOCX = 16
  27. WD_EXPORT_PDF = 17
  28. WD_HEADER_FOOTER_PRIMARY = 1
  29. WD_STAT_PAGES = 2
  30. # 占位标记 → (待插入文件, 分节方式)
  31. # 承诺书、一览表、团队表前面已有带段前分页的组织标题,用「连续」分节符可与标题
  32. # 同页,不再多出一页只有标题的空页;各章无组织标题,用「下一页」分节符另起页。
  33. PARTS = [
  34. ("invoice", "承诺书-能够提供增值税专用发票.docx", "cont"),
  35. ("consortium", "承诺书-不组成联合体及中选后不转包.docx", "cont"),
  36. ("credit", "承诺书-资格条件及信用状况.docx", "cont"),
  37. ("swtable", "软件著作权登记证书一览表.docx", "cont"),
  38. ("team", "项目团队岗位职责与人员配备表.docx", "cont"),
  39. ]
  40. for _no in range(4, 13):
  41. _hit = glob.glob(os.path.join(MERGE_DIR, f"六3-{_no:02d} *.docx"))
  42. PARTS.append((f"ch{_no:02d}",
  43. os.path.join("_bid_merge_parts", os.path.basename(_hit[0]))
  44. if _hit else None, "next"))
  45. def resolve():
  46. out = []
  47. for key, rel, brk in PARTS:
  48. if rel is None:
  49. raise SystemExit(f"缺少 {key} 对应文件(_bid_merge_parts 未生成?)")
  50. path = os.path.join(HERE, rel)
  51. if not os.path.exists(path):
  52. raise SystemExit(f"缺少待并入文件:{rel}")
  53. out.append((key, path, brk))
  54. return out
  55. def response_doc():
  56. hits = glob.glob(os.path.join(HERE, "响应文件-*研发项目*.docx"))
  57. if not hits:
  58. raise SystemExit("未找到响应文件,请先运行 _gen_bid_response.py")
  59. return hits[0]
  60. def insert_at_marker(word, doc, key, path, brk):
  61. """定位 @@MERGE:key@@ 所在段落,整段删除后在原位插入外部文件。
  62. 必须先插分节符:被插入文件自带的节属性(页边距、横向页)会作用到它之前的
  63. 内容,不隔开会把响应文件原有版式一起改掉。标记段整段删除,否则残留的空段
  64. 落会各自占掉一页。
  65. """
  66. sel = word.Selection
  67. sel.HomeKey(WD_STORY)
  68. f = sel.Find
  69. f.ClearFormatting()
  70. f.Text = f"@@MERGE:{key}@@"
  71. f.Forward = True
  72. f.Wrap = WD_FIND_STOP
  73. f.MatchCase = True
  74. if not f.Execute():
  75. raise SystemExit(f"文档中找不到占位标记 {key}")
  76. sel.Paragraphs(1).Range.Select()
  77. sel.Delete()
  78. sel.InsertBreak(WD_SECTION_BREAK_CONTINUOUS if brk == "cont"
  79. else WD_SECTION_BREAK_NEXT_PAGE)
  80. sel.InsertFile(path)
  81. return True
  82. def unify_page_numbers(doc):
  83. """全册连续编号:各节页脚继承首节,且除首节外都不重启编号。"""
  84. for i in range(2, doc.Sections.Count + 1):
  85. sec = doc.Sections(i)
  86. ft = sec.Footers(WD_HEADER_FOOTER_PRIMARY)
  87. ft.LinkToPrevious = True
  88. ft.PageNumbers.RestartNumberingAtSection = False
  89. pn = doc.Sections(1).Footers(WD_HEADER_FOOTER_PRIMARY).PageNumbers
  90. pn.RestartNumberingAtSection = True
  91. pn.StartingNumber = 1
  92. def main():
  93. parts = resolve()
  94. if "--list" in sys.argv:
  95. for key, path, brk in parts:
  96. print(f" {key:<10} [{brk}] → {os.path.relpath(path, HERE)}")
  97. return
  98. src = response_doc()
  99. out = os.path.join(HERE, "响应文件(完整版)-"
  100. "2026年河南产互联制造业数据智能运营平台研发项目.docx")
  101. pdf = os.path.join(HERE, "_bid_preview", os.path.basename(out)[:-5] + ".pdf")
  102. os.makedirs(os.path.dirname(pdf), exist_ok=True)
  103. for f in (out, pdf):
  104. if os.path.exists(f):
  105. os.remove(f)
  106. shutil.copy2(src, out)
  107. import win32com.client as win32
  108. word = None
  109. for attempt in range(4):
  110. try:
  111. word = win32.gencache.EnsureDispatch("Word.Application")
  112. word.Visible = False
  113. word.DisplayAlerts = 0
  114. break
  115. except Exception as e: # Word COM 偶发拒绝调用
  116. print(f" 启动 Word 重试 {attempt + 1}/4:{e}")
  117. time.sleep(4)
  118. if word is None:
  119. raise SystemExit("无法启动 Word")
  120. try:
  121. doc = word.Documents.Open(out)
  122. doc.Activate()
  123. for key, path, brk in parts:
  124. insert_at_marker(word, doc, key, path, brk)
  125. print(f" 已并入 {key:<10} {os.path.basename(path)}")
  126. unify_page_numbers(doc)
  127. for _ in range(3): # 目录页数变化会反过来影响页码,多刷几遍
  128. if doc.TablesOfContents.Count:
  129. doc.TablesOfContents(1).Update()
  130. doc.Fields.Update()
  131. doc.Repaginate()
  132. pages = doc.ComputeStatistics(WD_STAT_PAGES)
  133. toc_items = (doc.TablesOfContents(1).Range.Paragraphs.Count
  134. if doc.TablesOfContents.Count else 0)
  135. secs = doc.Sections.Count
  136. doc.SaveAs2(out, FileFormat=WD_FORMAT_DOCX)
  137. doc.ExportAsFixedFormat(pdf, WD_EXPORT_PDF)
  138. doc.Close(False)
  139. finally:
  140. word.Quit()
  141. size = os.path.getsize(out) / 1024 / 1024
  142. print(f"\n[完成] {os.path.basename(out)}")
  143. print(f" {pages} 页 {secs} 节 目录 {toc_items} 条 {size:.1f} MB")
  144. if os.path.isdir(SYNC_DIR):
  145. dst = os.path.join(SYNC_DIR, os.path.basename(out))
  146. # 交付目录里的总册会被手工补材料(业绩扫描件、查询截图等)。若那份比本次
  147. # 生成的更新,直接覆盖会丢掉手工内容,故先备份再同步。
  148. if os.path.exists(dst) and os.path.getmtime(dst) > os.path.getmtime(src):
  149. bak = dst[:-5] + time.strftime("_手工修改备份_%m%d%H%M") + ".docx"
  150. shutil.copy2(dst, bak)
  151. print(f"[注意] 交付目录那份比生成源更新,疑似手工改过,已备份为\n"
  152. f" {os.path.basename(bak)}")
  153. shutil.copy2(out, dst)
  154. print(f"[已同步] {SYNC_DIR}")
  155. if __name__ == "__main__":
  156. main()