from __future__ import annotations import argparse from pathlib import Path from typing import Any from docx import Document 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 common import get_bundle_defaults, normalize_bundle, read_json FONT_NAME = "Microsoft YaHei" def set_run_font(run, size: int = 12, bold: bool = False) -> None: run.font.name = FONT_NAME run._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_NAME) run.font.size = Pt(size) run.font.bold = bold def set_style_font(style, size: int, bold: bool = False) -> None: style.font.name = FONT_NAME style._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_NAME) style.font.size = Pt(size) style.font.bold = bold def add_toc(doc: Document) -> None: paragraph = doc.add_paragraph() run = paragraph.add_run() fld_char_begin = OxmlElement("w:fldChar") fld_char_begin.set(qn("w:fldCharType"), "begin") instr_text = OxmlElement("w:instrText") instr_text.set(qn("xml:space"), "preserve") instr_text.text = 'TOC \\o "1-3" \\h \\z \\u' fld_char_separate = OxmlElement("w:fldChar") fld_char_separate.set(qn("w:fldCharType"), "separate") placeholder = OxmlElement("w:t") placeholder.text = "打开文档后右键更新目录" fld_char_end = OxmlElement("w:fldChar") fld_char_end.set(qn("w:fldCharType"), "end") run._r.append(fld_char_begin) run._r.append(instr_text) run._r.append(fld_char_separate) run._r.append(placeholder) run._r.append(fld_char_end) def add_paragraph(doc: Document, text: str, size: int = 12, bold: bool = False, align=WD_ALIGN_PARAGRAPH.JUSTIFY): paragraph = doc.add_paragraph() paragraph.alignment = align paragraph.paragraph_format.space_after = Pt(6) paragraph.paragraph_format.line_spacing = 1.5 run = paragraph.add_run(text) set_run_font(run, size=size, bold=bold) return paragraph def add_bullets(doc: Document, items: list[str]) -> None: for item in items: paragraph = doc.add_paragraph(style="List Bullet") set_run_font(paragraph.add_run(item)) def add_table(doc: Document, title: str, headers: list[str], rows: list[list[str]]) -> None: if title: add_paragraph(doc, title, bold=True) table = doc.add_table(rows=1, cols=len(headers)) table.style = "Table Grid" for index, header in enumerate(headers): table.rows[0].cells[index].text = header for row in rows: cells = table.add_row().cells for index, value in enumerate(row): cells[index].text = value doc.add_paragraph() def add_image(doc: Document, path: Path, title: str | None = None, width_cm: float = 16.0) -> None: if title: add_paragraph(doc, title, bold=True) doc.add_picture(str(path), width=Cm(width_cm)) def coerce_content_block(value: Any) -> dict[str, Any]: if isinstance(value, str): return {"paragraphs": [value]} if isinstance(value, dict): return value return {} def normalize_section_payload(section: dict[str, Any]) -> dict[str, Any]: content = coerce_content_block(section.get("content")) return { "title": section.get("title", ""), "paragraphs": list(section.get("paragraphs", content.get("paragraphs", []))), "bullets": list(section.get("bullets", content.get("bullets", []))), "tables": list(section.get("tables", content.get("tables", []))), "images": list(section.get("images", content.get("images", []))), "children": [normalize_section_payload(child) for child in section.get("children", [])], } def normalize_sections(spec: dict[str, Any]) -> list[dict[str, Any]]: nodes = spec.get("nodes") if isinstance(nodes, list): return [normalize_section_payload(node) for node in nodes if isinstance(node, dict)] sections = spec.get("sections", []) if not isinstance(sections, list): return [] if any(isinstance(item, dict) and any(key in item for key in ("outline_id", "workflow_bucket", "status", "content")) for item in sections): return [normalize_section_payload(section) for section in sections if isinstance(section, dict)] return [section for section in sections if isinstance(section, dict)] def add_section(doc: Document, section: dict[str, Any], numbers: tuple[int, ...] = ()) -> None: prefix = ".".join(str(number) for number in numbers) + " " if numbers else "" title = section.get("title", "") if title: level = min(max(len(numbers), 1), 3) doc.add_heading(prefix + title, level=level) for paragraph_text in section.get("paragraphs", []): add_paragraph(doc, paragraph_text) bullets = section.get("bullets", []) if bullets: add_bullets(doc, bullets) for table in section.get("tables", []): add_table(doc, table.get("title", ""), table.get("headers", []), table.get("rows", [])) for image in section.get("images", []): image_path = Path(image["path"]).resolve() add_image(doc, image_path, image.get("title"), float(image.get("width_cm", 16))) for index, child in enumerate(section.get("children", []), start=1): add_section(doc, child, numbers + (index,)) def build_docx(spec: dict[str, Any], out_path: Path) -> None: doc = Document() section = doc.sections[0] section.top_margin = Cm(2.54) section.bottom_margin = Cm(2.54) section.left_margin = Cm(3.0) section.right_margin = Cm(2.5) set_style_font(doc.styles["Normal"], 12) set_style_font(doc.styles["Heading 1"], 16, True) set_style_font(doc.styles["Heading 2"], 14, True) set_style_font(doc.styles["Heading 3"], 12, True) bundle = normalize_bundle(spec.get("bundle")) bundle_defaults = get_bundle_defaults(bundle) if bundle else None doc_title = spec.get("doc_title") or (bundle_defaults["bid_doc_title"] if bundle_defaults else "投标文件") toc_title = spec.get("toc_title") or (bundle_defaults["bid_toc_title"] if bundle_defaults else "目录") cover = doc.add_paragraph() cover.alignment = WD_ALIGN_PARAGRAPH.CENTER cover_specs = [ (spec.get("project_name", "项目名称待补充"), 22, True), (doc_title, 28, True), (spec.get("subtitle", ""), 16, True), ("", 12, False), (f"投标人:{spec.get('bidder_name', '投标人名称待补充')}", 14, False), ] for text, size, bold in cover_specs: if not text and size != 12: continue run = cover.add_run(text + "\n") set_run_font(run, size=size, bold=bold) if size >= 22: run.font.color.rgb = RGBColor(0x1E, 0x3A, 0x8A) if spec.get("include_toc", True): doc.add_page_break() doc.add_heading(toc_title, level=1) add_toc(doc) for index, section_spec in enumerate(normalize_sections(spec), start=1): doc.add_page_break() add_section(doc, section_spec, (index,)) out_path.parent.mkdir(parents=True, exist_ok=True) doc.save(out_path) def main() -> None: parser = argparse.ArgumentParser() parser.add_argument("--content", required=True) parser.add_argument("--out", required=True) args = parser.parse_args() build_docx(read_json(Path(args.content).resolve()), Path(args.out).resolve()) if __name__ == "__main__": main()