"""
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
# 导出闭环:先做 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
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''
)
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,
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)
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, '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)
# 劳动力计划表:与「按工程施工阶段投入劳动力情况」双层表头一致(第一列工种纵跨两行)
_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, 'D6E4F7')
# 顶层右区:跨列标题
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, 'D6E4F7')
# 第二行:各施工阶段子表头
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, 'D6E4F7')
# 数据行(人数,居中对齐)
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):
"""将 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)
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=None)
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, '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')
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,
)
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)