737 lines
29 KiB
Python
737 lines
29 KiB
Python
"""
|
||
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'<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 _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)
|
||
|
||
|