|
|
@@ -0,0 +1,836 @@
|
|
|
+"""
|
|
|
+根据 bid_fill_draft.json(正文真源)+ sq_bid_outline.json(结构真源)
|
|
|
+生成投标文档 docx。
|
|
|
+
|
|
|
+API 用法:
|
|
|
+ from scripts.generate_docx import generate_bid_docx
|
|
|
+
|
|
|
+ # 方式 1: 传文件路径,自动保存
|
|
|
+ generate_bid_docx(
|
|
|
+ bid_fill_draft="data/bid_fill_draft.json",
|
|
|
+ sq_bid_outline="data/sq_bid_outline.json",
|
|
|
+ project_name="包件一 松江区办公中心物业管理服务",
|
|
|
+ header_label="商务标",
|
|
|
+ output_path="output/标书.docx",
|
|
|
+ )
|
|
|
+
|
|
|
+ # 方式 2: 传已解析的 dict,返回 Document 对象
|
|
|
+ doc = generate_bid_docx(
|
|
|
+ bid_fill_draft=json.load(open("data/bid_fill_draft.json")),
|
|
|
+ sq_bid_outline=json.load(open("data/sq_bid_outline.json")),
|
|
|
+ )
|
|
|
+
|
|
|
+CLI 用法:
|
|
|
+ python scripts/generate_docx.py [--help]
|
|
|
+
|
|
|
+参考: data/真源解析说明.md
|
|
|
+样式规范: 阶段5 - 生成标书
|
|
|
+"""
|
|
|
+
|
|
|
+import json
|
|
|
+import re
|
|
|
+from pathlib import Path
|
|
|
+from typing import Union
|
|
|
+
|
|
|
+import docx
|
|
|
+from docx import Document
|
|
|
+from docx.shared import Pt, Cm, RGBColor
|
|
|
+from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
|
|
+from docx.enum.table import WD_TABLE_ALIGNMENT
|
|
|
+from docx.oxml.ns import qn
|
|
|
+from docx.oxml import OxmlElement
|
|
|
+
|
|
|
+
|
|
|
+# ── 字体常量 ──────────────────────────────────────────
|
|
|
+
|
|
|
+FONT_SIZE = {"二号": 22, "小三": 15, "四号": 14, "小四": 12, "五号": 10.5}
|
|
|
+
|
|
|
+FONT_SONG = "宋体" # 正文中文字体
|
|
|
+FONT_HEI = "黑体" # 标题中文字体
|
|
|
+FONT_TNR = "Times New Roman" # 数字/英文字体
|
|
|
+
|
|
|
+
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+# 字体工具 — 核心规则:西文 = TNR,东亚 = 宋体/黑体
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+def _set_font(run, ea_font=FONT_SONG, size_pt=12, bold=False, color_rgb=(0, 0, 0)):
|
|
|
+ """
|
|
|
+ 统一设置 run 字体:
|
|
|
+ - 西文(数字/英文)→ Times New Roman
|
|
|
+ - 东亚(中文)→ ea_font 参数(宋体/黑体)
|
|
|
+ - 文字颜色始终黑色
|
|
|
+ - 禁用倾斜
|
|
|
+ """
|
|
|
+ run.font.name = FONT_TNR
|
|
|
+ run.font.element.rPr.rFonts.set(qn('w:eastAsia'), ea_font)
|
|
|
+ run.font.size = Pt(size_pt)
|
|
|
+ run.font.bold = bold
|
|
|
+ if color_rgb:
|
|
|
+ run.font.color.rgb = RGBColor(*color_rgb)
|
|
|
+ run.font.italic = False
|
|
|
+ # 注意: underline 不由这里控制,由调用处按需设置
|
|
|
+
|
|
|
+
|
|
|
+def _apply_style_fonts(rPr, ea_font):
|
|
|
+ """在 rPr 上设置西文=TNR + 东亚=ea_font,并清除 theme 引用"""
|
|
|
+ rFonts = rPr.find(qn('w:rFonts'))
|
|
|
+ if rFonts is None:
|
|
|
+ rFonts = OxmlElement('w:rFonts')
|
|
|
+ rPr.insert(0, rFonts)
|
|
|
+ rFonts.set(qn('w:ascii'), FONT_TNR)
|
|
|
+ rFonts.set(qn('w:hAnsi'), FONT_TNR)
|
|
|
+ rFonts.set(qn('w:eastAsia'), ea_font)
|
|
|
+ # 清除 theme 引用(否则 theme 会覆盖显式字体名)
|
|
|
+ for attr in ['asciiTheme', 'hAnsiTheme', 'eastAsiaTheme', 'cstheme']:
|
|
|
+ key = qn(f'w:{attr}')
|
|
|
+ if key in rFonts.attrib:
|
|
|
+ del rFonts.attrib[key]
|
|
|
+
|
|
|
+
|
|
|
+def _apply_normal_style(style):
|
|
|
+ """设置 Normal 样式基准"""
|
|
|
+ rPr = style.element.get_or_add_rPr()
|
|
|
+ _apply_style_fonts(rPr, FONT_SONG)
|
|
|
+ style.font.size = Pt(FONT_SIZE["小四"])
|
|
|
+ style.font.color.rgb = RGBColor(0, 0, 0)
|
|
|
+ pf = style.paragraph_format
|
|
|
+ pf.space_before = Pt(0)
|
|
|
+ pf.space_after = Pt(0)
|
|
|
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
|
|
+ pf.line_spacing = 1.5
|
|
|
+
|
|
|
+
|
|
|
+def _apply_heading_style(style, size_pt, ea_font, bold):
|
|
|
+ """统一设置标题样式"""
|
|
|
+ rPr = style.element.get_or_add_rPr()
|
|
|
+ _apply_style_fonts(rPr, ea_font)
|
|
|
+ # 字号
|
|
|
+ sz = rPr.find(qn('w:sz'))
|
|
|
+ if sz is None:
|
|
|
+ sz = OxmlElement('w:sz')
|
|
|
+ rPr.append(sz)
|
|
|
+ sz.set(qn('w:val'), str(int(size_pt * 2)))
|
|
|
+ szCs = rPr.find(qn('w:szCs'))
|
|
|
+ if szCs is None:
|
|
|
+ szCs = OxmlElement('w:szCs')
|
|
|
+ rPr.append(szCs)
|
|
|
+ szCs.set(qn('w:val'), str(int(size_pt * 2)))
|
|
|
+ # 加粗
|
|
|
+ b = rPr.find(qn('w:b'))
|
|
|
+ if b is None:
|
|
|
+ b = OxmlElement('w:b')
|
|
|
+ rPr.append(b)
|
|
|
+ bCs = rPr.find(qn('w:bCs'))
|
|
|
+ if bCs is None:
|
|
|
+ bCs = OxmlElement('w:bCs')
|
|
|
+ rPr.append(bCs)
|
|
|
+ # 颜色黑色
|
|
|
+ color = rPr.find(qn('w:color'))
|
|
|
+ if color is None:
|
|
|
+ color = OxmlElement('w:color')
|
|
|
+ rPr.append(color)
|
|
|
+ color.set(qn('w:val'), '000000')
|
|
|
+ # 清除颜色上的 themeColor 引用
|
|
|
+ for attr in ['themeColor', 'themeShade', 'themeTint']:
|
|
|
+ key = qn(f'w:{attr}')
|
|
|
+ if key in color.attrib:
|
|
|
+ del color.attrib[key]
|
|
|
+ # 禁止倾斜
|
|
|
+ i = rPr.find(qn('w:i'))
|
|
|
+ if i is None:
|
|
|
+ i = OxmlElement('w:i')
|
|
|
+ rPr.append(i)
|
|
|
+ i.set(qn('w:val'), '0')
|
|
|
+ # 段落格式
|
|
|
+ pf = style.paragraph_format
|
|
|
+ pf.space_before = Pt(0)
|
|
|
+ pf.space_after = Pt(0)
|
|
|
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
|
|
+ pf.line_spacing = 1.5
|
|
|
+ pf.first_line_indent = Pt(0)
|
|
|
+
|
|
|
+
|
|
|
+def _create_toc_style(styles_element, style_id, name, based_on, ea_font, size_pt, bold):
|
|
|
+ """在文档 styles.xml 中创建 TOC 样式"""
|
|
|
+ # 查找是否已存在(通过迭代避免 XPath 命名空间问题)
|
|
|
+ existing = None
|
|
|
+ for child in styles_element:
|
|
|
+ if child.tag == qn('w:style') and child.get(qn('w:styleId')) == style_id:
|
|
|
+ existing = child
|
|
|
+ break
|
|
|
+ if existing is not None:
|
|
|
+ styles_element.remove(existing)
|
|
|
+
|
|
|
+ style_el = OxmlElement('w:style')
|
|
|
+ style_el.set(qn('w:type'), 'paragraph')
|
|
|
+ style_el.set(qn('w:styleId'), style_id)
|
|
|
+
|
|
|
+ name_el = OxmlElement('w:name')
|
|
|
+ name_el.set(qn('w:val'), name)
|
|
|
+ style_el.append(name_el)
|
|
|
+ if based_on:
|
|
|
+ based_el = OxmlElement('w:basedOn')
|
|
|
+ based_el.set(qn('w:val'), based_on)
|
|
|
+ style_el.append(based_el)
|
|
|
+
|
|
|
+ rPr = OxmlElement('w:rPr')
|
|
|
+ _apply_style_fonts(rPr, ea_font)
|
|
|
+ sz = OxmlElement('w:sz'); sz.set(qn('w:val'), str(int(size_pt * 2))); rPr.append(sz)
|
|
|
+ szCs = OxmlElement('w:szCs'); szCs.set(qn('w:val'), str(int(size_pt * 2))); rPr.append(szCs)
|
|
|
+ if bold:
|
|
|
+ rPr.append(OxmlElement('w:b'))
|
|
|
+ rPr.append(OxmlElement('w:bCs'))
|
|
|
+ color = OxmlElement('w:color'); color.set(qn('w:val'), '000000'); rPr.append(color)
|
|
|
+ style_el.append(rPr)
|
|
|
+ styles_element.append(style_el)
|
|
|
+
|
|
|
+
|
|
|
+# ── 段落布局工具 ──────────────────────────────────────
|
|
|
+
|
|
|
+def _add_empty_para(doc, pt_height=6):
|
|
|
+ p = doc.add_paragraph()
|
|
|
+ pf = p.paragraph_format
|
|
|
+ pf.space_before = Pt(0)
|
|
|
+ pf.space_after = Pt(0)
|
|
|
+ pf.line_spacing = Pt(pt_height)
|
|
|
+ return p
|
|
|
+
|
|
|
+
|
|
|
+def _set_para_spacing(pf, before=0, after=0, line_spacing=1.5):
|
|
|
+ pf.space_before = Pt(before)
|
|
|
+ pf.space_after = Pt(after)
|
|
|
+ pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
|
|
+ pf.line_spacing = line_spacing
|
|
|
+
|
|
|
+
|
|
|
+def _set_first_line_indent(pf, font_size_pt=12, chars=2):
|
|
|
+ pf.first_line_indent = Pt(font_size_pt * chars)
|
|
|
+
|
|
|
+
|
|
|
+# ── 表格边框工具 ──────────────────────────────────────
|
|
|
+
|
|
|
+def _add_table_borders(table):
|
|
|
+ """给表格加细实线边框"""
|
|
|
+ tbl = table._tbl
|
|
|
+ tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
|
|
|
+ borders = OxmlElement('w:tblBorders')
|
|
|
+ for edge in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
|
|
|
+ el = OxmlElement(f'w:{edge}')
|
|
|
+ el.set(qn('w:val'), 'single')
|
|
|
+ el.set(qn('w:sz'), '4')
|
|
|
+ el.set(qn('w:space'), '0')
|
|
|
+ el.set(qn('w:color'), '000000')
|
|
|
+ borders.append(el)
|
|
|
+ tblPr.append(borders)
|
|
|
+
|
|
|
+
|
|
|
+def _remove_table_borders(table):
|
|
|
+ """移除表格边框(用于页眉表格)"""
|
|
|
+ tbl = table._tbl
|
|
|
+ tblPr = tbl.tblPr if tbl.tblPr is not None else OxmlElement('w:tblPr')
|
|
|
+ borders = OxmlElement('w:tblBorders')
|
|
|
+ for edge in ['top', 'left', 'bottom', 'right', 'insideH', 'insideV']:
|
|
|
+ el = OxmlElement(f'w:{edge}')
|
|
|
+ el.set(qn('w:val'), 'none')
|
|
|
+ el.set(qn('w:sz'), '0')
|
|
|
+ el.set(qn('w:space'), '0')
|
|
|
+ el.set(qn('w:color'), 'auto')
|
|
|
+ borders.append(el)
|
|
|
+ tblPr.append(borders)
|
|
|
+
|
|
|
+
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+# 正文渲染器 — 解析 export_markdown 写入 docx
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+class _BodyRenderer:
|
|
|
+ """渲染 export_markdown 正文到 docx
|
|
|
+
|
|
|
+ 规则:
|
|
|
+ - 正文:小四宋体(西文 TNR),1.5 倍行距,首行缩进 2 字符
|
|
|
+ - 表格:五号宋体
|
|
|
+ - **加粗** → 加粗
|
|
|
+ - #### 内部标题 → 黑体加粗段落
|
|
|
+ - && → 下划线空格
|
|
|
+ -  → 图片
|
|
|
+ """
|
|
|
+
|
|
|
+ def __init__(self, doc: Document):
|
|
|
+ self.doc = doc
|
|
|
+
|
|
|
+ def render(self, markdown_text: str):
|
|
|
+ if not markdown_text or not markdown_text.strip():
|
|
|
+ return
|
|
|
+ lines = markdown_text.split("\n")
|
|
|
+ i = 0
|
|
|
+ while i < len(lines):
|
|
|
+ line = lines[i]
|
|
|
+ stripped = line.strip()
|
|
|
+ if not stripped:
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ # 分隔线跳过
|
|
|
+ if re.match(r'^-{3,}$', stripped) or re.match(r'^\*{3,}$', stripped):
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ # Markdown 表格
|
|
|
+ if stripped.startswith("|") and stripped.endswith("|"):
|
|
|
+ tbl_lines = []
|
|
|
+ while i < len(lines) and lines[i].strip().startswith("|"):
|
|
|
+ tbl_lines.append(lines[i].strip())
|
|
|
+ i += 1
|
|
|
+ self._render_table(tbl_lines)
|
|
|
+ continue
|
|
|
+ # 行内图片
|
|
|
+ if "![" in stripped:
|
|
|
+ rest = stripped
|
|
|
+ while rest:
|
|
|
+ m = re.search(r'!\[.*?\]\((.*?)\)', rest)
|
|
|
+ if m:
|
|
|
+ before = rest[:m.start()].strip()
|
|
|
+ if before:
|
|
|
+ self._render_line(before)
|
|
|
+ self._render_image(m.group(1))
|
|
|
+ rest = rest[m.end():].strip()
|
|
|
+ else:
|
|
|
+ if rest:
|
|
|
+ self._render_line(rest)
|
|
|
+ rest = ""
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ # 引用
|
|
|
+ if stripped.startswith(">"):
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ _set_font(p.add_run(stripped.lstrip(">").strip()), size_pt=FONT_SIZE["小四"])
|
|
|
+ _set_para_spacing(p.paragraph_format)
|
|
|
+ p.paragraph_format.left_indent = Cm(1)
|
|
|
+ i += 1
|
|
|
+ continue
|
|
|
+ # 正文段落
|
|
|
+ self._render_line(stripped)
|
|
|
+ i += 1
|
|
|
+
|
|
|
+ def _render_line(self, text: str):
|
|
|
+ """一行正文:小四宋体,首行缩进2字符"""
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ pf = p.paragraph_format
|
|
|
+ _set_para_spacing(pf)
|
|
|
+ _set_first_line_indent(pf, font_size_pt=FONT_SIZE["小四"])
|
|
|
+ for seg_type, seg_text in self._parse_inline(text):
|
|
|
+ if seg_type == "bold":
|
|
|
+ r = p.add_run(seg_text)
|
|
|
+ _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"], bold=True)
|
|
|
+ elif seg_type == "placeholder":
|
|
|
+ r = p.add_run(seg_text.replace("&&", " "))
|
|
|
+ _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
|
|
|
+ r.underline = True
|
|
|
+ elif seg_type == "heading":
|
|
|
+ r = p.add_run(seg_text)
|
|
|
+ _set_font(r, ea_font=FONT_HEI, size_pt=FONT_SIZE["小四"], bold=True)
|
|
|
+ pf.first_line_indent = Pt(0)
|
|
|
+ else:
|
|
|
+ r = p.add_run(seg_text)
|
|
|
+ _set_font(r, ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _parse_inline(text: str):
|
|
|
+ """解析内联格式 → [(type, text), ...]"""
|
|
|
+ segments = []
|
|
|
+ m = re.match(r'^(#{1,6})\s+(.*)', text)
|
|
|
+ if m:
|
|
|
+ segments.append(("heading", m.group(2)))
|
|
|
+ return segments
|
|
|
+ for part in re.split(r'(\*\*.*?\*\*)', text):
|
|
|
+ if part.startswith("**") and part.endswith("**"):
|
|
|
+ inner = part[2:-2]
|
|
|
+ if inner:
|
|
|
+ segments.append(("bold", inner))
|
|
|
+ else:
|
|
|
+ for sub in re.split(r'(&&+)', part):
|
|
|
+ if sub.startswith("&&"):
|
|
|
+ segments.append(("placeholder", sub))
|
|
|
+ elif sub:
|
|
|
+ segments.append(("text", sub))
|
|
|
+ return segments
|
|
|
+
|
|
|
+ def _render_table(self, table_lines: list):
|
|
|
+ """Markdown 表格 → docx 表格,五号宋体"""
|
|
|
+ rows = []
|
|
|
+ for line in table_lines:
|
|
|
+ if not re.match(r'^\|[-:\s|]+\|$', line):
|
|
|
+ cells = [c.strip() for c in line.split("|")[1:-1]]
|
|
|
+ rows.append(cells)
|
|
|
+
|
|
|
+ if not rows:
|
|
|
+ return
|
|
|
+ header = rows[0]
|
|
|
+ body = rows[1:] if len(rows) > 1 else []
|
|
|
+ cols = max(len(header), max((len(r) for r in body), default=0))
|
|
|
+ if cols == 0:
|
|
|
+ return
|
|
|
+ for r in body:
|
|
|
+ while len(r) < cols:
|
|
|
+ r.append("")
|
|
|
+
|
|
|
+ table = self.doc.add_table(rows=1 + len(body), cols=cols)
|
|
|
+ table.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
|
+ _add_table_borders(table)
|
|
|
+
|
|
|
+ for j, h in enumerate(header):
|
|
|
+ cell = table.rows[0].cells[j]
|
|
|
+ cell.text = ""
|
|
|
+ p = cell.paragraphs[0]
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ _set_font(p.add_run(h), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"], bold=True)
|
|
|
+
|
|
|
+ for i, row in enumerate(body):
|
|
|
+ for j, text in enumerate(row):
|
|
|
+ if j >= len(table.rows[i + 1].cells):
|
|
|
+ continue
|
|
|
+ cell = table.rows[i + 1].cells[j]
|
|
|
+ cell.text = ""
|
|
|
+ p = cell.paragraphs[0]
|
|
|
+ is_num = bool(re.match(r'^[\d,.\s\-+%()]*$', text))
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.RIGHT if is_num else WD_ALIGN_PARAGRAPH.LEFT
|
|
|
+ _set_font(p.add_run(text), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
|
|
|
+
|
|
|
+ def _render_image(self, img_path: str):
|
|
|
+ p = Path(img_path)
|
|
|
+ resolved = None
|
|
|
+ if p.exists():
|
|
|
+ resolved = p
|
|
|
+ else:
|
|
|
+ for drive in ["D:", "E:", "C:"]:
|
|
|
+ alt = Path(str(p).replace("E:", drive).replace("D:", drive))
|
|
|
+ if alt.exists():
|
|
|
+ resolved = alt
|
|
|
+ break
|
|
|
+ if resolved:
|
|
|
+ try:
|
|
|
+ para = self.doc.add_paragraph()
|
|
|
+ para.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ para.paragraph_format.first_line_indent = Pt(0)
|
|
|
+ para.add_run().add_picture(str(resolved), width=Cm(14))
|
|
|
+ except Exception:
|
|
|
+ self._placeholder_image(img_path)
|
|
|
+ else:
|
|
|
+ self._placeholder_image(img_path)
|
|
|
+
|
|
|
+ def _placeholder_image(self, img_path):
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ _set_font(p.add_run(f"[图片: {Path(img_path).name}]"),
|
|
|
+ ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
|
|
|
+
|
|
|
+
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+# 大纲遍历器(核心引擎)
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+class _OutlineDocxBuilder:
|
|
|
+
|
|
|
+ def __init__(self, outline: dict, payload: dict, *,
|
|
|
+ project_name: Union[str, None] = None,
|
|
|
+ header_label: Union[str, None] = None):
|
|
|
+ self.outline = outline
|
|
|
+ self.payload = payload
|
|
|
+ self._project_name_override = project_name
|
|
|
+ self._header_label_override = header_label
|
|
|
+ self.doc = Document()
|
|
|
+
|
|
|
+ # path → section 索引
|
|
|
+ self.by_path = {sec["path"]: sec for sec in payload.get("sections", [])}
|
|
|
+
|
|
|
+ # 卷册角色 → 商务标/技术标
|
|
|
+ self.volume_roles: dict = {}
|
|
|
+ for node in outline.get("tree", []):
|
|
|
+ role = node.get("outline_role", "commercial")
|
|
|
+ self.volume_roles[node.get("path", "")] = "商务标" if role == "commercial" else "技术标"
|
|
|
+
|
|
|
+ self._header_right_cell = None
|
|
|
+
|
|
|
+ self._setup_page()
|
|
|
+ self._setup_styles()
|
|
|
+ self._setup_header_footer()
|
|
|
+
|
|
|
+ @property
|
|
|
+ def _project_name(self):
|
|
|
+ return self._project_name_override or self.outline.get("alias", "")
|
|
|
+
|
|
|
+ @property
|
|
|
+ def _task_id(self):
|
|
|
+ return self.outline.get("task_id", "")
|
|
|
+
|
|
|
+ @property
|
|
|
+ def _package_id(self):
|
|
|
+ return self.outline.get("package_id", "")
|
|
|
+
|
|
|
+ # ── 页面设置 ──
|
|
|
+ def _setup_page(self):
|
|
|
+ for sec in self.doc.sections:
|
|
|
+ sec.top_margin = Cm(2.54)
|
|
|
+ sec.bottom_margin = Cm(2.54)
|
|
|
+ sec.left_margin = Cm(2.5)
|
|
|
+ sec.right_margin = Cm(2.5)
|
|
|
+
|
|
|
+ # ── 样式 ──
|
|
|
+ def _setup_styles(self):
|
|
|
+ _apply_normal_style(self.doc.styles['Normal'])
|
|
|
+
|
|
|
+ # 标题层级映射(供目录使用 + 符合标书规范):
|
|
|
+ # L1 卷册 → Heading 1 → 二号黑体加粗 (不在"一、二、三"编号体系中)
|
|
|
+ # L2 章(第一章) → Heading 2 → 小三黑体加粗 (一级标题 一、二、三...)
|
|
|
+ # L3 节 → Heading 3 → 四号黑体加粗 (二级标题 (一)(二)...)
|
|
|
+ # L4+ 细项 → 加粗段落 → 小四宋体加粗 (三级标题 1. 2. 3...,不入目录)
|
|
|
+ _apply_heading_style(self.doc.styles['Heading 1'],
|
|
|
+ FONT_SIZE["二号"], FONT_HEI, bold=True)
|
|
|
+ _apply_heading_style(self.doc.styles['Heading 2'],
|
|
|
+ FONT_SIZE["小三"], FONT_HEI, bold=True)
|
|
|
+ _apply_heading_style(self.doc.styles['Heading 3'],
|
|
|
+ FONT_SIZE["四号"], FONT_HEI, bold=True)
|
|
|
+
|
|
|
+ # 目录样式: 一级目录四号黑体,二级目录小四宋体
|
|
|
+ styles_el = self.doc.styles.element
|
|
|
+ _create_toc_style(styles_el, 'TOC1', 'toc 1', 'Normal',
|
|
|
+ FONT_HEI, FONT_SIZE["四号"], bold=False)
|
|
|
+ _create_toc_style(styles_el, 'TOC2', 'toc 2', 'Normal',
|
|
|
+ FONT_SONG, FONT_SIZE["小四"], bold=False)
|
|
|
+
|
|
|
+ # ── 页眉(封面/目录节:无页码)──
|
|
|
+ def _setup_header_footer(self):
|
|
|
+ section = self.doc.sections[0]
|
|
|
+ header = section.header
|
|
|
+ header.is_linked_to_previous = False
|
|
|
+ for p in header.paragraphs:
|
|
|
+ p.clear()
|
|
|
+ self._build_header_table(header)
|
|
|
+
|
|
|
+ # 封面/目录节:页脚留空(无页码)
|
|
|
+
|
|
|
+ def _build_header_table(self, header):
|
|
|
+ """构造页眉表格:左项目名 + 右标签"""
|
|
|
+ htable = header.add_table(rows=1, cols=2, width=Cm(16))
|
|
|
+ htable.alignment = WD_TABLE_ALIGNMENT.CENTER
|
|
|
+ _set_font(htable.rows[0].cells[0].paragraphs[0].add_run(self._project_name),
|
|
|
+ ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
|
|
|
+
|
|
|
+ right_cell = htable.rows[0].cells[1]
|
|
|
+ right_cell.text = ""
|
|
|
+ self._header_right_cell = right_cell
|
|
|
+ _remove_table_borders(htable)
|
|
|
+
|
|
|
+ def _update_header_role(self, role_text):
|
|
|
+ if self._header_right_cell is not None:
|
|
|
+ p = self._header_right_cell.paragraphs[0]
|
|
|
+ p.clear()
|
|
|
+ _set_font(p.add_run(role_text), ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
|
|
|
+
|
|
|
+ # ── 正文节(页码从 1 起始)──
|
|
|
+ def _add_body_section(self):
|
|
|
+ new_sec = self.doc.add_section()
|
|
|
+ new_sec.top_margin = Cm(2.54)
|
|
|
+ new_sec.bottom_margin = Cm(2.54)
|
|
|
+ new_sec.left_margin = Cm(2.5)
|
|
|
+ new_sec.right_margin = Cm(2.5)
|
|
|
+ new_sec.header.is_linked_to_previous = False
|
|
|
+ new_sec.footer.is_linked_to_previous = False
|
|
|
+
|
|
|
+ # 页眉
|
|
|
+ for p in new_sec.header.paragraphs:
|
|
|
+ p.clear()
|
|
|
+ self._build_header_table(new_sec.header)
|
|
|
+
|
|
|
+ # 页脚:居中页码
|
|
|
+ fp = new_sec.footer.paragraphs[0]
|
|
|
+ fp.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+
|
|
|
+ # 在段落默认属性上设置五号宋体,确保 PAGE 域渲染时继承正确字体
|
|
|
+ pPr = fp._p.get_or_add_pPr()
|
|
|
+ rPr_def = pPr.find(qn('w:rPr'))
|
|
|
+ if rPr_def is None:
|
|
|
+ rPr_def = OxmlElement('w:rPr')
|
|
|
+ pPr.insert(0, rPr_def)
|
|
|
+ _apply_style_fonts(rPr_def, FONT_SONG)
|
|
|
+ sz_def = rPr_def.find(qn('w:sz'))
|
|
|
+ if sz_def is None:
|
|
|
+ sz_def = OxmlElement('w:sz')
|
|
|
+ rPr_def.append(sz_def)
|
|
|
+ sz_def.set(qn('w:val'), str(int(FONT_SIZE["五号"] * 2)))
|
|
|
+ szCs_def = rPr_def.find(qn('w:szCs'))
|
|
|
+ if szCs_def is None:
|
|
|
+ szCs_def = OxmlElement('w:szCs')
|
|
|
+ rPr_def.append(szCs_def)
|
|
|
+ szCs_def.set(qn('w:val'), str(int(FONT_SIZE["五号"] * 2)))
|
|
|
+ color_def = rPr_def.find(qn('w:color'))
|
|
|
+ if color_def is None:
|
|
|
+ color_def = OxmlElement('w:color')
|
|
|
+ rPr_def.append(color_def)
|
|
|
+ color_def.set(qn('w:val'), '000000')
|
|
|
+
|
|
|
+ r = fp.add_run()
|
|
|
+ fc = OxmlElement('w:fldChar')
|
|
|
+ fc.set(qn('w:fldCharType'), 'begin')
|
|
|
+ r._r.append(fc)
|
|
|
+
|
|
|
+ r2 = fp.add_run()
|
|
|
+ it = OxmlElement('w:instrText')
|
|
|
+ it.text = ' PAGE '
|
|
|
+ r2._r.append(it)
|
|
|
+
|
|
|
+ r3 = fp.add_run()
|
|
|
+ fc2 = OxmlElement('w:fldChar')
|
|
|
+ fc2.set(qn('w:fldCharType'), 'end')
|
|
|
+ r3._r.append(fc2)
|
|
|
+
|
|
|
+ for rn in fp.runs:
|
|
|
+ _set_font(rn, ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
|
|
|
+
|
|
|
+ # 从 1 开始
|
|
|
+ pnt = OxmlElement('w:pgNumType')
|
|
|
+ pnt.set(qn('w:start'), '1')
|
|
|
+ new_sec._sectPr.append(pnt)
|
|
|
+
|
|
|
+ # ── 封面 ──
|
|
|
+ def _add_cover(self):
|
|
|
+ for _ in range(8):
|
|
|
+ _add_empty_para(self.doc, 18)
|
|
|
+
|
|
|
+ # "投 标 文 件" —— 二号黑体加粗居中
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ _set_font(p.add_run("投 标 文 件"), ea_font=FONT_HEI,
|
|
|
+ size_pt=FONT_SIZE["二号"], bold=True)
|
|
|
+ _add_empty_para(self.doc, 24)
|
|
|
+
|
|
|
+ # 项目名称(副标题)
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ _set_font(p.add_run(self._project_name), ea_font=FONT_HEI, size_pt=18)
|
|
|
+ _add_empty_para(self.doc, 12)
|
|
|
+
|
|
|
+ # 标识
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ _set_font(p.add_run(f"{self._task_id} / {self._package_id}"),
|
|
|
+ ea_font=FONT_SONG, size_pt=FONT_SIZE["小四"])
|
|
|
+ self.doc.add_page_break()
|
|
|
+
|
|
|
+ # ── 目录 ──
|
|
|
+ def _add_toc(self):
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|
|
+ _set_font(p.add_run("目 录"), ea_font=FONT_HEI,
|
|
|
+ size_pt=FONT_SIZE["二号"], bold=False)
|
|
|
+ p.paragraph_format.space_after = Pt(12)
|
|
|
+
|
|
|
+ ptoc = self.doc.add_paragraph()
|
|
|
+ r = ptoc.add_run()
|
|
|
+ c = OxmlElement('w:fldChar')
|
|
|
+ c.set(qn('w:fldCharType'), 'begin')
|
|
|
+ r._r.append(c)
|
|
|
+
|
|
|
+ r2 = ptoc.add_run()
|
|
|
+ it = OxmlElement('w:instrText')
|
|
|
+ it.text = ' TOC \\o "1-3" \\h \\z \\u '
|
|
|
+ r2._r.append(it)
|
|
|
+
|
|
|
+ r3 = ptoc.add_run()
|
|
|
+ c2 = OxmlElement('w:fldChar')
|
|
|
+ c2.set(qn('w:fldCharType'), 'separate')
|
|
|
+ r3._r.append(c2)
|
|
|
+
|
|
|
+ r4 = ptoc.add_run()
|
|
|
+ r4.text = "(请在 Word 中右键此处 → 更新域 以生成目录)"
|
|
|
+ _set_font(r4, ea_font=FONT_SONG, size_pt=FONT_SIZE["五号"])
|
|
|
+
|
|
|
+ r5 = ptoc.add_run()
|
|
|
+ c3 = OxmlElement('w:fldChar')
|
|
|
+ c3.set(qn('w:fldCharType'), 'end')
|
|
|
+ r5._r.append(c3)
|
|
|
+
|
|
|
+ self.doc.add_page_break()
|
|
|
+
|
|
|
+ # ── 构建主流程 ──
|
|
|
+ def build(self) -> Document:
|
|
|
+ self._add_cover()
|
|
|
+ self._add_toc()
|
|
|
+ self._add_body_section()
|
|
|
+
|
|
|
+ for l1_node in self.outline.get("tree", []):
|
|
|
+ role = self.volume_roles.get(l1_node.get("path", ""), "")
|
|
|
+ self._update_header_role(self._header_label_override or role)
|
|
|
+ self._traverse_node(l1_node)
|
|
|
+ return self.doc
|
|
|
+
|
|
|
+ # ── 大纲递归遍历 ──
|
|
|
+ @staticmethod
|
|
|
+ def _strip_title_punct(title: str) -> str:
|
|
|
+ """去除标题末尾标点符号,确保标题独占一行、末尾不带标点"""
|
|
|
+ return title.rstrip("。,、;:?!.,;:!?·—…")
|
|
|
+
|
|
|
+ def _traverse_node(self, node, parent_role=""):
|
|
|
+ level = node["level"]
|
|
|
+ title = self._strip_title_punct(node["title"])
|
|
|
+ path = node["path"]
|
|
|
+ children = node.get("children", [])
|
|
|
+
|
|
|
+ em = ""
|
|
|
+ if path in self.by_path:
|
|
|
+ raw = (self.by_path[path].get("export_markdown", "") or "")
|
|
|
+ em = self._clean_export(raw, title)
|
|
|
+
|
|
|
+ if level == 1:
|
|
|
+ # L1 卷册 → Heading 1(二号黑体加粗,出现在目录)
|
|
|
+ self.doc.add_page_break()
|
|
|
+ self.doc.add_heading(title, level=1)
|
|
|
+ if em.strip():
|
|
|
+ _BodyRenderer(self.doc).render(em)
|
|
|
+ for child in children:
|
|
|
+ self._traverse_node(child, parent_role=path)
|
|
|
+
|
|
|
+ elif level == 2:
|
|
|
+ # L2 章(第一章...)→ Heading 2 → 一级标题,小三黑体加粗
|
|
|
+ self.doc.add_heading(title, level=2)
|
|
|
+ if em.strip():
|
|
|
+ _BodyRenderer(self.doc).render(em)
|
|
|
+ for child in children:
|
|
|
+ self._traverse_node(child, parent_role)
|
|
|
+
|
|
|
+ elif level == 3:
|
|
|
+ # L3 节 → Heading 3 → 二级标题,四号黑体加粗
|
|
|
+ self.doc.add_heading(title, level=3)
|
|
|
+ if em.strip():
|
|
|
+ _BodyRenderer(self.doc).render(em)
|
|
|
+ for child in children:
|
|
|
+ self._traverse_node(child, parent_role)
|
|
|
+
|
|
|
+ elif level >= 4:
|
|
|
+ # L4+ 细项 → 加粗段落(小四宋体加粗),不入目录
|
|
|
+ if em.strip():
|
|
|
+ p = self.doc.add_paragraph()
|
|
|
+ pf = p.paragraph_format
|
|
|
+ _set_para_spacing(pf, before=3, after=0)
|
|
|
+ pf.first_line_indent = Pt(0)
|
|
|
+ _set_font(p.add_run(title), ea_font=FONT_SONG,
|
|
|
+ size_pt=FONT_SIZE["小四"], bold=True)
|
|
|
+ _BodyRenderer(self.doc).render(em)
|
|
|
+ for child in children:
|
|
|
+ self._traverse_node(child, parent_role)
|
|
|
+
|
|
|
+ @staticmethod
|
|
|
+ def _clean_export(markdown: str, title: str) -> str:
|
|
|
+ """去除 export_markdown 开头冗余内容"""
|
|
|
+ lines = markdown.split("\n")
|
|
|
+ cleaned = []
|
|
|
+ skipping = True
|
|
|
+ for line in lines:
|
|
|
+ s = line.strip()
|
|
|
+ if skipping:
|
|
|
+ if not s:
|
|
|
+ continue
|
|
|
+ if s.startswith("#") and title in s:
|
|
|
+ continue
|
|
|
+ if any(kw in s for kw in ["为您撰写", "根据您", "好的,"]):
|
|
|
+ continue
|
|
|
+ skipping = False
|
|
|
+ cleaned.append(line)
|
|
|
+ return "\n".join(cleaned)
|
|
|
+
|
|
|
+
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+# 公开 API
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+def generate_bid_docx(
|
|
|
+ bid_fill_draft: Union[dict, str, Path],
|
|
|
+ sq_bid_outline: Union[dict, str, Path],
|
|
|
+ *,
|
|
|
+ project_name: Union[str, None] = None,
|
|
|
+ header_label: Union[str, None] = None,
|
|
|
+ output_path: Union[str, Path, None] = None,
|
|
|
+) -> Union[Document, Path]:
|
|
|
+ """生成投标文件 docx。
|
|
|
+
|
|
|
+ 参数
|
|
|
+ ----
|
|
|
+ bid_fill_draft : dict | str | Path
|
|
|
+ 正文真源。dict 或 JSON 文件路径。
|
|
|
+ sq_bid_outline : dict | str | Path
|
|
|
+ 结构真源。dict 或 JSON 文件路径。
|
|
|
+ project_name : str | None
|
|
|
+ 页眉左侧项目全称。不传则从 outline.alias 读取。
|
|
|
+ header_label : str | None
|
|
|
+ 页眉右侧标签("商务标"/"技术标")。不传则按大纲角色判断。
|
|
|
+ output_path : str | Path | None
|
|
|
+ docx 保存路径。None 则返回 Document 对象。
|
|
|
+
|
|
|
+ 返回
|
|
|
+ ----
|
|
|
+ Document | Path
|
|
|
+ """
|
|
|
+ outline = _load_json(sq_bid_outline)
|
|
|
+ payload = _load_json(bid_fill_draft)
|
|
|
+ builder = _OutlineDocxBuilder(outline, payload,
|
|
|
+ project_name=project_name,
|
|
|
+ header_label=header_label)
|
|
|
+ doc = builder.build()
|
|
|
+ if output_path:
|
|
|
+ output_path = Path(output_path)
|
|
|
+ output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
|
+ doc.save(str(output_path))
|
|
|
+ return output_path
|
|
|
+ return doc
|
|
|
+
|
|
|
+
|
|
|
+def _load_json(data) -> dict:
|
|
|
+ if isinstance(data, dict):
|
|
|
+ return data
|
|
|
+ with open(Path(data), "r", encoding="utf-8") as f:
|
|
|
+ return json.load(f)
|
|
|
+
|
|
|
+
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+# CLI 入口
|
|
|
+# ════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+def main():
|
|
|
+ import argparse
|
|
|
+ parser = argparse.ArgumentParser(description="投标文件 DOCX 生成器")
|
|
|
+ parser.add_argument("--fill", default="data/bid_fill_draft.json")
|
|
|
+ parser.add_argument("--outline", default="data/sq_bid_outline.json")
|
|
|
+ parser.add_argument("--project-name", default=None)
|
|
|
+ parser.add_argument("--header-label", default=None)
|
|
|
+ parser.add_argument("--output", "-o", default=None)
|
|
|
+ args = parser.parse_args()
|
|
|
+
|
|
|
+ if args.output is None:
|
|
|
+ outline = _load_json(args.outline)
|
|
|
+ alias = outline.get("alias", "投标文件")
|
|
|
+ safe = re.sub(r'[\\/:*?"<>|]', '_', alias)
|
|
|
+ out_dir = Path(__file__).resolve().parent.parent / "output"
|
|
|
+ out_dir.mkdir(parents=True, exist_ok=True)
|
|
|
+ args.output = str(out_dir / f"{safe}.docx")
|
|
|
+
|
|
|
+ result = generate_bid_docx(
|
|
|
+ bid_fill_draft=args.fill,
|
|
|
+ sq_bid_outline=args.outline,
|
|
|
+ project_name=args.project_name,
|
|
|
+ header_label=args.header_label,
|
|
|
+ output_path=args.output,
|
|
|
+ )
|
|
|
+
|
|
|
+ payload = _load_json(args.fill)
|
|
|
+ total_chars = sum(
|
|
|
+ len(sec.get("export_markdown", "") or "")
|
|
|
+ for sec in payload.get("sections", [])
|
|
|
+ )
|
|
|
+ result_doc = docx.Document(str(result))
|
|
|
+ print(f"文档已生成: {result}")
|
|
|
+ print(f" 正文总字符数: {total_chars:,}")
|
|
|
+ print(f" 段 落 数: {len(result_doc.paragraphs)}")
|
|
|
+ print(f" 表 格 数: {len(result_doc.tables)}")
|
|
|
+
|
|
|
+
|
|
|
+if __name__ == "__main__":
|
|
|
+ main()
|