""" Word 文档导出模块 """ 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) " "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]) # 获取标书大纲文本(用于标题页) 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 # 导出闭环:先做 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 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 _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): """ 将图示内容渲染为带边框 + 背景色的文字图示框。 使用单格表格(Table Grid 样式)实现四周边框,比纯段落背景更专业。 """ if show_caption: _add_block_caption(doc, '图', title) if preset is None: preset = get_preset('standard') 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, 'EFF3FB') # 淡蓝灰背景 _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) def _add_word_table(doc: Document, title: str, content: str, show_caption: bool = True, allow_text_fallback: bool = True): """将 Markdown 表格解析并渲染为 Word 表格""" # 解析 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) 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, 'D6E4F7') # 浅蓝表头 # 表格后空行 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') blocks = _split_content_blocks(text) if attachment_only: # 附件章节导出硬限制:仅保留图/表块,其余文字全部忽略 figure_table_blocks = [b for b in blocks if b['type'] in ('figure', 'table')] 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 block['type'] == 'figure' and preset.get('figure_enabled', True): _add_figure_block(doc, block['title'], block['content'], preset, show_caption=not attachment_only) elif block['type'] == 'table' and preset.get('table_enabled', True): _add_word_table( doc, block['title'], block['content'], show_caption=not attachment_only, allow_text_fallback=not attachment_only, ) elif not attachment_only: _add_plain_text(doc, block['content'], preset)