""" Word 文档导出模块 """ import io import os import re import sqlite3 import logging import copy from time import perf_counter from datetime import datetime 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 import OxmlElement from docx.oxml.ns import qn import config from utils.outline_numbering import format_heading_display from utils.style_manager import get_preset, docx_to_html_spec from modules.dark_bid_format_check import check_technical_bid from utils import attachment_section as att_sec logger = logging.getLogger(__name__) LEVEL_STYLES = { 1: ('Heading 1', 16, True), 2: ('Heading 2', 14, True), 3: ('Heading 3', 13, False), 4: ('Heading 4', 12, False), } _HF_TOKEN_RE = re.compile(r'(\{page\}|\{total\})', re.IGNORECASE) _HEADER_BRAND_RE = re.compile(r'标桥\s*AI\s*编标', re.IGNORECASE) def export_to_word(db_path: str, project_id: int, style_preset_name='standard') -> str: """ 生成 Word 文档并保存到 data/exports/,返回文件名。 支持从首页「文件样式设置」传入预设 (style_preset_name)。 """ conn = sqlite3.connect(db_path) try: # 获取项目信息 cur = conn.cursor() cur.execute( "SELECT name, COALESCE(enable_figure, 0), COALESCE(enable_table, 0), " "COALESCE(anon_requirements, '') " "FROM projects WHERE id=?", (project_id,) ) project = cur.fetchone() if not project: raise ValueError(f'项目 {project_id} 不存在') project_name = project[0] project_enable_figure = bool(project[1]) project_enable_table = bool(project[2]) anon_requirements = project[3] or '' dark_bid_body = bool(str(anon_requirements).strip()) # 获取标书大纲文本(用于标题页) cur.execute("SELECT outline FROM tender_data WHERE project_id=?", (project_id,)) td = cur.fetchone() bid_title = project_name + '技术标书' if td and td[0]: first_line = td[0].strip().split('\n')[0].strip() if first_line: bid_title = first_line # 获取所有章节(按顺序) cur.execute(''' SELECT section_number, section_title, level, is_leaf, content, intro_content FROM bid_sections WHERE project_id=? ORDER BY order_index ''', (project_id,)) sections = cur.fetchall() t0 = perf_counter() raw_preset = get_preset(style_preset_name) preset = _enforce_format_constraints(raw_preset) # 导出时以“项目设置”中的图示/表格开关为准(运行时实时检查并自动应用) preset['figure_enabled'] = project_enable_figure preset['table_enabled'] = project_enable_table # 暗标:Word 正文/引言不渲染图、表块(附件章节除外) preset['dark_bid_body_strip_charts'] = dark_bid_body # 暗标:附件图/表 Word 渲染与文生图须为灰度,不得使用彩色表头或彩图 preset['dark_bid_grayscale'] = dark_bid_body # 导出闭环:先做 Docx<->HTML 规格检查,不通过则自动修正后再出 Docx check_result = _run_docx_html_roundtrip_check(bid_title, sections, preset) if not check_result.get('overall', True): preset = _repair_preset_from_check(preset, check_result) # 修正后再次强制回灌项目开关,避免被修正逻辑覆盖 preset['figure_enabled'] = project_enable_figure preset['table_enabled'] = project_enable_table preset['dark_bid_body_strip_charts'] = dark_bid_body preset['dark_bid_grayscale'] = dark_bid_body check_result = _run_docx_html_roundtrip_check(bid_title, sections, preset) doc = _build_document(bid_title, sections, preset) # 保存文件 os.makedirs(config.EXPORT_DIR, exist_ok=True) timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') safe_name = ''.join(c for c in project_name if c.isalnum() or c in '._- \u4e00-\u9fff') filename = f'{safe_name}_{timestamp}.docx' filepath = os.path.join(config.EXPORT_DIR, filename) doc.save(filepath) elapsed = perf_counter() - t0 logger.info( f'导出完成: {filepath} (预设: {style_preset_name}, ' f'图示: {"开" if project_enable_figure else "关"}, 表格: {"开" if project_enable_table else "关"}, ' f'检查通过: {check_result.get("overall", True)}, 耗时: {elapsed:.2f}s)' ) return filename finally: conn.close() DISCLAIMER_TEXT = """\ 免责声明 本工具仅供学习交流免费使用,所生成的技术方案不可直接用于投标,请务必人工核对。本工具不会通过任何平台进行销售,请用户注意辨别真伪。在您开始使用本AI标书制作服务之前,请认真阅读并同意以下关键条款。一旦您继续使用,即表示您已充分理解并认可本提示的全部内容。 服务定位 本工具为单机使用的AI标书辅助工具,旨在帮助您生成标书的参考素材。您需对最终自己编写的标书文件承担全部责任,包括审核、修改内容,确保其符合相关法律法规及项目要求。 准确性免责 本人不对AI生成内容的完全准确性与完整性作任何保证。您有义务自行核实所有关键信息,并自行承担因使用本工具所引发的一切后果。 标书风险 本工具所生成的素材文件仅作参考。若您使用(包括引用、修改或二次创作),需自行承担由此可能导致的废标、侵权等全部风险与责任,本人不承担任何相关责任。 责任限制 任何情形下,本人均不对因使用本服务而造成的任何直接、间接或衍生损失(例如利润损失、业务中断、数据丢失等)承担法律责任。 其他事项 本人保留随时修改或终止本服务的权利。本提示的解释及争议解决,均适用中华人民共和国法律。\ """ def _add_disclaimer_page(doc: Document) -> None: """在文档开头插入免责声明页""" # 标题 title_para = doc.add_paragraph() title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER title_run = title_para.add_run('免责声明') title_run.font.size = Pt(16) title_run.font.bold = True title_run.font.name = '黑体' title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体') doc.add_paragraph() # 正文各段(跳过第一行标题,已单独渲染) body_lines = DISCLAIMER_TEXT.split('\n')[2:] # 跳过"免责声明"和空行 for line in body_lines: p = doc.add_paragraph() stripped = line.strip() # 小标题行(非空且后面没有缩进,即段落标题) is_section_title = bool(stripped) and not line.startswith(' ') and not line.startswith('\u3000') run = p.add_run(stripped if stripped else '') if is_section_title and stripped: run.font.bold = True run.font.size = Pt(11) else: run.font.size = Pt(10.5) run.font.name = 'Times New Roman' run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体') p.paragraph_format.space_after = Pt(4) _set_line_spacing_15(p) doc.add_page_break() def _add_toc_tree_page(doc: Document, sections: list) -> None: """标题页之后插入树状目录(按 level 缩进;静态文本,不含 Word 目录域)。""" toc_heading = doc.add_paragraph() toc_heading.alignment = WD_ALIGN_PARAGRAPH.CENTER tr = toc_heading.add_run('目录') tr.font.size = Pt(16) tr.font.bold = True tr.font.name = '黑体' tr._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体') doc.add_paragraph() for row in sections: section_number, title, level, _, _, _ = row level = min(int(level), 4) text = format_heading_display(level, str(section_number or ''), str(title or '')) p = doc.add_paragraph() p.paragraph_format.left_indent = Cm(0.75 * max(0, level - 1)) p.paragraph_format.space_after = Pt(3) run = p.add_run(text) run.font.size = Pt(12) run.font.name = '宋体' run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体') doc.add_page_break() def _add_word_field(paragraph, instruction: str): """在段落中插入 Word 域(如 PAGE / NUMPAGES)。""" run = paragraph.add_run() fld_begin = OxmlElement('w:fldChar') fld_begin.set(qn('w:fldCharType'), 'begin') run._r.append(fld_begin) run = paragraph.add_run() instr = OxmlElement('w:instrText') instr.set(qn('xml:space'), 'preserve') instr.text = instruction run._r.append(instr) run = paragraph.add_run() fld_sep = OxmlElement('w:fldChar') fld_sep.set(qn('w:fldCharType'), 'separate') run._r.append(fld_sep) # 该文本必须位于 field begin/separate/end 之间,作为域初始显示值 text_run = paragraph.add_run("1") text_run.font.name = 'Times New Roman' _safe_set_eastasia(text_run, '宋体') run = paragraph.add_run() fld_end = OxmlElement('w:fldChar') fld_end.set(qn('w:fldCharType'), 'end') run._r.append(fld_end) def _apply_header_footer_template(paragraph, template: str): """将 {page}/{total} 模板渲染为 Word 可计算域。""" paragraph.clear() parts = _HF_TOKEN_RE.split(template or '') if not parts: parts = [template or ''] for part in parts: token = (part or '').lower() if token == '{page}': _add_word_field(paragraph, 'PAGE') elif token == '{total}': _add_word_field(paragraph, 'NUMPAGES') elif part: run = paragraph.add_run(part) run.font.name = 'Times New Roman' _safe_set_eastasia(run, '宋体') paragraph.alignment = WD_ALIGN_PARAGRAPH.CENTER def _sanitize_header_text(text: str) -> str: """清理页眉中的品牌默认文案,避免导出出现固定字符。""" cleaned = _HEADER_BRAND_RE.sub('', text or '') return cleaned.strip() def _build_document(bid_title: str, sections, preset=None) -> Document: if preset is None: preset = get_preset('standard') doc = Document() # ── 页面设置 (from preset, overrides hardcoded values) ───────────────────── section_obj = doc.sections[0] m = preset.get('margins_cm', {'top': 2.5, 'bottom': 2.5, 'left': 3.0, 'right': 2.5}) section_obj.page_width = Cm(21) section_obj.page_height = Cm(29.7) section_obj.left_margin = Cm(m.get('left', 3.0)) section_obj.right_margin = Cm(m.get('right', 2.5)) section_obj.top_margin = Cm(m.get('top', 2.5)) section_obj.bottom_margin = Cm(m.get('bottom', 2.5)) # Header / Footer from preset header_text = _sanitize_header_text((preset.get('header_text') or '').strip()) footer_text = (preset.get('footer_text') or '').strip() # 页眉默认留空;页脚仍保留分页模板 if not footer_text: footer_text = '第 {page} 页 / 共 {total} 页' header = section_obj.header if header.paragraphs: if header_text: _apply_header_footer_template(header.paragraphs[0], header_text) else: header.paragraphs[0].clear() footer = section_obj.footer if footer.paragraphs: _apply_header_footer_template(footer.paragraphs[0], footer_text) # ── 免责声明页(第一页)───────────────────────────────────────────── _add_disclaimer_page(doc) # ── 标题页 ────────────────────────────────────────────────────────── title_para = doc.add_paragraph() title_para.alignment = WD_ALIGN_PARAGRAPH.CENTER title_run = title_para.add_run(bid_title) title_run.font.size = Pt(22) title_run.font.bold = True title_run.font.color.rgb = RGBColor(0x1a, 0x56, 0xdb) title_run.font.name = preset.get('heading_font', '黑体') title_run._element.rPr.rFonts.set(qn('w:eastAsia'), preset.get('heading_font', '黑体')) doc.add_paragraph() date_para = doc.add_paragraph() date_para.alignment = WD_ALIGN_PARAGRAPH.CENTER date_run = date_para.add_run(datetime.now().strftime('%Y年%m月')) date_run.font.size = Pt(14) date_run.font.name = '宋体' date_run._element.rPr.rFonts.set(qn('w:eastAsia'), '宋体') doc.add_page_break() # ── 树状目录页(标题页后、正文前)────────────────────────────────── _add_toc_tree_page(doc, sections) # ── 章节内容 ───────────────────────────────────────────────────────── for row in sections: section_number, title, level, is_leaf, content, intro = row level = min(int(level), 4) # 添加标题(带完整目录号,使用preset字体) heading_text = format_heading_display(level, str(section_number or ''), str(title or '')) heading = doc.add_heading(level=level) heading.clear() run = heading.add_run(heading_text) _, font_size, bold = LEVEL_STYLES.get(level, ('Heading 4', 12, False)) run.font.size = Pt(preset.get(f'heading{level}_size_pt', font_size)) run.font.bold = bold run.font.name = preset.get('heading_font', '黑体' if level <= 2 else '宋体') run._element.rPr.rFonts.set(qn('w:eastAsia'), preset.get('heading_font', '黑体' if level <= 2 else '宋体')) attachment_only = bool(att_sec.is_attachment_only_section(str(title or ''), att_sec.get_attachment_rules_cached())) # 章节引言(非叶节点) if intro and intro.strip(): _add_body_paragraphs(doc, intro, preset, attachment_only=attachment_only, section_title=str(title or '')) # 正文内容(叶节点) if content and content.strip(): _add_body_paragraphs(doc, content, preset, attachment_only=attachment_only, section_title=str(title or '')) return doc def _set_line_spacing_15(paragraph): """将段落设为 1.5 倍行距(Word 中的 WD_LINE_SPACING.MULTIPLE × 1.5)""" from docx.oxml.ns import qn as _qn pPr = paragraph._element.get_or_add_pPr() spacing = pPr.find(_qn('w:spacing')) if spacing is None: spacing = OxmlElement('w:spacing') pPr.append(spacing) spacing.set(_qn('w:line'), '360') # 240 × 1.5 = 360 twips spacing.set(_qn('w:lineRule'), 'auto') def _safe_float(val, default): try: return float(val) except (TypeError, ValueError): return default def _safe_int(val, default): try: return int(val) except (TypeError, ValueError): return default def _enforce_format_constraints(preset: dict) -> dict: """格式限定:对关键排版参数做约束,避免导出失控。""" p = copy.deepcopy(preset or {}) margins = p.get('margins_cm') or {} top = _safe_float(margins.get('top', 2.54), 2.54) bottom = _safe_float(margins.get('bottom', 2.54), 2.54) left = _safe_float(margins.get('left', 3.18), 3.18) right = _safe_float(margins.get('right', 3.18), 3.18) p['margins_cm'] = { 'top': min(max(top, 1.5), 5.0), 'bottom': min(max(bottom, 1.5), 5.0), 'left': min(max(left, 2.0), 5.0), 'right': min(max(right, 2.0), 5.0), } p['heading_font'] = p.get('heading_font') or '黑体' p['body_font'] = p.get('body_font') or '宋体' p['heading1_size_pt'] = _safe_int(p.get('heading1_size_pt', 16), 16) p['heading2_size_pt'] = _safe_int(p.get('heading2_size_pt', 14), 14) p['body_size_pt'] = _safe_float(p.get('body_size_pt', 12), 12) # 支持倍率行距或固定磅值(>3 视为固定 pt) p['body_line_spacing'] = _safe_float(p.get('body_line_spacing', 1.5), 1.5) p['body_indent_pt'] = _safe_float(p.get('body_indent_pt', 24), 24) return p def _build_html_snapshot_for_check(bid_title: str, sections, preset: dict) -> str: """基于当前导出配置生成 HTML 快照,供格式清标校验。""" spec = docx_to_html_spec(preset) margins = spec.get('margins', {}) margin_css = ( f"{margins.get('top', 2.54)}cm {margins.get('right', 3.18)}cm " f"{margins.get('bottom', 2.54)}cm {margins.get('left', 3.18)}cm" ) heading_css = spec.get('heading1', 'font-size:16pt;font-family:黑体;font-weight:bold;') body_css = spec.get( 'body', f"font-size:{preset.get('body_size_pt', 12)}pt;font-family:{preset.get('body_font', '宋体')};" "line-height:26pt;text-indent:2em;color:#000;text-align:justify;" ) table_header_css = spec.get('tableHeader', 'font-size:12pt;font-family:宋体;font-weight:bold;text-align:center;') table_body_css = spec.get('tableBody', 'font-size:12pt;font-family:宋体;text-align:left;') sample_title = sections[0][1] if sections else bid_title sample_content = '' if sections: sample_content = (sections[0][4] or sections[0][5] or '').strip() if not sample_content: sample_content = '本章内容用于导出格式校验。' lines = [x.strip() for x in sample_content.split('\n') if x.strip()] para_html = ''.join([f'

