Skill-BidCreater/scripts/render_bid_docx.py
2026-03-09 22:20:38 +08:00

207 lines
7.3 KiB
Python

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()