2026-04-24 18:53:49 +08:00

953 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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'<p style="{body_css}">{ln}</p>' for ln in lines[:3]]) or f'<p style="{body_css}">{sample_content}</p>'
return (
f'<!doctype html><html><head><style>'
f'@page{{size:A4 portrait;margin:{margin_css};}}'
f'table{{border-collapse:collapse;width:100%;}}td,th{{border:1px solid #d1d5db;padding:4pt;}}'
f'</style></head><body style="margin:{margin_css};">'
f'<h2 style="{heading_css}">{sample_title}</h2>'
f'{para_html}'
f'<table><thead><tr><th style="{table_header_css}">表头</th></tr></thead>'
f'<tbody><tr><td style="{table_body_css}">表格内容</td></tr></tbody></table>'
f'</body></html>'
)
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 → twips1pt = 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)