Files
piano-plan/app/services/pdf_generator.py
T

298 lines
10 KiB
Python

# PDF生成服务 - 支持中文和富文本
import os
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import mm
from reportlab.lib import colors
from reportlab.platypus import (
SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle
)
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.lib.enums import TA_CENTER, TA_LEFT
from reportlab.pdfgen import canvas as pdfcanvas
# 注册中文字体
# Windows 和 Linux 使用不同路径
if os.name == 'nt': # Windows
FONT_PATH = r"C:\Windows\Fonts\msyh.ttc"
FONT_BOLD_PATH = r"C:\Windows\Fonts\msyhbd.ttc"
else: # Linux (Docker)
# 直接尝试常见的中文字体路径
possible_paths = [
"/usr/share/fonts/truetype/wqy/wqy-microhei.ttc",
"/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc",
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
"/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc",
]
FONT_PATH = None
for p in possible_paths:
if os.path.exists(p):
FONT_PATH = p
break
if not FONT_PATH:
FONT_PATH = possible_paths[0] # 使用第一个作为默认值(会触发异常)
FONT_BOLD_PATH = FONT_PATH # 使用同一字体(不支持粗体分离)
try:
pdfmetrics.registerFont(TTFont("Chinese", FONT_PATH))
# 注册粗体(如果路径不同才注册,相同则复用)
if FONT_BOLD_PATH != FONT_PATH:
try:
pdfmetrics.registerFont(TTFont("Chinese-Bold", FONT_BOLD_PATH))
except:
pdfmetrics.registerFont(TTFont("Chinese-Bold", FONT_PATH))
else:
# 同一字体,注册为 Chinese-Bold 别名
try:
pdfmetrics.registerFont(TTFont("Chinese-Bold", FONT_PATH))
except:
pass
CHINESE_FONT_OK = True
except Exception as e:
CHINESE_FONT_OK = False
def md_to_xml(text):
"""将markdown转换为reportlab XML markup"""
if not text:
return ""
result = []
i = 0
while i < len(text):
# 处理 bold **text** - 使用中文字体粗体
if text[i:i+2] == '**':
end = text.find('**', i+2)
if end != -1:
if CHINESE_FONT_OK:
result.append(f'<font name="Chinese-Bold">{text[i+2:end]}</font>')
else:
result.append(f'<b>{text[i+2:end]}</b>')
i = end + 2
continue
# 处理 italic *text*
if text[i] == '*' and (i == 0 or text[i-1] not in '*_'):
end = text.find('*', i+1)
if end != -1 and text[end-1] != '*':
result.append(f'<i>{text[i+1:end]}</i>')
i = end + 1
continue
# 处理 inline code `text`
if text[i] == '`':
end = text.find('`', i+1)
if end != -1:
result.append(f'<font name="Courier">{text[i+1:end]}</font>')
i = end + 1
continue
result.append(text[i])
i += 1
return ''.join(result)
class PianoPDF:
def __init__(self):
self.elements = []
self.styles = getSampleStyleSheet()
if CHINESE_FONT_OK:
self.base_font = "Chinese"
self.bold_font = "Chinese-Bold"
else:
self.base_font = "Helvetica"
self.bold_font = "Helvetica-Bold"
# 标题样式
self.title_style = ParagraphStyle(
"CustomTitle",
parent=self.styles["Heading1"],
fontName=self.bold_font,
fontSize=18,
spaceAfter=10*mm,
alignment=TA_CENTER,
)
# 二级标题
self.heading_style = ParagraphStyle(
"CustomHeading",
parent=self.styles["Heading2"],
fontName=self.bold_font,
fontSize=14,
spaceAfter=6*mm,
spaceBefore=6*mm,
textColor=colors.HexColor("#2c3e50"),
)
# 三级标题
self.h3_style = ParagraphStyle(
"CustomH3",
parent=self.styles["Heading3"],
fontName=self.bold_font,
fontSize=12,
spaceAfter=4*mm,
spaceBefore=4*mm,
textColor=colors.HexColor("#34495e"),
)
# 正文样式
self.body_style = ParagraphStyle(
"CustomBody",
parent=self.styles["Normal"],
fontName=self.base_font,
fontSize=10,
spaceAfter=4*mm,
leading=14,
)
# 表格样式
self.table_style = TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#3498db")),
("TEXTCOLOR", (0, 0), (-1, 0), colors.whitesmoke),
("ALIGN", (0, 0), (-1, -1), "LEFT"),
("FONTNAME", (0, 0), (-1, 0), self.bold_font),
("FONTSIZE", (0, 0), (-1, 0), 10),
("BOTTOMPADDING", (0, 0), (-1, 0), 8),
("BACKGROUND", (0, 1), (-1, -1), colors.white),
("FONTNAME", (0, 1), (-1, -1), self.base_font),
("FONTSIZE", (0, 1), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.grey),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.HexColor("#f8f9fa")]),
])
def add_title(self, text):
self.elements.append(Paragraph(md_to_xml(text), self.title_style))
self.elements.append(Spacer(1, 5*mm))
def add_heading(self, text, level=2):
if level == 3:
self.elements.append(Paragraph(md_to_xml(text), self.h3_style))
else:
self.elements.append(Paragraph(md_to_xml(text), self.heading_style))
def add_paragraph(self, text):
if text:
self.elements.append(Paragraph(md_to_xml(text), self.body_style))
self.elements.append(Spacer(1, 3*mm))
def add_list(self, items):
for item in items:
if item:
self.elements.append(Paragraph(f"{md_to_xml(item)}", self.body_style))
self.elements.append(Spacer(1, 3*mm))
def add_table(self, data):
if not data or len(data) < 2:
return
table_data = []
for row in data:
table_data.append([Paragraph(md_to_xml(str(cell)), self.body_style) if cell else "" for cell in row])
if table_data:
table = Table(table_data)
table.setStyle(self.table_style)
self.elements.append(table)
self.elements.append(Spacer(1, 5*mm))
def generate_pdf(plan_id, student_name, content, output_dir, rendered_report=None, watermark_text=None):
"""生成PDF文件"""
os.makedirs(output_dir, exist_ok=True)
output_path = os.path.join(output_dir, f"plan_{plan_id}.pdf")
doc = SimpleDocTemplate(
output_path, pagesize=A4,
rightMargin=20*mm, leftMargin=20*mm,
topMargin=20*mm, bottomMargin=20*mm,
)
pdf = PianoPDF()
if rendered_report:
# 解析Markdown并添加到PDF
lines = rendered_report.split('\n')
in_table = False
table_data = []
for line in lines:
line = line.strip()
if not line or line.startswith('---'):
if in_table and table_data:
pdf.add_table(table_data)
table_data = []
in_table = False
continue
if line.startswith('# ') and not line.startswith('## '):
if in_table and table_data:
pdf.add_table(table_data)
table_data = []
in_table = False
pdf.add_title(line.replace('# ', ''))
elif line.startswith('## '):
if in_table and table_data:
pdf.add_table(table_data)
table_data = []
in_table = False
pdf.add_heading(line.replace('## ', ''))
elif line.startswith('### '):
pdf.add_heading(line.replace('### ', ''), level=3)
elif line.startswith('|') and '|' in line[1:]:
in_table = True
raw_cells = [c.strip() for c in line.split('|')[1:-1]]
cells = [md_to_xml(c) if c else "" for c in raw_cells]
if cells and not all(c and c.strip().startswith('-') for c in cells if c):
table_data.append(cells)
elif line.startswith('- '):
pdf.add_paragraph(f"{line[2:]}")
elif line and not in_table:
pdf.add_paragraph(line)
if in_table and table_data:
pdf.add_table(table_data)
else:
# 使用结构化内容
pdf.add_title(f"钢琴练习方案 - {student_name}")
pdf.add_heading("学员信息")
pdf.add_paragraph(f"学员姓名:{student_name}")
pdf.add_paragraph(f"每日练习时间:{content.get('practice_time', 'N/A')}")
pdf.add_paragraph(f"生成时间:{content.get('generated_at', '')}")
if content.get("ai_report"):
pdf.add_heading("AI个性化练习报告")
for line in content["ai_report"].split("\n"):
line = line.strip()
if line.startswith("### "):
pdf.add_heading(line.replace("### ", ""), level=3)
elif line.startswith("## "):
pdf.add_heading(line.replace("## ", ""))
elif line.startswith("# "):
pdf.add_title(line.replace("# ", ""))
elif line.startswith("- "):
pdf.add_paragraph(line)
elif line:
pdf.add_paragraph(line)
# 水印函数(每页都绘制)
def draw_watermark(c, doc):
if not watermark_text:
return
if not CHINESE_FONT_OK:
return
c.saveState()
try:
c.setFont("Chinese", 56)
# 浅灰色半透明
c.setFillColor(colors.Color(0.6, 0.6, 0.6, alpha=0.25))
# 旋转45度
c.translate(A4[0]/2, A4[1]/2)
c.rotate(45)
# 绘制水印文字(居中)
c.drawCentredString(0, 0, watermark_text)
except Exception:
pass
c.restoreState()
# 移除末尾的空白元素(避免产生多余空白页)
while pdf.elements and isinstance(pdf.elements[-1], Spacer):
pdf.elements.pop()
doc.build(pdf.elements, onFirstPage=draw_watermark, onLaterPages=draw_watermark)
return output_path