2026-04-23 14:37:19 +08:00

438 lines
16 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 os
import re
import sqlite3
import logging
from datetime import datetime
from docx import Document
from docx.shared import Pt, Cm, RGBColor
from docx.enum.text import WD_ALIGN_PARAGRAPH
from docx.oxml import OxmlElement
from docx.oxml.ns import qn
import config
from utils.outline_numbering import format_heading_display
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),
}
def export_to_word(db_path: str, project_id: int) -> str:
"""
生成 Word 文档并保存到 data/exports/,返回文件名。
"""
conn = sqlite3.connect(db_path)
try:
# 获取项目信息
cur = conn.cursor()
cur.execute("SELECT name FROM projects WHERE id=?", (project_id,))
project = cur.fetchone()
if not project:
raise ValueError(f'项目 {project_id} 不存在')
project_name = project[0]
# 获取标书大纲文本(用于标题页)
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()
doc = _build_document(bid_title, sections)
# 保存文件
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)
logger.info(f'导出完成: {filepath}')
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 _build_document(bid_title: str, sections) -> Document:
doc = Document()
# ── 页面设置 ─────────────────────────────────────────────────────────
section_obj = doc.sections[0]
section_obj.page_width = Cm(21)
section_obj.page_height = Cm(29.7)
section_obj.left_margin = Cm(3)
section_obj.right_margin = Cm(2.5)
section_obj.top_margin = Cm(2.5)
section_obj.bottom_margin = Cm(2.5)
# ── 免责声明页(第一页)─────────────────────────────────────────────
_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 = '黑体'
title_run._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体')
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)
# 添加标题(带完整目录号)
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(font_size)
run.font.bold = bold
run.font.name = '黑体' if level <= 2 else '宋体'
run._element.rPr.rFonts.set(qn('w:eastAsia'), '黑体' if level <= 2 else '宋体')
# 章节引言(非叶节点)
if intro and intro.strip():
_add_body_paragraphs(doc, intro)
# 正文内容(叶节点)
if content and content.strip():
_add_body_paragraphs(doc, content)
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')
# ── 图/表标记解析 ─────────────────────────────────────────────────────────
_BLOCK_PATTERN = re.compile(
r'\[FIGURE:([^\]]+)\](.*?)\[/FIGURE\]'
r'|\[TABLE:([^\]]+)\](.*?)\[/TABLE\]',
re.DOTALL
)
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': m.group(2).strip()})
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 → 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 _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):
"""
将图示内容渲染为带边框 + 背景色的文字图示框。
使用单格表格Table Grid 样式)实现四周边框,比纯段落背景更专业。
"""
_add_block_caption(doc, '', title)
lines = content.split('\n')
# 单格表格:四周边框 + 淡蓝灰背景
tbl = doc.add_table(rows=1, cols=1)
tbl.style = 'Table Grid'
cell = tbl.cell(0, 0)
_set_cell_bg(cell, 'EFF3FB') # 淡蓝灰背景
_set_cell_padding(cell, 5) # 内边距 5pt
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(1)
run = para.add_run(line if line else ' ')
run.font.size = Pt(9.5)
run.font.name = 'Courier New'
_safe_set_eastasia(run, '宋体')
# 图示后空行
sp = doc.add_paragraph()
sp.paragraph_format.space_after = Pt(8)
def _add_word_table(doc: Document, title: str, content: str):
"""将 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:
# 没有解析到有效行时,降级为普通文本
_add_block_caption(doc, '', title)
_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]
_add_block_caption(doc, '', title)
table = doc.add_table(rows=len(rows), cols=col_count)
table.style = 'Table Grid'
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):
"""添加普通文本段落(内部辅助)"""
for line in text.split('\n'):
line = line.strip()
if not line:
continue
p = doc.add_paragraph()
p.paragraph_format.first_line_indent = Pt(24)
p.paragraph_format.space_after = Pt(6)
_set_line_spacing_15(p)
run = p.add_run(line)
run.font.size = Pt(12)
run.font.name = 'Times New Roman'
_safe_set_eastasia(run, '宋体')
def _add_body_paragraphs(doc: Document, text: str):
"""
将正文文本分段渲染,自动识别并处理图示 [FIGURE:...] 和表格 [TABLE:...] 标记。
"""
for block in _split_content_blocks(text):
if block['type'] == 'figure':
_add_figure_block(doc, block['title'], block['content'])
elif block['type'] == 'table':
_add_word_table(doc, block['title'], block['content'])
else:
_add_plain_text(doc, block['content'])