{ln}

' for ln in lines[:3]]) or f'

{sample_content}

' return ( f'' f'

{sample_title}

' f'{para_html}' f'' f'
表头
表格内容
' f'' ) def _run_docx_html_roundtrip_check(bid_title: str, sections, preset: dict) -> dict: html = _build_html_snapshot_for_check(bid_title, sections, preset) return check_technical_bid(html) def _repair_preset_from_check(preset: dict, check_result: dict) -> dict: """根据 HTML 格式检查结果回写修正 preset,再用于最终 Docx 导出。""" p = copy.deepcopy(preset or {}) violations = check_result.get('violations', []) or [] violated_rules = {v.get('rule') for v in violations} if '页面设置' in violated_rules: p['margins_cm'] = {'top': 2.54, 'bottom': 2.54, 'left': 3.18, 'right': 3.18} if '标题格式' in violated_rules: p['heading_font'] = '黑体' p['heading1_size_pt'] = 16 p['heading2_size_pt'] = 14 if '正文格式' in violated_rules: p['body_font'] = '宋体' p['body_size_pt'] = 14 p['body_line_spacing'] = 26 p['body_indent_pt'] = 28 return _enforce_format_constraints(p) # ── 图/表标记解析 ───────────────────────────────────────────────────────── _BLOCK_PATTERN = re.compile( r'\[FIGURE:([^\]]+)\](.*?)\[/FIGURE\]' r'|\[TABLE:([^\]]+)\](.*?)\[/TABLE\]', re.DOTALL ) def _strip_empty_lines_keep_indent(text: str) -> str: """仅移除首尾空行,保留行内与行首缩进,避免图示图素偏移。""" if text is None: return '' lines = text.replace('\r\n', '\n').replace('\r', '\n').split('\n') while lines and lines[0].strip() == '': lines.pop(0) while lines and lines[-1].strip() == '': lines.pop() return '\n'.join(lines) def _split_content_blocks(text: str) -> list: """ 将章节正文拆分为有序内容块列表: {'type': 'text', 'content': '...'} {'type': 'figure', 'title': '...', 'content': '...'} {'type': 'table', 'title': '...', 'content': '...'} """ blocks = [] last = 0 for m in _BLOCK_PATTERN.finditer(text): if m.start() > last: blocks.append({'type': 'text', 'content': text[last:m.start()]}) if m.group(1) is not None: blocks.append({'type': 'figure', 'title': m.group(1).strip(), 'content': _strip_empty_lines_keep_indent(m.group(2))}) else: blocks.append({'type': 'table', 'title': m.group(3).strip(), 'content': m.group(4).strip()}) last = m.end() if last < len(text): blocks.append({'type': 'text', 'content': text[last:]}) return blocks def _set_para_shading(para, hex_fill: str): """为段落设置背景填充色""" pPr = para._element.get_or_add_pPr() shd = OxmlElement('w:shd') shd.set(qn('w:val'), 'clear') shd.set(qn('w:color'), 'auto') shd.set(qn('w:fill'), hex_fill) pPr.append(shd) def _attachment_header_fill_hex(preset) -> str: """附件表头底色:暗标模式下用浅灰,禁止彩色表头。""" if preset and preset.get('dark_bid_grayscale'): return 'DCDCDC' return 'D6E4F7' def _attachment_figure_box_fill_hex(preset) -> str: """附件图示回退文字框底色。""" if preset and preset.get('dark_bid_grayscale'): return 'F0F0F0' return 'EFF3FB' def _set_cell_bg(cell, hex_fill: str): """为表格单元格设置背景色""" tc = cell._tc tcPr = tc.get_or_add_tcPr() shd = OxmlElement('w:shd') shd.set(qn('w:val'), 'clear') shd.set(qn('w:color'), 'auto') shd.set(qn('w:fill'), hex_fill) tcPr.append(shd) def _set_cell_padding(cell, pt_value: float): """设置表格单元格四侧内边距(单位:磅)""" tc = cell._tc tcPr = tc.get_or_add_tcPr() tcMar = OxmlElement('w:tcMar') val = str(int(pt_value * 20)) # pt → twips(1pt = 20 twips) for side in ('top', 'left', 'bottom', 'right'): node = OxmlElement(f'w:{side}') node.set(qn('w:w'), val) node.set(qn('w:type'), 'dxa') tcMar.append(node) tcPr.append(tcMar) def _normalize_table_layout(table, align_center: bool = True): """统一表格布局,消除单侧偏移。""" try: table.alignment = WD_TABLE_ALIGNMENT.CENTER if align_center else WD_TABLE_ALIGNMENT.LEFT except Exception: pass try: table.autofit = True except Exception: pass for row in table.rows: for cell in row.cells: _set_cell_padding(cell, 0) for para in cell.paragraphs: para.paragraph_format.space_before = Pt(0) para.paragraph_format.space_after = Pt(0) para.paragraph_format.first_line_indent = Pt(0) para.paragraph_format.left_indent = Pt(0) para.paragraph_format.right_indent = Pt(0) def _safe_set_eastasia(run, font_name: str): """安全设置东亚字体,确保 rPr 已存在""" _ = run.font.size # 触发 rPr 创建 try: run._element.rPr.rFonts.set(qn('w:eastAsia'), font_name) except Exception: pass def _add_block_caption(doc: Document, prefix: str, title: str): """添加图/表居中加粗标题行""" cap = doc.add_paragraph() cap.alignment = WD_ALIGN_PARAGRAPH.CENTER cap.paragraph_format.space_before = Pt(8) cap.paragraph_format.space_after = Pt(3) run = cap.add_run(f'{prefix}:{title}') run.font.bold = True run.font.size = Pt(11) run.font.name = 'Times New Roman' _safe_set_eastasia(run, '黑体') def _add_figure_block( doc: Document, title: str, content: str, preset=None, show_caption: bool = True, attachment_only: bool = False, ): """ 附件类图示:默认调用通义 qwen-image-2.0-pro 文生图并插入 Word;失败则回退为文字示意框。 正文图示:保持文字框(不调用文生图)。 """ if preset is None: preset = get_preset('standard') use_qwen = bool( attachment_only and preset.get('attachment_figure_use_qwen', True) ) if use_qwen: try: from utils.qwen_image_client import generate_attachment_figure_png with config.llm_call(): png, err = generate_attachment_figure_png( title, content, grayscale=bool(preset.get('dark_bid_grayscale')), ) if png: if show_caption: _add_block_caption(doc, '图', title) pic_para = doc.add_paragraph() pic_para.alignment = WD_ALIGN_PARAGRAPH.CENTER pic_para.paragraph_format.space_before = Pt(4) pic_para.paragraph_format.space_after = Pt(6) pr = pic_para.add_run() w_cm = float(preset.get('attachment_figure_width_cm', 15) or 15) pr.add_picture(io.BytesIO(png), width=Cm(min(max(w_cm, 8), 18))) sp = doc.add_paragraph() sp.paragraph_format.space_after = Pt(8) return if err: logger.warning('附件文生图未返回图片: %s', err) except Exception as e: logger.warning('附件文生图异常,回退文字框: %s', e) if show_caption: _add_block_caption(doc, '图', title) render_method = str(preset.get('figure_render_method', 'fixed_box')).strip().lower() if render_method not in ('fixed_box',): render_method = 'fixed_box' lines = content.replace('\r\n', '\n').replace('\r', '\n').split('\n') # 单格表格:四周边框 + 淡蓝灰背景 tbl = doc.add_table(rows=1, cols=1) tbl.style = 'Table Grid' _normalize_table_layout(tbl, align_center=True) cell = tbl.cell(0, 0) _set_cell_bg(cell, _attachment_figure_box_fill_hex(preset)) # 暗标时为浅灰 _set_cell_padding(cell, 0 if render_method == 'fixed_box' else 5) for i, line in enumerate(lines): if i == 0: para = cell.paragraphs[0] para.clear() else: para = cell.add_paragraph() para.paragraph_format.space_before = Pt(0) para.paragraph_format.space_after = Pt(0) para.paragraph_format.first_line_indent = Pt(0) para.paragraph_format.left_indent = Pt(0) para.paragraph_format.right_indent = Pt(0) para.paragraph_format.line_spacing = 1.0 run = para.add_run(line if line else ' ') run.font.size = Pt(9.5) run.font.name = 'Consolas' _safe_set_eastasia(run, 'Consolas') # 图示后空行 sp = doc.add_paragraph() sp.paragraph_format.space_after = Pt(8) # 劳动力计划表:与「按工程施工阶段投入劳动力情况」双层表头一致(第一列工种纵跨两行) _LABOR_PLAN_STAGE_HEADERS = ( '施工准备阶段', '建筑工程施工阶段', '临时工程施工阶段', '其他附属相关工程', '收尾阶段', ) _LABOR_PLAN_PARENT_HEADER = '按工程施工阶段投入劳动力情况' def _normalize_labor_plan_header_cell(s: str) -> str: t = (s or '').strip().replace('**', '') return re.sub(r'[((]\s*人\s*[))]', '', t) def _is_labor_plan_double_header_table(raw_rows: list) -> bool: if not raw_rows or len(raw_rows[0]) != 6: return False if raw_rows[0][0].strip() != '工种': return False h = raw_rows[0] got = tuple(_normalize_labor_plan_header_cell(h[i]) for i in range(1, 6)) return got == _LABOR_PLAN_STAGE_HEADERS def _add_labor_plan_word_table( doc: Document, title: str, raw_rows: list, show_caption: bool = True, preset=None, ): """劳动力计划表:工种列纵跨两行 + 右侧顶层合并为「按工程施工阶段投入劳动力情况」。""" if show_caption: _add_block_caption(doc, '表', title) if preset is None: preset = get_preset('standard') data_rows = raw_rows[1:] col_count = 6 data_rows = [r + [''] * (col_count - len(r)) for r in data_rows] n = len(data_rows) if n < 1: return tbl = doc.add_table(rows=2 + n, cols=col_count) tbl.style = 'Table Grid' _normalize_table_layout(tbl, align_center=True) # 工种:纵跨第 0、1 行 c_00 = tbl.cell(0, 0) c_00.merge(tbl.cell(1, 0)) p = c_00.paragraphs[0] p.clear() p.alignment = WD_ALIGN_PARAGRAPH.CENTER run = p.add_run('工种') run.font.bold = True run.font.size = Pt(10) run.font.name = 'Times New Roman' _safe_set_eastasia(run, '宋体') _set_cell_bg(c_00, _attachment_header_fill_hex(preset)) # 顶层右区:跨列标题 c01 = tbl.cell(0, 1) c01.merge(tbl.cell(0, 5)) p2 = c01.paragraphs[0] p2.clear() p2.alignment = WD_ALIGN_PARAGRAPH.CENTER r2 = p2.add_run(_LABOR_PLAN_PARENT_HEADER) r2.font.bold = True r2.font.size = Pt(10) r2.font.name = 'Times New Roman' _safe_set_eastasia(r2, '宋体') _set_cell_bg(c01, _attachment_header_fill_hex(preset)) # 第二行:各施工阶段子表头 for j, st in enumerate(_LABOR_PLAN_STAGE_HEADERS): cell = tbl.cell(1, j + 1) para = cell.paragraphs[0] para.clear() para.alignment = WD_ALIGN_PARAGRAPH.CENTER ru = para.add_run(st) ru.font.bold = True ru.font.size = Pt(10) ru.font.name = 'Times New Roman' _safe_set_eastasia(ru, '宋体') _set_cell_bg(cell, _attachment_header_fill_hex(preset)) # 数据行(人数,居中对齐) for i, row_data in enumerate(data_rows): ridx = 2 + i first = (row_data[0] or '').strip().replace('**', '') is_total = first in ('合计', '总计', '小计') for j in range(col_count): cell = tbl.cell(ridx, j) para = cell.paragraphs[0] para.clear() para.alignment = WD_ALIGN_PARAGRAPH.CENTER txt = (row_data[j] if j < len(row_data) else '').replace('**', '').strip() ru = para.add_run(txt) ru.font.size = Pt(10) ru.font.bold = is_total ru.font.name = 'Times New Roman' _safe_set_eastasia(ru, '宋体') sp = doc.add_paragraph() sp.paragraph_format.space_after = Pt(6) def _add_word_table( doc: Document, title: str, content: str, show_caption: bool = True, allow_text_fallback: bool = True, preset=None, ): """将 Markdown 表格解析并渲染为 Word 表格""" if preset is None: preset = get_preset('standard') # 解析 markdown 行,过滤掉分隔行(|---|) raw_rows = [] for line in content.strip().split('\n'): line = line.strip() if not line: continue if re.match(r'^\|[\s\-:| ]+\|$', line): continue # 分隔行 if line.startswith('|') and line.endswith('|'): cells = [c.strip() for c in line[1:-1].split('|')] raw_rows.append(cells) if not raw_rows: # 没有解析到有效行时,降级为普通文本 if show_caption: _add_block_caption(doc, '表', title) if allow_text_fallback: _add_plain_text(doc, content) return col_count = max(len(r) for r in raw_rows) rows = [r + [''] * (col_count - len(r)) for r in raw_rows] if show_caption: _add_block_caption(doc, '表', title) if col_count == 6 and _is_labor_plan_double_header_table(rows): # 已添加 caption,与 _add_labor_plan_word_table 的 show_caption 一致 _add_labor_plan_word_table(doc, title, rows, show_caption=False, preset=preset) return table = doc.add_table(rows=len(rows), cols=col_count) table.style = 'Table Grid' _normalize_table_layout(table, align_center=True) for i, row_data in enumerate(rows): for j, cell_text in enumerate(row_data): cell = table.cell(i, j) para = cell.paragraphs[0] para.clear() para.alignment = WD_ALIGN_PARAGRAPH.CENTER if i == 0 else WD_ALIGN_PARAGRAPH.LEFT run = para.add_run(cell_text) run.font.size = Pt(10) run.font.bold = (i == 0) run.font.name = 'Times New Roman' _safe_set_eastasia(run, '宋体') if i == 0: _set_cell_bg(cell, _attachment_header_fill_hex(preset)) # 表格后空行 sp = doc.add_paragraph() sp.paragraph_format.space_after = Pt(6) def _add_plain_text(doc: Document, text: str, preset=None): """添加普通文本段落(内部辅助),支持preset字体/大小""" if preset is None: preset = get_preset('standard') for line in text.split('\n'): line = line.strip() if not line: continue p = doc.add_paragraph() p.paragraph_format.first_line_indent = Pt(_safe_float(preset.get('body_indent_pt', 24), 24)) p.paragraph_format.space_after = Pt(6) spacing = _safe_float(preset.get('body_line_spacing', 1.5), 1.5) if spacing > 3: p.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY p.paragraph_format.line_spacing = Pt(spacing) else: p.paragraph_format.line_spacing = spacing run = p.add_run(line) run.font.size = Pt(preset.get('body_size_pt', 12)) run.font.name = preset.get('body_font', 'Times New Roman') _safe_set_eastasia(run, preset.get('body_font', '宋体')) def _add_body_paragraphs(doc: Document, text: str, preset=None, attachment_only: bool = False, section_title: str = ''): """ 将正文文本分段渲染,自动识别并处理图示 [FIGURE:...] 和表格 [TABLE:...] 标记。 支持从preset读取figure/table开关。 暗标模式下正文/引言不输出图、表块(附件章节仍导出图/表)。 """ if preset is None: preset = get_preset('standard') dark_strip = bool(preset.get('dark_bid_body_strip_charts')) blocks = _split_content_blocks(text) if attachment_only: # 附件章节:优先导出图/表块;无块时回退为正文说明,避免 Word 仅见标题 figure_table_blocks = [b for b in blocks if b['type'] in ('figure', 'table')] if not figure_table_blocks: text_merge = '\n'.join( (b.get('content') or '').strip() for b in blocks if b['type'] == 'text' and (b.get('content') or '').strip() ) blocks = [{'type': 'text', 'content': text_merge}] if text_merge else [] else: rules = att_sec.get_attachment_rules_cached() preferred_kind = att_sec.pick_single_figure_or_table( section_title or '', bool(preset.get('figure_enabled', True)), bool(preset.get('table_enabled', True)), rules, ) if preferred_kind in ('figure', 'table'): preferred_blocks = [b for b in figure_table_blocks if b['type'] == preferred_kind] blocks = preferred_blocks[:1] if preferred_blocks else figure_table_blocks[:1] else: blocks = figure_table_blocks[:1] for block in blocks: if dark_strip and not attachment_only and block['type'] in ('figure', 'table'): continue # 附件章节:图/表为投标要件,须导出;不因项目「正文图表」开关而隐藏 show_fig = block['type'] == 'figure' and ( attachment_only or preset.get('figure_enabled', True) ) show_tbl = block['type'] == 'table' and ( attachment_only or preset.get('table_enabled', True) ) if show_fig: _add_figure_block( doc, block['title'], block['content'], preset, show_caption=not attachment_only, attachment_only=attachment_only, ) elif show_tbl: _add_word_table( doc, block['title'], block['content'], show_caption=not attachment_only, allow_text_fallback=not attachment_only, preset=preset, ) elif not attachment_only: _add_plain_text(doc, block['content'], preset) elif attachment_only and block['type'] == 'text' and (block.get('content') or '').strip(): # 无图块时的说明性文字(如开关提示)仍输出,避免仅见标题 _add_plain_text(doc, block['content'], preset)