378 lines
12 KiB
Python
378 lines
12 KiB
Python
|
|
"""
|
|||
|
|
Word文档格式化模块
|
|||
|
|
按照GB/T 9704-2012标准格式化金融研究报告
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from docx import Document
|
|||
|
|
from docx.shared import Pt, Mm, RGBColor
|
|||
|
|
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
|||
|
|
from docx.oxml.ns import qn
|
|||
|
|
from docx.oxml import OxmlElement
|
|||
|
|
|
|||
|
|
|
|||
|
|
class GBT9704Formatter:
|
|||
|
|
"""
|
|||
|
|
GB/T 9704-2012党政机关公文格式化器
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
# 字体映射(号数 -> 磅值)
|
|||
|
|
FONT_SIZES = {
|
|||
|
|
'三号': 16,
|
|||
|
|
'四号': 14,
|
|||
|
|
'小四': 12,
|
|||
|
|
'五号': 10.5
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def __init__(self, doc: Document):
|
|||
|
|
"""
|
|||
|
|
初始化格式化器
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
doc: Document对象
|
|||
|
|
"""
|
|||
|
|
self.doc = doc
|
|||
|
|
self._setup_page()
|
|||
|
|
|
|||
|
|
def _setup_page(self):
|
|||
|
|
"""设置页面格式"""
|
|||
|
|
for section in self.doc.sections:
|
|||
|
|
# 页面边距(单位:毫米)
|
|||
|
|
section.top_margin = Mm(37)
|
|||
|
|
section.bottom_margin = Mm(35)
|
|||
|
|
section.left_margin = Mm(28)
|
|||
|
|
section.right_margin = Mm(26)
|
|||
|
|
|
|||
|
|
def set_font(self, run, font_name: str, size: int = None, bold: bool = None, color: RGBColor = None):
|
|||
|
|
"""
|
|||
|
|
设置字体
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
run: Run对象
|
|||
|
|
font_name: 字体名称(中文字体)
|
|||
|
|
size: 字号(磅值),如 16, 14, 12
|
|||
|
|
bold: 是否加粗
|
|||
|
|
color: 字体颜色
|
|||
|
|
"""
|
|||
|
|
run.font.name = font_name
|
|||
|
|
run._element.rPr.rFonts.set(qn('w:eastAsia'), font_name)
|
|||
|
|
|
|||
|
|
if size is not None:
|
|||
|
|
run.font.size = Pt(size)
|
|||
|
|
if bold is not None:
|
|||
|
|
run.font.bold = bold
|
|||
|
|
if color is not None:
|
|||
|
|
run.font.color.rgb = color
|
|||
|
|
|
|||
|
|
def add_heading(self, text: str, level: int = 1, font_name: str = '黑体', size: int = 16):
|
|||
|
|
"""
|
|||
|
|
添加标题
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
text: 标题文本
|
|||
|
|
level: 标题级别(1-3)
|
|||
|
|
font_name: 字体名称
|
|||
|
|
size: 字号
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Paragraph对象
|
|||
|
|
"""
|
|||
|
|
if level == 1:
|
|||
|
|
heading = self.doc.add_heading(text, level=1)
|
|||
|
|
run = heading.runs[0]
|
|||
|
|
self.set_font(run, font_name, size, bold=True)
|
|||
|
|
heading.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
|||
|
|
heading.paragraph_format.space_before = Pt(0)
|
|||
|
|
heading.paragraph_format.space_after = Pt(0)
|
|||
|
|
return heading
|
|||
|
|
else:
|
|||
|
|
# 二级、三级标题使用"一、"、"(一)"等格式
|
|||
|
|
prefix_map = {1: '一、', 2: '(一)', 3: '1.'}
|
|||
|
|
prefix = prefix_map.get(level, '')
|
|||
|
|
|
|||
|
|
paragraph = self.doc.add_paragraph(f"{prefix}{text}")
|
|||
|
|
run = paragraph.runs[0]
|
|||
|
|
self.set_font(run, font_name, size, bold=True)
|
|||
|
|
|
|||
|
|
# 段落格式
|
|||
|
|
paragraph.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
|||
|
|
paragraph.paragraph_format.space_before = Pt(0)
|
|||
|
|
paragraph.paragraph_format.space_after = Pt(0)
|
|||
|
|
paragraph.paragraph_format.line_spacing = Pt(25)
|
|||
|
|
paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
|||
|
|
|
|||
|
|
return paragraph
|
|||
|
|
|
|||
|
|
def add_paragraph(self, text: str,
|
|||
|
|
font_name: str = '仿宋',
|
|||
|
|
size: int = 14,
|
|||
|
|
first_line_indent: bool = True,
|
|||
|
|
alignment: str = 'justify'):
|
|||
|
|
"""
|
|||
|
|
添加正文段落
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
text: 段落文本
|
|||
|
|
font_name: 字体名称
|
|||
|
|
size: 字号(磅值)
|
|||
|
|
first_line_indent: 是否首行缩进
|
|||
|
|
alignment: 对齐方式('left', 'center', 'right', 'justify')
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Paragraph对象
|
|||
|
|
"""
|
|||
|
|
paragraph = self.doc.add_paragraph(text)
|
|||
|
|
run = paragraph.runs[0]
|
|||
|
|
self.set_font(run, font_name, size)
|
|||
|
|
|
|||
|
|
# 段落格式
|
|||
|
|
alignment_map = {
|
|||
|
|
'left': WD_ALIGN_PARAGRAPH.LEFT,
|
|||
|
|
'center': WD_ALIGN_PARAGRAPH.CENTER,
|
|||
|
|
'right': WD_ALIGN_PARAGRAPH.RIGHT,
|
|||
|
|
'justify': WD_ALIGN_PARAGRAPH.JUSTIFY
|
|||
|
|
}
|
|||
|
|
paragraph.paragraph_format.alignment = alignment_map.get(alignment, WD_ALIGN_PARAGRAPH.JUSTIFY)
|
|||
|
|
|
|||
|
|
if first_line_indent:
|
|||
|
|
# 首行缩进2字符(28pt)
|
|||
|
|
paragraph.paragraph_format.first_line_indent = Pt(28)
|
|||
|
|
|
|||
|
|
# 行距固定值28pt
|
|||
|
|
paragraph.paragraph_format.line_spacing = Pt(28)
|
|||
|
|
paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
|||
|
|
|
|||
|
|
# 段前段后距为0
|
|||
|
|
paragraph.paragraph_format.space_before = Pt(0)
|
|||
|
|
paragraph.paragraph_format.space_after = Pt(0)
|
|||
|
|
|
|||
|
|
return paragraph
|
|||
|
|
|
|||
|
|
def add_bullet_list(self, items: List[str], font_name: str = '仿宋', size: int = 14):
|
|||
|
|
"""
|
|||
|
|
添加项目符号列表
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
items: 列表项
|
|||
|
|
font_name: 字体名称
|
|||
|
|
size: 字号
|
|||
|
|
"""
|
|||
|
|
for item in items:
|
|||
|
|
paragraph = self.doc.add_paragraph(item, style='List Bullet')
|
|||
|
|
run = paragraph.runs[0]
|
|||
|
|
self.set_font(run, font_name, size)
|
|||
|
|
paragraph.paragraph_format.line_spacing = Pt(28)
|
|||
|
|
paragraph.paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
|
|||
|
|
paragraph.paragraph_format.space_before = Pt(0)
|
|||
|
|
paragraph.paragraph_format.space_after = Pt(0)
|
|||
|
|
|
|||
|
|
def add_three_line_table(self, data: List[List[str]],
|
|||
|
|
headers: List[str],
|
|||
|
|
font_name: str = '仿宋',
|
|||
|
|
size: int = 12):
|
|||
|
|
"""
|
|||
|
|
添加三线表
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
data: 表格数据(二维列表)
|
|||
|
|
headers: 表头
|
|||
|
|
font_name: 字体名称
|
|||
|
|
size: 字号
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
Table对象
|
|||
|
|
"""
|
|||
|
|
# 创建表格
|
|||
|
|
table = self.doc.add_table(rows=len(data) + 1, cols=len(headers))
|
|||
|
|
self._set_three_line_table_borders(table)
|
|||
|
|
|
|||
|
|
# 设置表头
|
|||
|
|
header_cells = table.rows[0].cells
|
|||
|
|
for i, header in enumerate(headers):
|
|||
|
|
cell = header_cells[i]
|
|||
|
|
self._set_cell_font(cell, header, font_name, size, bold=True)
|
|||
|
|
cell.paragraphs[0].paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|||
|
|
|
|||
|
|
# 设置数据行
|
|||
|
|
for row_idx, row_data in enumerate(data, start=1):
|
|||
|
|
cells = table.rows[row_idx].cells
|
|||
|
|
for i, cell_data in enumerate(row_data):
|
|||
|
|
cell = cells[i]
|
|||
|
|
self._set_cell_font(cell, str(cell_data), font_name, size)
|
|||
|
|
cell.paragraphs[0].paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|||
|
|
|
|||
|
|
return table
|
|||
|
|
|
|||
|
|
def _set_three_line_table_borders(self, table):
|
|||
|
|
"""设置三线表边框"""
|
|||
|
|
tblPr = table._tbl.tblPr
|
|||
|
|
tblBorders = OxmlElement('w:tblBorders')
|
|||
|
|
|
|||
|
|
# 顶底边框 1.5pt (sz=18)
|
|||
|
|
for pos in ['top', 'bottom']:
|
|||
|
|
border = OxmlElement(f'w:{pos}')
|
|||
|
|
border.set(qn('w:val'), 'single')
|
|||
|
|
border.set(qn('w:sz'), '18')
|
|||
|
|
border.set(qn('w:color'), 'auto')
|
|||
|
|
tblBorders.append(border)
|
|||
|
|
|
|||
|
|
# 内部横线 0.75pt (sz=9)
|
|||
|
|
for pos in ['insideH']:
|
|||
|
|
border = OxmlElement(f'w:{pos}')
|
|||
|
|
border.set(qn('w:val'), 'single')
|
|||
|
|
border.set(qn('w:sz'), '9')
|
|||
|
|
border.set(qn('w:color'), 'auto')
|
|||
|
|
tblBorders.append(border)
|
|||
|
|
|
|||
|
|
# 无左右和竖线
|
|||
|
|
for pos in ['left', 'right', 'insideV']:
|
|||
|
|
border = OxmlElement(f'w:{pos}')
|
|||
|
|
border.set(qn('w:val'), 'none')
|
|||
|
|
tblBorders.append(border)
|
|||
|
|
|
|||
|
|
tblPr.append(tblBorders)
|
|||
|
|
|
|||
|
|
def _set_cell_font(self, cell, text: str, font_name: str, size: int, bold: bool = False):
|
|||
|
|
"""设置单元格字体"""
|
|||
|
|
cell.text = ''
|
|||
|
|
run = cell.paragraphs[0].add_run(text)
|
|||
|
|
self.set_font(run, font_name, size, bold=bold)
|
|||
|
|
|
|||
|
|
def add_page_number_footer(self):
|
|||
|
|
"""添加页码页脚"""
|
|||
|
|
for section in self.doc.sections:
|
|||
|
|
footer = section.footer
|
|||
|
|
|
|||
|
|
# 添加页码段落
|
|||
|
|
p = footer.paragraphs[0] if footer.paragraphs else footer.add_paragraph()
|
|||
|
|
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|||
|
|
|
|||
|
|
# 页码格式:— 1 —
|
|||
|
|
self.add_footer_run(p, '— ', font_name='仿宋', size=12)
|
|||
|
|
|
|||
|
|
# 添加页码字段
|
|||
|
|
self.add_page_number_field(p)
|
|||
|
|
|
|||
|
|
self.add_footer_run(p, ' ', font_name='仿宋', size=12)
|
|||
|
|
self.add_footer_run(p, '—', font_name='仿宋', size=12)
|
|||
|
|
|
|||
|
|
def add_footer_run(self, paragraph, text: str, font_name: str, size: int):
|
|||
|
|
"""添加页脚run"""
|
|||
|
|
run = paragraph.add_run(text)
|
|||
|
|
self.set_font(run, font_name, size)
|
|||
|
|
|
|||
|
|
def add_page_number_field(self, paragraph):
|
|||
|
|
"""添加页码字段"""
|
|||
|
|
fldChar1 = OxmlElement('w:fldChar')
|
|||
|
|
fldChar1.set(qn('w:fldCharType'), 'begin')
|
|||
|
|
|
|||
|
|
instrText = OxmlElement('w:instrText')
|
|||
|
|
instrText.set(qn('xml:space'), 'preserve')
|
|||
|
|
instrText.text = "PAGE"
|
|||
|
|
|
|||
|
|
fldChar2 = OxmlElement('w:fldChar')
|
|||
|
|
fldChar2.set(qn('w:fldCharType'), 'end')
|
|||
|
|
|
|||
|
|
run = paragraph.add_run()
|
|||
|
|
run._r.append(fldChar1)
|
|||
|
|
run._r.append(instrText)
|
|||
|
|
run._r.append(fldChar2)
|
|||
|
|
|
|||
|
|
self.set_font(run, '仿宋', 12)
|
|||
|
|
|
|||
|
|
def add_cover_page(self, title: str, organization: str, date_str: str):
|
|||
|
|
"""
|
|||
|
|
添加封面页
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
title: 报告标题
|
|||
|
|
organization: 机构名称
|
|||
|
|
date_str: 日期
|
|||
|
|
"""
|
|||
|
|
# 标题
|
|||
|
|
paragraph = self.doc.add_paragraph()
|
|||
|
|
run = paragraph.add_run(title)
|
|||
|
|
self.set_font(run, '黑体', 22, bold=True)
|
|||
|
|
paragraph.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|||
|
|
paragraph.paragraph_format.space_before = Pt(100)
|
|||
|
|
paragraph.paragraph_format.space_after = Pt(50)
|
|||
|
|
|
|||
|
|
# 机构名称
|
|||
|
|
paragraph = self.doc.add_paragraph()
|
|||
|
|
run = paragraph.add_run(organization)
|
|||
|
|
self.set_font(run, '黑体', 16)
|
|||
|
|
paragraph.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|||
|
|
paragraph.paragraph_format.space_after = Pt(20)
|
|||
|
|
|
|||
|
|
# 日期
|
|||
|
|
paragraph = self.doc.add_paragraph()
|
|||
|
|
run = paragraph.add_run(date_str)
|
|||
|
|
self.set_font(run, '黑体', 16)
|
|||
|
|
paragraph.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
|||
|
|
|
|||
|
|
# 添加分页符
|
|||
|
|
self.doc.add_page_break()
|
|||
|
|
|
|||
|
|
def add_data_source_footnote(self, text: str = "数据来源:Wind资讯"):
|
|||
|
|
"""
|
|||
|
|
添加数据来源注脚
|
|||
|
|
|
|||
|
|
Args:
|
|||
|
|
text: 注脚文本
|
|||
|
|
"""
|
|||
|
|
paragraph = self.doc.add_paragraph()
|
|||
|
|
run = paragraph.add_run(text)
|
|||
|
|
self.set_font(run, '仿宋', 12)
|
|||
|
|
paragraph.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.RIGHT
|
|||
|
|
|
|||
|
|
|
|||
|
|
# 便捷函数
|
|||
|
|
def create_formatted_document() -> Tuple[Document, GBT9704Formatter]:
|
|||
|
|
"""
|
|||
|
|
创建格式化的Word文档
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(Document, GBT9704Formatter) 文档对象和格式化器
|
|||
|
|
"""
|
|||
|
|
doc = Document()
|
|||
|
|
formatter = GBT9704Formatter(doc)
|
|||
|
|
return doc, formatter
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == '__main__':
|
|||
|
|
# 测试代码
|
|||
|
|
doc, formatter = create_formatted_document()
|
|||
|
|
|
|||
|
|
# 添加封面
|
|||
|
|
formatter.add_cover_page(
|
|||
|
|
'某某股票投资研究报告',
|
|||
|
|
'某某证券研究所',
|
|||
|
|
'2026年2月6日'
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 添加核心提要
|
|||
|
|
formatter.add_heading('核心提要', level=1)
|
|||
|
|
formatter.add_bullet_list([
|
|||
|
|
'该公司2025年三季度净资产收益率(ROE)为15.2%,盈利能力较强',
|
|||
|
|
'当前市盈率(TTM)为25.3倍,略高于行业平均水平',
|
|||
|
|
'技术面显示股价呈现震荡上行趋势,建议关注回调机会',
|
|||
|
|
'给予"买入"评级,目标价格35元'
|
|||
|
|
])
|
|||
|
|
|
|||
|
|
# 添加投资评级表格
|
|||
|
|
formatter.add_heading('一、投资评级', level=1)
|
|||
|
|
data = [
|
|||
|
|
['买入', '35.00元', '2026年12月31日', '中风险']
|
|||
|
|
]
|
|||
|
|
formatter.add_three_line_table(
|
|||
|
|
data,
|
|||
|
|
['投资建议', '目标价格', '有效期', '风险等级']
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 添加数据来源
|
|||
|
|
formatter.add_data_source_footnote()
|
|||
|
|
|
|||
|
|
# 保存文档
|
|||
|
|
output_path = 'test_report.docx'
|
|||
|
|
doc.save(output_path)
|
|||
|
|
print(f"测试报告已保存到: {output_path}")
|