207 lines
7.3 KiB
Python
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()
|