| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- # -*- 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}")
|