""" proposal_writer.py - 投标文件DOCX输出模块 将 ProposalDocument 格式化为结构化的 Word 文档(.docx), 支持封面、目录、标题层级、表格、列表、页脚页码等排版要素。 """ import logging import re from typing import Optional from docx import Document from docx.enum.table import WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING from docx.oxml.ns import qn, nsdecls from docx.oxml import parse_xml from docx.shared import Cm, Pt, RGBColor from docx.table import _Cell from .models import ProposalDocument, ProposalSection logger = logging.getLogger(__name__) # ---- 常量 ---- PAGE_WIDTH_CM = 21.0 PAGE_HEIGHT_CM = 29.7 MARGIN_CM = 2.0 BODY_WIDTH_CM = PAGE_WIDTH_CM - 2 * MARGIN_CM # 17.0 cm FONT_NAME = "宋体" FONT_NAME_ASCII = "Times New Roman" HEADING_FONTS = {1: 16, 2: 14, 3: 12} BODY_FONT_SIZE = 12 # pt def _set_cell_border(cell: _Cell, **kwargs): """设置单元格边框。""" tc = cell._tc tcPr = tc.get_or_add_tcPr() tcBorders = parse_xml( f'' ' ' ' ' ' ' ' ' '' ) tcPr.append(tcBorders) def _set_paragraph_spacing(paragraph, line_spacing: float = 1.5, space_before: float = 0.5, space_after: float = 0.5): """设置段落间距。""" pf = paragraph.paragraph_format pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE pf.line_spacing = line_spacing pf.space_before = Pt(space_before * BODY_FONT_SIZE) pf.space_after = Pt(space_after * BODY_FONT_SIZE) def _run_format(run, font_name: str = FONT_NAME, font_size: int = BODY_FONT_SIZE, bold: bool = False, color: Optional[str] = None): """设置 run 的字体格式。""" run.font.name = font_name run.font.size = Pt(font_size) run.font.bold = bold run._element.rPr.rFonts.set(qn("w:eastAsia"), font_name) if color: run.font.color.rgb = RGBColor(*bytes.fromhex(color.lstrip("#"))) def _setup_page(doc: Document): """设置页面格式:A4、2cm页边距。""" section = doc.sections[0] section.page_width = Cm(PAGE_WIDTH_CM) section.page_height = Cm(PAGE_HEIGHT_CM) section.top_margin = Cm(MARGIN_CM) section.bottom_margin = Cm(MARGIN_CM) section.left_margin = Cm(MARGIN_CM) section.right_margin = Cm(MARGIN_CM) def _add_footer_page_number(doc: Document): """添加页脚页码。""" section = doc.sections[0] footer = section.footer footer.is_linked_to_previous = False p = footer.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER # "第 X 页 / 共 Y 页" run1 = p.add_run("第 ") _run_format(run1, font_size=10) fld_char1 = parse_xml(f'') run2 = p.add_run() run2._element.append(fld_char1) instr = parse_xml(f' PAGE ') run3 = p.add_run() run3._element.append(instr) fld_char2 = parse_xml(f'') run4 = p.add_run() run4._element.append(fld_char2) run5 = p.add_run(" 页 / 共 ") _run_format(run5, font_size=10) fld_char3 = parse_xml(f'') run6 = p.add_run() run6._element.append(fld_char3) instr2 = parse_xml(f' NUMPAGES ') run7 = p.add_run() run7._element.append(instr2) fld_char4 = parse_xml(f'') run8 = p.add_run() run8._element.append(fld_char4) run9 = p.add_run(" 页") _run_format(run9, font_size=10) def _create_cover_page(doc: Document, title: str, date_str: str = ""): """创建封面页。 封面排版:居中,从上到下依次为: - "投标文件"(大号标题) - 项目名称 - 编制日期 """ import datetime if not date_str: date_str = datetime.date.today().strftime("%Y年%m月%d日") # 上部留白 for _ in range(6): doc.add_paragraph() # 大标题:投标文件 p_title = doc.add_paragraph() p_title.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p_title.add_run("投 标 文 件") _run_format(run, font_name=FONT_NAME, font_size=28, bold=True) # 装饰线 p_line = doc.add_paragraph() p_line.alignment = WD_ALIGN_PARAGRAPH.CENTER run_line = p_line.add_run("━" * 20) _run_format(run_line, font_size=14, color="333333") doc.add_paragraph() # 项目名称 p_proj = doc.add_paragraph() p_proj.alignment = WD_ALIGN_PARAGRAPH.CENTER run_proj = p_proj.add_run(f"项目名称:{title}") _run_format(run_proj, font_size=16) doc.add_paragraph() doc.add_paragraph() # 编制日期 p_date = doc.add_paragraph() p_date.alignment = WD_ALIGN_PARAGRAPH.CENTER run_date = p_date.add_run(f"编制日期:{date_str}") _run_format(run_date, font_size=14) # 分页 doc.add_page_break() def _add_heading(doc: Document, text: str, level: int = 1): """添加格式化标题。 使用预定义的字体大小:Heading1=16pt, Heading2=14pt, Heading3=12pt。 """ heading = doc.add_heading(text, level=min(level, 3)) font_size = HEADING_FONTS.get(min(level, 3), 12) for run in heading.runs: run.font.name = FONT_NAME run.font.size = Pt(font_size) run._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_NAME) return heading def _add_body_paragraph(doc: Document, text: str): """添加格式化正文段落。""" p = doc.add_paragraph() p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY _set_paragraph_spacing(p) # 处理内联格式:**粗体** parts = re.split(r'(\*\*.+?\*\*)', text) for part in parts: if part.startswith("**") and part.endswith("**"): run = p.add_run(part[2:-2]) _run_format(run, bold=True) else: run = p.add_run(part) _run_format(run) p.style = doc.styles["Normal"] return p def _add_bullet_list(doc: Document, items: list[str]): """添加无序列表。""" for item in items: p = doc.add_paragraph(style="List Bullet") _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0) p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY run = p.add_run(item.strip()) _run_format(run) def _add_numbered_list(doc: Document, items: list[str]): """添加有序列表。""" for item in items: p = doc.add_paragraph(style="List Number") _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0) p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY run = p.add_run(item.strip()) _run_format(run) def _parse_markdown_table(md_table: str) -> list[list[str]]: """解析Markdown表格为二维列表。 Args: md_table: Markdown表格文本 Returns: list[list[str]]: 二维字符串列表,第一行为表头 """ lines = [line.strip() for line in md_table.strip().split("\n")] lines = [line for line in lines if line and not line.startswith("| ---") and not line.startswith("|---")] if not lines: return [] table_data = [] for line in lines: line = line.strip() if line.startswith("|"): line = line[1:] if line.endswith("|"): line = line[:-1] cells = [cell.strip() for cell in line.split("|")] table_data.append(cells) return table_data def _write_table(doc: Document, table_lines: list[str]): """将Markdown表格写入DOCX表格。""" table_text = "\n".join(table_lines) data = _parse_markdown_table(table_text) if not data or len(data) < 2: logger.warning("表格数据不足,跳过表格写入") return rows = len(data) cols = max(len(row) for row in data) doc_table = doc.add_table(rows=rows, cols=cols) doc_table.autofit = True doc_table.alignment = WD_TABLE_ALIGNMENT.CENTER # 设置表格边框 tbl = doc_table._tbl tblPr = tbl.tblPr if tbl.tblPr is not None else parse_xml( f'' ) borders = parse_xml( f'' ' ' ' ' ' ' ' ' ' ' ' ' '' ) tblPr.append(borders) for r_idx, row_data in enumerate(data): for c_idx in range(cols): cell_text = row_data[c_idx] if c_idx < len(row_data) else "" cell = doc_table.cell(r_idx, c_idx) # 清空并重新设置内容 cell.text = "" p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run(cell_text) is_header = r_idx == 0 _run_format(run, font_size=10, bold=is_header) doc.add_paragraph() # 表后空行 def _write_markdown_content(doc, markdown_text: str, base_level: int = 2): """将Markdown格式的内容写入DOCX。 支持的Markdown元素: - # 标题 → Heading样式 - **粗体** → 粗体 - 普通段落 → Normal样式 - - 列表项 → 无序列表 - 1. 列表项 → 有序列表 - | 表格 | → DOCX表格 - 空行 → 段落分隔 Args: doc: Document对象 markdown_text: Markdown格式的文本 base_level: 基础标题层级偏移(默认2,即# -> Heading 2) """ lines = markdown_text.split("\n") i = 0 in_table = False table_lines = [] in_code_block = False code_lines = [] while i < len(lines): line = lines[i] stripped = line.strip() # 代码块处理 if stripped.startswith("```"): if in_code_block: # 结束代码块,作为普通段落写入 for code_line in code_lines: _add_body_paragraph(doc, code_line) code_lines = [] in_code_block = False else: in_code_block = True i += 1 continue if in_code_block: code_lines.append(line) i += 1 continue # 表格处理:连续以 | 开头的行 # 判断是否为 Markdown 表格分隔行(如 |---|---| 或 |:---|:---:|) _is_separator = bool(re.match(r'^\|[\s\-:]+\|?$', stripped)) if stripped.startswith("|") and not _is_separator: in_table = True table_lines.append(line) i += 1 continue elif _is_separator: # 分隔行,跳过 i += 1 continue else: if in_table: # 表格结束,写入表格 _write_table(doc, table_lines) table_lines = [] in_table = False # 空行 if not stripped: i += 1 continue # 标题 heading_match = re.match(r'^(#{1,6})\s+(.+)$', stripped) if heading_match: level = min(len(heading_match.group(1)) + base_level - 1, 3) # level最小为1 level = max(level, 1) title_text = heading_match.group(2) # 去掉可能的内联格式标记(标题中保留文字) title_text = re.sub(r'\*\*(.+?)\*\*', r'\1', title_text) _add_heading(doc, title_text, level) i += 1 continue # 无序列表 if stripped.startswith("- ") or stripped.startswith("* "): items = [] while i < len(lines): s = lines[i].strip() if s.startswith("- ") or s.startswith("* "): items.append(s[2:]) i += 1 else: break _add_bullet_list(doc, items) continue # 有序列表 ordered_match = re.match(r'^\d+\.\s+(.+)$', stripped) if ordered_match: items = [] while i < len(lines): s = lines[i].strip() if re.match(r'^\d+\.\s+.+$', s): items.append(re.match(r'^\d+\.\s+(.+)$', s).group(1)) i += 1 else: break _add_numbered_list(doc, items) continue # 普通段落 paragraph_text = stripped i += 1 # 合并后续非空、非特殊行 while i < len(lines): next_line = lines[i] next_stripped = next_line.strip() if not next_stripped: break if next_stripped.startswith("#"): break if next_stripped.startswith("- ") or next_stripped.startswith("* "): break if re.match(r'^\d+\.\s+', next_stripped): break if next_stripped.startswith("|") and not next_stripped.startswith("| ---"): break paragraph_text += " " + next_stripped i += 1 _add_body_paragraph(doc, paragraph_text) # 如果文档以表格结尾 if in_table and table_lines: _write_table(doc, table_lines) def write_proposal_docx(content: ProposalDocument, output_path: str) -> str: """将投标内容写入格式化的DOCX文件。 文档结构: 1. 封面("投标文件"大标题 + 项目名称 + 日期) 2. 目录 3. 投标函(从 sections 中识别) 4. 投标概要(content.summary) 5. 正文章节(按层级组织) 6. 页脚页码 Args: content: 投标文档内容(ProposalDocument) output_path: 输出文件路径(应以 .docx 结尾) Returns: str: 输出文件路径 Raises: ValueError: 如果 content 为空或 output_path 不合法 IOError: 如果文件写入失败 """ if not content: raise ValueError("投标内容不能为空") if not output_path: raise ValueError("输出路径不能为空") logger.info("开始生成DOCX文档:%s", output_path) doc = Document() _setup_page(doc) # 从 sections 中分离出投标函和内容章节 cover_letter_section = None content_sections: list[ProposalSection] = [] for s in content.sections: if s.title == "投标函" and cover_letter_section is None: cover_letter_section = s else: content_sections.append(s) # 1. 封面 _create_cover_page(doc, content.title or content.project_name or "投标文件") # 2. 目录 _add_heading(doc, "目 录", 1) toc_num = 1 if cover_letter_section: p = doc.add_paragraph(f"{_to_chinese_number(toc_num)}、{cover_letter_section.title}") _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0) toc_num += 1 if content.summary: p = doc.add_paragraph(f"{_to_chinese_number(toc_num)}、编制说明") _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0) toc_num += 1 for s in content_sections: indent = " " * (s.level - 1) if s.level > 1 else "" p = doc.add_paragraph(f"{indent}{s.title}") _set_paragraph_spacing(p, line_spacing=1.3, space_before=0, space_after=0) doc.add_page_break() # 3. 投标函 section_counter = 1 if cover_letter_section: _add_heading(doc, f"{_to_chinese_number(section_counter)}、{cover_letter_section.title}", 1) section_counter += 1 _write_markdown_content(doc, cover_letter_section.content, base_level=2) doc.add_page_break() # 4. 投标概要 if content.summary: _add_heading(doc, f"{_to_chinese_number(section_counter)}、编制说明", 1) section_counter += 1 _write_markdown_content(doc, content.summary, base_level=2) doc.add_page_break() # 5. 各章节 for section in content_sections: numbered_title = f"{_to_chinese_number(section_counter)}、{section.title}" section_counter += 1 _add_heading(doc, numbered_title, 1) _write_markdown_content(doc, section.content, base_level=2) # 6. 页脚页码 _add_footer_page_number(doc) try: doc.save(output_path) except (OSError, IOError) as e: raise OSError( f"无法保存DOCX文件到路径: {output_path}\n" f"请检查目录是否存在、磁盘空间是否充足。\n" f"原始错误: {e}" ) from e logger.info("DOCX文档已保存至: %s", output_path) return output_path def _to_chinese_number(n: int) -> str: """将阿拉伯数字转为中文数字(一、二、三...)。 Args: n: 阿拉伯数字 Returns: str: 中文数字 """ chinese_nums = ["〇", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十", "十一", "十二", "十三", "十四", "十五", "十六", "十七", "十八", "十九", "二十"] if 1 <= n <= len(chinese_nums): return chinese_nums[n] return str(n)