_bid_docx_kit.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. # -*- coding: utf-8 -*-
  2. """技术响应文件(附件五,格式自拟)Word 排版工具。
  3. 统一版式:A4、宋体小四正文、黑体各级标题、图表居中并自动编号。
  4. 内容侧只描述结构(h2/p/ul/fig/table),排版细节全部收敛在这里。
  5. """
  6. import os
  7. import re
  8. from docx import Document
  9. from docx.enum.section import WD_ORIENT, WD_SECTION
  10. from docx.enum.table import WD_TABLE_ALIGNMENT
  11. from docx.enum.text import WD_ALIGN_PARAGRAPH
  12. from docx.oxml import OxmlElement
  13. from docx.oxml.ns import qn
  14. from docx.shared import Cm, Pt, RGBColor
  15. from PIL import Image
  16. HERE = os.path.dirname(os.path.abspath(__file__))
  17. ASSETS = os.path.join(HERE, "_bid_assets")
  18. SONG = "宋体"
  19. HEI = "黑体"
  20. LATIN = "Times New Roman"
  21. BODY_PT = 12.0 # 小四
  22. CAP_PT = 10.5 # 五号
  23. TBL_PT = 9.0
  24. # 版面几何随当前节(纵向/横向)切换,图与表都按当前正文宽度排版
  25. PORTRAIT = {"w": 14.66, "max_fig_h": 19.0, "full_fig_h": 22.5}
  26. LANDSCAPE = {"w": 26.70, "max_fig_h": 12.6, "full_fig_h": 17.2}
  27. GEO = dict(PORTRAIT)
  28. HEAD_FILL = "DDEBF7"
  29. TITLE_C = RGBColor(0x1F, 0x4E, 0x79)
  30. BLACK = RGBColor(0, 0, 0)
  31. # ------------------------------------------------------------------ 基础
  32. def _font(run, name_cn, size_pt, bold=False, color=None, italic=False):
  33. run.font.size = Pt(size_pt)
  34. run.font.bold = bold
  35. run.font.italic = italic
  36. run.font.name = LATIN
  37. if color is not None:
  38. run.font.color.rgb = color
  39. rPr = run._element.get_or_add_rPr()
  40. rf = rPr.find(qn("w:rFonts"))
  41. if rf is None:
  42. rf = OxmlElement("w:rFonts")
  43. rPr.insert(0, rf)
  44. rf.set(qn("w:ascii"), LATIN)
  45. rf.set(qn("w:hAnsi"), LATIN)
  46. rf.set(qn("w:eastAsia"), name_cn)
  47. def _spacing(p, before=0, after=0, line=1.5, first_indent=0.0):
  48. pf = p.paragraph_format
  49. pf.space_before = Pt(before)
  50. pf.space_after = Pt(after)
  51. pf.line_spacing = line
  52. if first_indent:
  53. pf.first_line_indent = Pt(first_indent)
  54. def new_doc():
  55. doc = Document()
  56. sec = doc.sections[0]
  57. sec.page_width = Cm(21.0)
  58. sec.page_height = Cm(29.7)
  59. sec.top_margin = Cm(2.54)
  60. sec.bottom_margin = Cm(2.54)
  61. sec.left_margin = Cm(3.17)
  62. sec.right_margin = Cm(3.17)
  63. st = doc.styles["Normal"]
  64. st.font.size = Pt(BODY_PT)
  65. st.font.name = LATIN
  66. st.element.rPr.rFonts.set(qn("w:eastAsia"), SONG)
  67. _add_page_footer(sec)
  68. return doc
  69. def _add_page_footer(sec):
  70. # 新节默认沿用上一节页脚,若不断开会把页码域重复追加到同一段落
  71. sec.footer.is_linked_to_previous = False
  72. ftr = sec.footer
  73. for extra in list(ftr.paragraphs[1:]):
  74. extra._p.getparent().remove(extra._p)
  75. p = ftr.paragraphs[0] if ftr.paragraphs else ftr.add_paragraph()
  76. for r in list(p.runs):
  77. r._element.getparent().remove(r._element)
  78. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  79. for kind, payload in (("begin", None), ("instr", " PAGE \\* MERGEFORMAT "),
  80. ("separate", None), ("text", "1"), ("end", None)):
  81. r = OxmlElement("w:r")
  82. if kind == "instr":
  83. it = OxmlElement("w:instrText")
  84. it.set(qn("xml:space"), "preserve")
  85. it.text = payload
  86. r.append(it)
  87. elif kind == "text":
  88. t = OxmlElement("w:t")
  89. t.text = payload
  90. r.append(t)
  91. else:
  92. fc = OxmlElement("w:fldChar")
  93. fc.set(qn("w:fldCharType"), kind)
  94. r.append(fc)
  95. p._p.append(r)
  96. for run in p.runs:
  97. _font(run, SONG, CAP_PT)
  98. # ---------------------------------------------------------- 合并入册时的改号
  99. # 各章单独成文时编号自成一套(第 1 章 → 1.1、图 1-1);并入响应文件后要挂到
  100. # 「六(三)」下重新排序,故提供整章改号与大纲级别下移两个开关。
  101. RENUM = None # (旧章号, 新章号)
  102. OUTLINE = None # {标题级别: 大纲级别值},值为 0 起算
  103. def set_renum(old, new):
  104. global RENUM
  105. RENUM = None if old is None else (int(old), int(new))
  106. def set_outline(mapping):
  107. global OUTLINE
  108. OUTLINE = mapping
  109. def _renum_head(text):
  110. """小节标题的首段编号:1.2.3 → 4.2.3。只改开头,不碰标题里的其它数字。"""
  111. if not RENUM:
  112. return text
  113. old, new = RENUM
  114. m = re.match(rf"^{old}((?:\.\d+)*)(?=[\s ]|$)", text)
  115. return f"{new}{m.group(1)}{text[m.end():]}" if m else text
  116. def _renum_body(text):
  117. """正文里的交叉引用「详见 1.8.2 节」→「详见 4.8.2 节」。
  118. 只在「见」字之后替换,避免误伤版本号、倍数、金额等小数。
  119. """
  120. if not RENUM:
  121. return text
  122. old, new = RENUM
  123. return re.sub(rf"(?<=见)(\s*){old}((?:\.\d+)+)",
  124. lambda m: f"{m.group(1)}{new}{m.group(2)}", text)
  125. # ------------------------------------------------------------------ 段落
  126. def _heading(doc, text, level, name_cn, size_pt, color, bold=False,
  127. before=0, after=0, center=False):
  128. """单章成文时套内置 Heading 样式,供 Word 目录域识别;
  129. 外观全部用行内格式锁死,避免继承样式自带的蓝色、斜体与字体。
  130. 并入总册时(OUTLINE 生效)改用 Normal 样式 + 显式大纲级别:目录域的 \\o
  131. 开关按「内置标题样式」取条目并以样式级别为准,会无视行内的大纲级别覆盖,
  132. 致使章内三级标题也被收进总目录;去掉样式后全册统一由大纲级别决定层级。
  133. """
  134. p = doc.add_paragraph(style="Normal" if OUTLINE else f"Heading {level}")
  135. if center:
  136. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  137. _spacing(p, before=before, after=after, line=1.5)
  138. p.paragraph_format.keep_with_next = True
  139. if OUTLINE and level in OUTLINE:
  140. pPr = p._p.get_or_add_pPr()
  141. old = pPr.find(qn("w:outlineLvl"))
  142. if old is not None:
  143. pPr.remove(old)
  144. ol = OxmlElement("w:outlineLvl")
  145. ol.set(qn("w:val"), str(OUTLINE[level]))
  146. pPr.append(ol)
  147. _font(p.add_run(_renum_head(text)), name_cn, size_pt, bold=bold,
  148. color=color, italic=False)
  149. return p
  150. def h1(doc, text):
  151. return _heading(doc, text, 1, HEI, 16, TITLE_C,
  152. before=6, after=16, center=True)
  153. def h2(doc, text):
  154. return _heading(doc, text, 2, HEI, 14, TITLE_C, before=14, after=8)
  155. def h3(doc, text):
  156. return _heading(doc, text, 3, HEI, 12, BLACK, before=10, after=6)
  157. def h4(doc, text):
  158. return _heading(doc, text, 4, SONG, BODY_PT, BLACK, bold=True,
  159. before=8, after=4)
  160. def para(doc, text, indent=True):
  161. p = doc.add_paragraph()
  162. _spacing(p, after=4, line=1.5, first_indent=BODY_PT * 2 if indent else 0)
  163. _font(p.add_run(_renum_body(text)), SONG, BODY_PT)
  164. return p
  165. def bullets(doc, items):
  166. """无编号要点:用「—」引导,左缩进 2 字符,避免依赖 Word 列表样式。"""
  167. for it in items:
  168. p = doc.add_paragraph()
  169. _spacing(p, after=3, line=1.5)
  170. p.paragraph_format.left_indent = Pt(BODY_PT * 2)
  171. p.paragraph_format.first_line_indent = Pt(-BODY_PT)
  172. _font(p.add_run("— " + it), SONG, BODY_PT)
  173. def numbered(doc, items):
  174. for i, it in enumerate(items, 1):
  175. p = doc.add_paragraph()
  176. _spacing(p, after=3, line=1.5)
  177. p.paragraph_format.left_indent = Pt(BODY_PT * 2)
  178. p.paragraph_format.first_line_indent = Pt(-BODY_PT * 2)
  179. _font(p.add_run(f"({i}){it}"), SONG, BODY_PT)
  180. # ------------------------------------------------------------------ 图
  181. class Counter:
  182. def __init__(self, chapter):
  183. self.chapter = chapter
  184. self.fig = 0
  185. self.tbl = 0
  186. def next_fig(self):
  187. self.fig += 1
  188. return f"图 {self.chapter}-{self.fig}"
  189. def next_tbl(self):
  190. self.tbl += 1
  191. return f"表 {self.chapter}-{self.tbl}"
  192. def figure(doc, cnt, name, caption, full_page=False, max_h=None):
  193. """full_page:图另起一页单独放,用于纵横比接近正方、缩到正文流里会看不清的图。
  194. max_h:单独指定本图高度上限(厘米),用于界面截图这类必须放大才可辨认的图。
  195. """
  196. path = os.path.join(ASSETS, name if name.endswith(".png") else name + ".png")
  197. if not os.path.exists(path):
  198. raise FileNotFoundError(path)
  199. if full_page:
  200. doc.add_page_break()
  201. w_px, h_px = Image.open(path).size
  202. limit = max_h or (GEO["full_fig_h"] if full_page else GEO["max_fig_h"])
  203. w_cm = GEO["w"]
  204. h_cm = w_cm * h_px / w_px
  205. if h_cm > limit:
  206. h_cm = limit
  207. w_cm = h_cm * w_px / h_px
  208. p = doc.add_paragraph()
  209. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  210. _spacing(p, before=5, after=1, line=1.0)
  211. p.add_run().add_picture(path, width=Cm(w_cm))
  212. cp = doc.add_paragraph()
  213. cp.alignment = WD_ALIGN_PARAGRAPH.CENTER
  214. _spacing(cp, after=8, line=1.0)
  215. _font(cp.add_run(f"{cnt.next_fig()} {caption}"), SONG, CAP_PT)
  216. # ------------------------------------------------------------------ 表
  217. def _shade(cell, hexcolor):
  218. tcPr = cell._tc.get_or_add_tcPr()
  219. sh = OxmlElement("w:shd")
  220. sh.set(qn("w:val"), "clear")
  221. sh.set(qn("w:fill"), hexcolor)
  222. tcPr.append(sh)
  223. def _fixed_layout(tb):
  224. tblPr = tb._tbl.tblPr
  225. el = OxmlElement("w:tblLayout")
  226. el.set(qn("w:type"), "fixed")
  227. tblPr.append(el)
  228. def _repeat_header(row):
  229. """跨页时自动重复表头行。"""
  230. trPr = row._tr.get_or_add_trPr()
  231. el = OxmlElement("w:tblHeader")
  232. el.set(qn("w:val"), "true")
  233. trPr.append(el)
  234. def table(doc, cnt, caption, headers, rows, widths=None, align=None):
  235. """widths:各列占比(会归一化到正文宽度);align:每列 'l'/'c'。"""
  236. cp = doc.add_paragraph()
  237. cp.alignment = WD_ALIGN_PARAGRAPH.CENTER
  238. _spacing(cp, before=10, after=3, line=1.0)
  239. _font(cp.add_run(f"{cnt.next_tbl()} {caption}"), SONG, CAP_PT)
  240. n = len(headers)
  241. tb = doc.add_table(rows=1, cols=n)
  242. tb.style = "Table Grid"
  243. tb.alignment = WD_TABLE_ALIGNMENT.CENTER
  244. tb.autofit = False
  245. _fixed_layout(tb)
  246. widths = widths or [1] * n
  247. total = float(sum(widths))
  248. cols_cm = [GEO["w"] * w / total for w in widths]
  249. align = align or (["c"] + ["l"] * (n - 1))
  250. def put(cell, text, is_head, col):
  251. cell.width = Cm(cols_cm[col])
  252. p = cell.paragraphs[0]
  253. _spacing(p, before=2, after=2, line=1.15)
  254. p.alignment = (WD_ALIGN_PARAGRAPH.CENTER if is_head or align[col] == "c"
  255. else WD_ALIGN_PARAGRAPH.LEFT)
  256. _font(p.add_run(str(text)), HEI if is_head else SONG, TBL_PT,
  257. bold=False)
  258. if is_head:
  259. _shade(cell, HEAD_FILL)
  260. for j, htxt in enumerate(headers):
  261. put(tb.rows[0].cells[j], htxt, True, j)
  262. _repeat_header(tb.rows[0])
  263. for r in rows:
  264. cells = tb.add_row().cells
  265. for j, v in enumerate(r):
  266. put(cells[j], v, False, j)
  267. for j, cm in enumerate(cols_cm):
  268. for row in tb.rows:
  269. row.cells[j].width = Cm(cm)
  270. doc.add_paragraph().paragraph_format.space_after = Pt(6)
  271. return tb
  272. def page_break(doc):
  273. doc.add_page_break()
  274. def landscape_section(doc):
  275. """切换到横向节:架构图信息密度高,纵向 A4 上字号会小到看不清。"""
  276. global GEO
  277. sec = doc.add_section(WD_SECTION.NEW_PAGE)
  278. sec.orientation = WD_ORIENT.LANDSCAPE
  279. sec.page_width, sec.page_height = Cm(29.7), Cm(21.0)
  280. sec.top_margin = sec.bottom_margin = Cm(1.5)
  281. sec.left_margin = sec.right_margin = Cm(1.5)
  282. _add_page_footer(sec)
  283. GEO = dict(LANDSCAPE)
  284. return sec
  285. def portrait_section(doc):
  286. global GEO
  287. sec = doc.add_section(WD_SECTION.NEW_PAGE)
  288. sec.orientation = WD_ORIENT.PORTRAIT
  289. sec.page_width, sec.page_height = Cm(21.0), Cm(29.7)
  290. sec.top_margin = sec.bottom_margin = Cm(2.54)
  291. sec.left_margin = sec.right_margin = Cm(3.17)
  292. _add_page_footer(sec)
  293. GEO = dict(PORTRAIT)
  294. return sec
  295. def render(doc, cnt, blocks):
  296. """按内容块列表渲染。块形如 ("h2", "标题") / ("fig", (name, caption)) 等。"""
  297. for kind, payload in blocks:
  298. if kind == "h2":
  299. h2(doc, payload)
  300. elif kind == "h3":
  301. h3(doc, payload)
  302. elif kind == "h4":
  303. h4(doc, payload)
  304. elif kind == "p":
  305. para(doc, payload)
  306. elif kind == "ul":
  307. bullets(doc, payload)
  308. elif kind == "ol":
  309. numbered(doc, payload)
  310. elif kind == "fig":
  311. max_h = payload[2] if len(payload) > 2 else None
  312. figure(doc, cnt, payload[0], payload[1], max_h=max_h)
  313. elif kind == "figfull":
  314. figure(doc, cnt, payload[0], payload[1], full_page=True)
  315. elif kind == "table":
  316. cap, headers, rows = payload[0], payload[1], payload[2]
  317. widths = payload[3] if len(payload) > 3 else None
  318. align = payload[4] if len(payload) > 4 else None
  319. table(doc, cnt, cap, headers, rows, widths, align)
  320. elif kind == "pagebreak":
  321. page_break(doc)
  322. elif kind == "landscape":
  323. landscape_section(doc)
  324. elif kind == "portrait":
  325. portrait_section(doc)
  326. else:
  327. raise ValueError(f"未知内容块:{kind}")