# -*- coding: utf-8 -*- """技术响应文件(附件五,格式自拟)Word 排版工具。 统一版式:A4、宋体小四正文、黑体各级标题、图表居中并自动编号。 内容侧只描述结构(h2/p/ul/fig/table),排版细节全部收敛在这里。 """ import os import re from docx import Document from docx.enum.section import WD_ORIENT, WD_SECTION from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Cm, Pt, RGBColor from PIL import Image HERE = os.path.dirname(os.path.abspath(__file__)) ASSETS = os.path.join(HERE, "_bid_assets") SONG = "宋体" HEI = "黑体" LATIN = "Times New Roman" BODY_PT = 12.0 # 小四 CAP_PT = 10.5 # 五号 TBL_PT = 9.0 # 版面几何随当前节(纵向/横向)切换,图与表都按当前正文宽度排版 PORTRAIT = {"w": 14.66, "max_fig_h": 19.0, "full_fig_h": 22.5} LANDSCAPE = {"w": 26.70, "max_fig_h": 12.6, "full_fig_h": 17.2} GEO = dict(PORTRAIT) HEAD_FILL = "DDEBF7" TITLE_C = RGBColor(0x1F, 0x4E, 0x79) BLACK = RGBColor(0, 0, 0) # ------------------------------------------------------------------ 基础 def _font(run, name_cn, size_pt, bold=False, color=None, italic=False): run.font.size = Pt(size_pt) run.font.bold = bold run.font.italic = italic run.font.name = LATIN if color is not None: run.font.color.rgb = color rPr = run._element.get_or_add_rPr() rf = rPr.find(qn("w:rFonts")) if rf is None: rf = OxmlElement("w:rFonts") rPr.insert(0, rf) rf.set(qn("w:ascii"), LATIN) rf.set(qn("w:hAnsi"), LATIN) rf.set(qn("w:eastAsia"), name_cn) def _spacing(p, before=0, after=0, line=1.5, first_indent=0.0): pf = p.paragraph_format pf.space_before = Pt(before) pf.space_after = Pt(after) pf.line_spacing = line if first_indent: pf.first_line_indent = Pt(first_indent) def new_doc(): doc = Document() sec = doc.sections[0] sec.page_width = Cm(21.0) sec.page_height = Cm(29.7) sec.top_margin = Cm(2.54) sec.bottom_margin = Cm(2.54) sec.left_margin = Cm(3.17) sec.right_margin = Cm(3.17) st = doc.styles["Normal"] st.font.size = Pt(BODY_PT) st.font.name = LATIN st.element.rPr.rFonts.set(qn("w:eastAsia"), SONG) _add_page_footer(sec) return doc def _add_page_footer(sec): # 新节默认沿用上一节页脚,若不断开会把页码域重复追加到同一段落 sec.footer.is_linked_to_previous = False ftr = sec.footer for extra in list(ftr.paragraphs[1:]): extra._p.getparent().remove(extra._p) p = ftr.paragraphs[0] if ftr.paragraphs else ftr.add_paragraph() for r in list(p.runs): r._element.getparent().remove(r._element) p.alignment = WD_ALIGN_PARAGRAPH.CENTER for kind, payload in (("begin", None), ("instr", " PAGE \\* MERGEFORMAT "), ("separate", None), ("text", "1"), ("end", None)): r = OxmlElement("w:r") if kind == "instr": it = OxmlElement("w:instrText") it.set(qn("xml:space"), "preserve") it.text = payload r.append(it) elif kind == "text": t = OxmlElement("w:t") t.text = payload r.append(t) else: fc = OxmlElement("w:fldChar") fc.set(qn("w:fldCharType"), kind) r.append(fc) p._p.append(r) for run in p.runs: _font(run, SONG, CAP_PT) # ---------------------------------------------------------- 合并入册时的改号 # 各章单独成文时编号自成一套(第 1 章 → 1.1、图 1-1);并入响应文件后要挂到 # 「六(三)」下重新排序,故提供整章改号与大纲级别下移两个开关。 RENUM = None # (旧章号, 新章号) OUTLINE = None # {标题级别: 大纲级别值},值为 0 起算 def set_renum(old, new): global RENUM RENUM = None if old is None else (int(old), int(new)) def set_outline(mapping): global OUTLINE OUTLINE = mapping def _renum_head(text): """小节标题的首段编号:1.2.3 → 4.2.3。只改开头,不碰标题里的其它数字。""" if not RENUM: return text old, new = RENUM m = re.match(rf"^{old}((?:\.\d+)*)(?=[\s ]|$)", text) return f"{new}{m.group(1)}{text[m.end():]}" if m else text def _renum_body(text): """正文里的交叉引用「详见 1.8.2 节」→「详见 4.8.2 节」。 只在「见」字之后替换,避免误伤版本号、倍数、金额等小数。 """ if not RENUM: return text old, new = RENUM return re.sub(rf"(?<=见)(\s*){old}((?:\.\d+)+)", lambda m: f"{m.group(1)}{new}{m.group(2)}", text) # ------------------------------------------------------------------ 段落 def _heading(doc, text, level, name_cn, size_pt, color, bold=False, before=0, after=0, center=False): """单章成文时套内置 Heading 样式,供 Word 目录域识别; 外观全部用行内格式锁死,避免继承样式自带的蓝色、斜体与字体。 并入总册时(OUTLINE 生效)改用 Normal 样式 + 显式大纲级别:目录域的 \\o 开关按「内置标题样式」取条目并以样式级别为准,会无视行内的大纲级别覆盖, 致使章内三级标题也被收进总目录;去掉样式后全册统一由大纲级别决定层级。 """ p = doc.add_paragraph(style="Normal" if OUTLINE else f"Heading {level}") if center: p.alignment = WD_ALIGN_PARAGRAPH.CENTER _spacing(p, before=before, after=after, line=1.5) p.paragraph_format.keep_with_next = True if OUTLINE and level in OUTLINE: pPr = p._p.get_or_add_pPr() old = pPr.find(qn("w:outlineLvl")) if old is not None: pPr.remove(old) ol = OxmlElement("w:outlineLvl") ol.set(qn("w:val"), str(OUTLINE[level])) pPr.append(ol) _font(p.add_run(_renum_head(text)), name_cn, size_pt, bold=bold, color=color, italic=False) return p def h1(doc, text): return _heading(doc, text, 1, HEI, 16, TITLE_C, before=6, after=16, center=True) def h2(doc, text): return _heading(doc, text, 2, HEI, 14, TITLE_C, before=14, after=8) def h3(doc, text): return _heading(doc, text, 3, HEI, 12, BLACK, before=10, after=6) def h4(doc, text): return _heading(doc, text, 4, SONG, BODY_PT, BLACK, bold=True, before=8, after=4) def para(doc, text, indent=True): p = doc.add_paragraph() _spacing(p, after=4, line=1.5, first_indent=BODY_PT * 2 if indent else 0) _font(p.add_run(_renum_body(text)), SONG, BODY_PT) return p def bullets(doc, items): """无编号要点:用「—」引导,左缩进 2 字符,避免依赖 Word 列表样式。""" for it in items: p = doc.add_paragraph() _spacing(p, after=3, line=1.5) p.paragraph_format.left_indent = Pt(BODY_PT * 2) p.paragraph_format.first_line_indent = Pt(-BODY_PT) _font(p.add_run("— " + it), SONG, BODY_PT) def numbered(doc, items): for i, it in enumerate(items, 1): p = doc.add_paragraph() _spacing(p, after=3, line=1.5) p.paragraph_format.left_indent = Pt(BODY_PT * 2) p.paragraph_format.first_line_indent = Pt(-BODY_PT * 2) _font(p.add_run(f"({i}){it}"), SONG, BODY_PT) # ------------------------------------------------------------------ 图 class Counter: def __init__(self, chapter): self.chapter = chapter self.fig = 0 self.tbl = 0 def next_fig(self): self.fig += 1 return f"图 {self.chapter}-{self.fig}" def next_tbl(self): self.tbl += 1 return f"表 {self.chapter}-{self.tbl}" def figure(doc, cnt, name, caption, full_page=False, max_h=None): """full_page:图另起一页单独放,用于纵横比接近正方、缩到正文流里会看不清的图。 max_h:单独指定本图高度上限(厘米),用于界面截图这类必须放大才可辨认的图。 """ path = os.path.join(ASSETS, name if name.endswith(".png") else name + ".png") if not os.path.exists(path): raise FileNotFoundError(path) if full_page: doc.add_page_break() w_px, h_px = Image.open(path).size limit = max_h or (GEO["full_fig_h"] if full_page else GEO["max_fig_h"]) w_cm = GEO["w"] h_cm = w_cm * h_px / w_px if h_cm > limit: h_cm = limit w_cm = h_cm * w_px / h_px p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.CENTER _spacing(p, before=5, after=1, line=1.0) p.add_run().add_picture(path, width=Cm(w_cm)) cp = doc.add_paragraph() cp.alignment = WD_ALIGN_PARAGRAPH.CENTER _spacing(cp, after=8, line=1.0) _font(cp.add_run(f"{cnt.next_fig()} {caption}"), SONG, CAP_PT) # ------------------------------------------------------------------ 表 def _shade(cell, hexcolor): tcPr = cell._tc.get_or_add_tcPr() sh = OxmlElement("w:shd") sh.set(qn("w:val"), "clear") sh.set(qn("w:fill"), hexcolor) tcPr.append(sh) def _fixed_layout(tb): tblPr = tb._tbl.tblPr el = OxmlElement("w:tblLayout") el.set(qn("w:type"), "fixed") tblPr.append(el) def _repeat_header(row): """跨页时自动重复表头行。""" trPr = row._tr.get_or_add_trPr() el = OxmlElement("w:tblHeader") el.set(qn("w:val"), "true") trPr.append(el) def table(doc, cnt, caption, headers, rows, widths=None, align=None): """widths:各列占比(会归一化到正文宽度);align:每列 'l'/'c'。""" cp = doc.add_paragraph() cp.alignment = WD_ALIGN_PARAGRAPH.CENTER _spacing(cp, before=10, after=3, line=1.0) _font(cp.add_run(f"{cnt.next_tbl()} {caption}"), SONG, CAP_PT) n = len(headers) tb = doc.add_table(rows=1, cols=n) tb.style = "Table Grid" tb.alignment = WD_TABLE_ALIGNMENT.CENTER tb.autofit = False _fixed_layout(tb) widths = widths or [1] * n total = float(sum(widths)) cols_cm = [GEO["w"] * w / total for w in widths] align = align or (["c"] + ["l"] * (n - 1)) def put(cell, text, is_head, col): cell.width = Cm(cols_cm[col]) p = cell.paragraphs[0] _spacing(p, before=2, after=2, line=1.15) p.alignment = (WD_ALIGN_PARAGRAPH.CENTER if is_head or align[col] == "c" else WD_ALIGN_PARAGRAPH.LEFT) _font(p.add_run(str(text)), HEI if is_head else SONG, TBL_PT, bold=False) if is_head: _shade(cell, HEAD_FILL) for j, htxt in enumerate(headers): put(tb.rows[0].cells[j], htxt, True, j) _repeat_header(tb.rows[0]) for r in rows: cells = tb.add_row().cells for j, v in enumerate(r): put(cells[j], v, False, j) for j, cm in enumerate(cols_cm): for row in tb.rows: row.cells[j].width = Cm(cm) doc.add_paragraph().paragraph_format.space_after = Pt(6) return tb def page_break(doc): doc.add_page_break() def landscape_section(doc): """切换到横向节:架构图信息密度高,纵向 A4 上字号会小到看不清。""" global GEO sec = doc.add_section(WD_SECTION.NEW_PAGE) sec.orientation = WD_ORIENT.LANDSCAPE sec.page_width, sec.page_height = Cm(29.7), Cm(21.0) sec.top_margin = sec.bottom_margin = Cm(1.5) sec.left_margin = sec.right_margin = Cm(1.5) _add_page_footer(sec) GEO = dict(LANDSCAPE) return sec def portrait_section(doc): global GEO sec = doc.add_section(WD_SECTION.NEW_PAGE) sec.orientation = WD_ORIENT.PORTRAIT sec.page_width, sec.page_height = Cm(21.0), Cm(29.7) sec.top_margin = sec.bottom_margin = Cm(2.54) sec.left_margin = sec.right_margin = Cm(3.17) _add_page_footer(sec) GEO = dict(PORTRAIT) return sec def render(doc, cnt, blocks): """按内容块列表渲染。块形如 ("h2", "标题") / ("fig", (name, caption)) 等。""" for kind, payload in blocks: if kind == "h2": h2(doc, payload) elif kind == "h3": h3(doc, payload) elif kind == "h4": h4(doc, payload) elif kind == "p": para(doc, payload) elif kind == "ul": bullets(doc, payload) elif kind == "ol": numbered(doc, payload) elif kind == "fig": max_h = payload[2] if len(payload) > 2 else None figure(doc, cnt, payload[0], payload[1], max_h=max_h) elif kind == "figfull": figure(doc, cnt, payload[0], payload[1], full_page=True) elif kind == "table": cap, headers, rows = payload[0], payload[1], payload[2] widths = payload[3] if len(payload) > 3 else None align = payload[4] if len(payload) > 4 else None table(doc, cnt, cap, headers, rows, widths, align) elif kind == "pagebreak": page_break(doc) elif kind == "landscape": landscape_section(doc) elif kind == "portrait": portrait_section(doc) else: raise ValueError(f"未知内容块:{kind}")