Files
deep_research/.opencode/templates/report-template.py
T
2026-04-21 12:31:58 +08:00

674 lines
21 KiB
Python
Executable File
Raw 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.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Deep Research 中文 PDF 报告模板(ReportLab 基础版)
用法:
python3 report-template.py \
--input projects/<slug>/phase4/final.md \
--manifest projects/<slug>/manifest.json \
--output projects/<slug>/phase4/final.pdf \
--fonts-dir .opencode/templates/fonts
依赖:
pip install reportlab markdown-it-py
设计原则:
1. 字体集中注册,样式集中管理(StyleSheet),避免字号不一
2. 思源宋 = 正文;思源黑 = 标题/UI;霞鹜文楷 = 引文/摘要
3. Markdown → ReportLab Flowables,保留结构化信息
4. 基础版支持:封面 / 目录 / 正文(h1-h3 / 段落 / 列表 / 引用 / 表格 / 图片)/ 参考文献
5. 基础版暂不支持:PDF 书签、交叉引用、附录自动生成(后续补齐)
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Tuple
try:
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT, TA_RIGHT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import cm, mm
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.platypus import (
BaseDocTemplate,
Frame,
Image,
NextPageTemplate,
PageBreak,
PageTemplate,
Paragraph,
Spacer,
Table,
TableStyle,
)
except ImportError:
print("❌ 缺少依赖:pip install reportlab", file=sys.stderr)
sys.exit(1)
# ============================================================
# 字体注册
# ============================================================
FONT_MAP = {
# 逻辑名 -> 文件名(优先 ttf/ 子目录的 TrueType 转换版,兼容 ReportLab
"SrcSerif": "ttf/SourceHanSerifSC-Regular.ttf",
"SrcSerif-Bold": "ttf/SourceHanSerifSC-Bold.ttf",
"SrcSans-Light": "ttf/SourceHanSansSC-Light.ttf",
"SrcSans-Medium": "ttf/SourceHanSansSC-Medium.ttf",
"SrcSans-Bold": "ttf/SourceHanSansSC-Bold.ttf",
"SrcSans-Heavy": "ttf/SourceHanSansSC-Heavy.ttf",
"Kai": "LXGWWenKai-Regular.ttf",
}
def register_fonts(fonts_dir: Path) -> None:
"""注册所有中文字体。失败则 exit(1)。"""
missing = []
for logical, filename in FONT_MAP.items():
font_path = fonts_dir / filename
if not font_path.exists():
missing.append(str(font_path))
continue
try:
pdfmetrics.registerFont(TTFont(logical, str(font_path)))
except Exception as e:
print(f"❌ 字体注册失败:{logical} ({font_path}): {e}", file=sys.stderr)
sys.exit(1)
if missing:
print("❌ 缺少字体文件:", file=sys.stderr)
for m in missing:
print(f" - {m}", file=sys.stderr)
print("\n请运行:bash .opencode/templates/fonts/download-fonts.sh", file=sys.stderr)
sys.exit(1)
# 注册字体族(粗体自动映射)
pdfmetrics.registerFontFamily(
"SrcSerif",
normal="SrcSerif",
bold="SrcSerif-Bold",
italic="SrcSerif",
boldItalic="SrcSerif-Bold",
)
pdfmetrics.registerFontFamily(
"SrcSans",
normal="SrcSans-Medium",
bold="SrcSans-Bold",
italic="SrcSans-Medium",
boldItalic="SrcSans-Bold",
)
# ============================================================
# 样式表(集中管理)
# ============================================================
def build_styles() -> StyleSheet1:
"""构建所有段落样式。字号、行高在此唯一定义。"""
ss = StyleSheet1()
# 正文
ss.add(ParagraphStyle(
name="body",
fontName="SrcSerif",
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
firstLineIndent=21, # 首行缩进 2 字符
spaceBefore=2,
spaceAfter=2,
textColor=colors.HexColor("#1a1a1a"),
wordWrap="CJK",
))
# 一级标题(章)
ss.add(ParagraphStyle(
name="h1",
fontName="SrcSans-Bold",
fontSize=18,
leading=28,
alignment=TA_LEFT,
spaceBefore=20,
spaceAfter=12,
textColor=colors.HexColor("#1e3a8a"),
keepWithNext=True,
wordWrap="CJK",
))
# 二级标题(section
ss.add(ParagraphStyle(
name="h2",
fontName="SrcSans-Bold",
fontSize=14,
leading=22,
alignment=TA_LEFT,
spaceBefore=14,
spaceAfter=8,
textColor=colors.HexColor("#1e40af"),
keepWithNext=True,
wordWrap="CJK",
))
# 三级标题(sub-section
ss.add(ParagraphStyle(
name="h3",
fontName="SrcSans-Medium",
fontSize=12,
leading=18,
alignment=TA_LEFT,
spaceBefore=10,
spaceAfter=6,
textColor=colors.HexColor("#374151"),
keepWithNext=True,
wordWrap="CJK",
))
# 引文 / 摘要(霞鹜文楷)
ss.add(ParagraphStyle(
name="quote",
fontName="Kai",
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
leftIndent=20,
rightIndent=20,
spaceBefore=6,
spaceAfter=6,
textColor=colors.HexColor("#4b5563"),
borderWidth=0,
borderPadding=8,
borderColor=colors.HexColor("#d1d5db"),
backColor=colors.HexColor("#f9fafb"),
wordWrap="CJK",
))
# 图表标题
ss.add(ParagraphStyle(
name="caption",
fontName="SrcSans-Medium",
fontSize=9,
leading=13,
alignment=TA_CENTER,
spaceBefore=4,
spaceAfter=10,
textColor=colors.HexColor("#6b7280"),
wordWrap="CJK",
))
# 脚注 / 参考文献
ss.add(ParagraphStyle(
name="footnote",
fontName="SrcSerif",
fontSize=9,
leading=13,
alignment=TA_JUSTIFY,
leftIndent=20,
firstLineIndent=-20, # 悬挂缩进
spaceAfter=4,
textColor=colors.HexColor("#374151"),
wordWrap="CJK",
))
# 页眉页脚
ss.add(ParagraphStyle(
name="header-footer",
fontName="SrcSans-Light",
fontSize=8,
leading=12,
alignment=TA_CENTER,
textColor=colors.HexColor("#9ca3af"),
))
# 封面大标题
ss.add(ParagraphStyle(
name="cover-title",
fontName="SrcSans-Heavy",
fontSize=32,
leading=42,
alignment=TA_CENTER,
spaceBefore=12,
spaceAfter=12,
textColor=colors.HexColor("#0f172a"),
wordWrap="CJK",
))
ss.add(ParagraphStyle(
name="cover-subtitle",
fontName="SrcSans-Medium",
fontSize=16,
leading=24,
alignment=TA_CENTER,
spaceBefore=8,
spaceAfter=30,
textColor=colors.HexColor("#475569"),
wordWrap="CJK",
))
ss.add(ParagraphStyle(
name="cover-meta",
fontName="SrcSerif",
fontSize=11,
leading=18,
alignment=TA_CENTER,
textColor=colors.HexColor("#334155"),
wordWrap="CJK",
))
# 列表
ss.add(ParagraphStyle(
name="bullet",
parent=ss["body"],
firstLineIndent=0,
leftIndent=20,
bulletIndent=6,
))
return ss
# ============================================================
# Markdown 轻量解析(基础版)
# ============================================================
@dataclass
class Block:
kind: str # h1 / h2 / h3 / p / quote / bullet / image / table / hr
content: str # 原始 Markdown 内容
meta: Optional[dict] = None
def parse_markdown(md_text: str) -> List[Block]:
"""
极简 Markdown 解析器,输出扁平 Block 列表。
不支持嵌套结构,复杂情况后续可接 markdown-it-py。
"""
blocks: List[Block] = []
lines = md_text.split("\n")
i = 0
while i < len(lines):
line = lines[i]
stripped = line.strip()
# 空行
if not stripped:
i += 1
continue
# 水平线 / 分页
if stripped in ("---", "***", "___"):
blocks.append(Block(kind="hr", content=""))
i += 1
continue
# 标题
if stripped.startswith("#"):
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if m:
level = min(len(m.group(1)), 3) # h4+ 降级为 h3
blocks.append(Block(kind=f"h{level}", content=m.group(2).strip()))
i += 1
continue
# 图片
m = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", stripped)
if m:
blocks.append(Block(
kind="image",
content=m.group(2),
meta={"caption": m.group(1)},
))
i += 1
continue
# 引用
if stripped.startswith(">"):
quote_lines = []
while i < len(lines) and lines[i].strip().startswith(">"):
quote_lines.append(lines[i].strip().lstrip(">").strip())
i += 1
blocks.append(Block(kind="quote", content="\n".join(quote_lines)))
continue
# 无序列表
if re.match(r"^[-*+]\s+", stripped):
item_lines = []
while i < len(lines) and re.match(r"^[-*+]\s+", lines[i].strip()):
item_lines.append(re.sub(r"^[-*+]\s+", "", lines[i].strip()))
i += 1
for item in item_lines:
blocks.append(Block(kind="bullet", content=item))
continue
# 有序列表
if re.match(r"^\d+\.\s+", stripped):
item_lines = []
while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
item_lines.append(re.sub(r"^\d+\.\s+", "", lines[i].strip()))
i += 1
for idx, item in enumerate(item_lines, 1):
blocks.append(Block(kind="bullet", content=f"{idx}. {item}"))
continue
# 表格(简单识别:有 |
if "|" in line and i + 1 < len(lines) and re.match(r"^\s*\|?\s*:?-+:?\s*\|", lines[i + 1]):
table_lines = [line]
i += 1
# 跳过分隔线
i += 1
while i < len(lines) and "|" in lines[i]:
table_lines.append(lines[i])
i += 1
blocks.append(Block(kind="table", content="\n".join(table_lines)))
continue
# 普通段落(合并连续行)
para_lines = [line]
i += 1
while i < len(lines) and lines[i].strip() and not (
lines[i].strip().startswith(("#", ">", "-", "*", "+", "!"))
or re.match(r"^\d+\.\s+", lines[i].strip())
or "|" in lines[i]
):
para_lines.append(lines[i])
i += 1
blocks.append(Block(kind="p", content=" ".join(l.strip() for l in para_lines)))
return blocks
def md_inline_to_rl(text: str) -> str:
"""Markdown inline → ReportLab mini HTML."""
# 粗体 **text**
text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
# 斜体 *text*
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<i>\1</i>", text)
# 行内代码 `code`
text = re.sub(r"`([^`]+)`", r'<font face="Courier">\1</font>', text)
# 引用标签 [src_001] → 上标
text = re.sub(r"\[(src_\d+)\]", r"<super><font size=8>[\1]</font></super>", text)
# 链接 [text](url) 保留 text
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
return text
# ============================================================
# 文档模板与渲染
# ============================================================
@dataclass
class Manifest:
slug: str
topic: str
subtitle: str
author: str
date: str
type: str
version: str
disclaimer: str
@classmethod
def load(cls, path: Path) -> "Manifest":
data = json.loads(path.read_text(encoding="utf-8"))
return cls(
slug=data.get("slug", ""),
topic=data.get("topic", "未命名研究"),
subtitle=data.get("subtitle", ""),
author=data.get("author", "Deep Research 系统"),
date=data.get("date", ""),
type=data.get("type", ""),
version=data.get("version", "1.0"),
disclaimer=data.get("disclaimer", ""),
)
def make_page_decorator(manifest: Manifest, styles: StyleSheet1):
"""生成普通页的页眉页脚绘制函数。"""
def draw(canvas, doc):
canvas.saveState()
# 页眉
canvas.setFont("SrcSans-Light", 8)
canvas.setFillColor(colors.HexColor("#9ca3af"))
canvas.drawString(2 * cm, A4[1] - 1.2 * cm, manifest.topic[:30])
canvas.drawRightString(A4[0] - 2 * cm, A4[1] - 1.2 * cm, manifest.type)
canvas.setStrokeColor(colors.HexColor("#e5e7eb"))
canvas.line(2 * cm, A4[1] - 1.4 * cm, A4[0] - 2 * cm, A4[1] - 1.4 * cm)
# 页脚
canvas.drawCentredString(A4[0] / 2, 1.2 * cm, f"— {doc.page} —")
canvas.restoreState()
return draw
def build_cover(manifest: Manifest, styles: StyleSheet1) -> List:
"""封面页 flowables。"""
story = []
story.append(Spacer(1, 4 * cm))
story.append(Paragraph(manifest.topic, styles["cover-title"]))
if manifest.subtitle:
story.append(Paragraph(manifest.subtitle, styles["cover-subtitle"]))
story.append(Spacer(1, 6 * cm))
story.append(Paragraph(f"<b>类型</b>{manifest.type}", styles["cover-meta"]))
story.append(Paragraph(f"<b>作者</b>{manifest.author}", styles["cover-meta"]))
story.append(Paragraph(f"<b>日期</b>{manifest.date}", styles["cover-meta"]))
story.append(Paragraph(f"<b>版本</b>v{manifest.version}", styles["cover-meta"]))
story.append(PageBreak())
return story
def build_disclaimer(manifest: Manifest, styles: StyleSheet1) -> List:
"""免责声明页。"""
story = []
story.append(Paragraph("免责声明", styles["h1"]))
story.append(Spacer(1, 0.5 * cm))
disclaimer = manifest.disclaimer or (
"本报告基于公开信息与 AI 辅助研究生成,仅供参考,不构成投资、医疗或法律建议。"
"数据截至报告生成日,使用者应自行核实关键数据并评估时效性。"
)
story.append(Paragraph(disclaimer, styles["body"]))
story.append(PageBreak())
return story
def build_body(blocks: List[Block], base_dir: Path, styles: StyleSheet1) -> List:
"""正文 flowables。"""
story = []
for block in blocks:
if block.kind == "h1":
# h1 前强制分页(每章新起一页)
story.append(PageBreak())
story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"]))
elif block.kind == "h2":
story.append(Paragraph(md_inline_to_rl(block.content), styles["h2"]))
elif block.kind == "h3":
story.append(Paragraph(md_inline_to_rl(block.content), styles["h3"]))
elif block.kind == "p":
story.append(Paragraph(md_inline_to_rl(block.content), styles["body"]))
elif block.kind == "quote":
story.append(Paragraph(md_inline_to_rl(block.content), styles["quote"]))
elif block.kind == "bullet":
story.append(Paragraph("• " + md_inline_to_rl(block.content), styles["bullet"]))
elif block.kind == "hr":
story.append(PageBreak())
elif block.kind == "image":
img_path = base_dir / block.content
if img_path.exists():
try:
img = Image(str(img_path), width=15 * cm, height=10 * cm, kind="proportional")
story.append(img)
if block.meta and block.meta.get("caption"):
story.append(Paragraph(block.meta["caption"], styles["caption"]))
except Exception as e:
story.append(Paragraph(
f"[图片加载失败:{block.content}{e}]",
styles["caption"],
))
else:
story.append(Paragraph(
f"[图片未找到:{block.content}]",
styles["caption"],
))
elif block.kind == "table":
try:
table_flow = render_table(block.content, styles)
story.append(table_flow)
except Exception as e:
story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"]))
return story
def render_table(md_table: str, styles: StyleSheet1) -> Table:
"""Markdown 表格 → ReportLab Table。自动计算均匀列宽,避免负宽度问题。"""
rows = []
body_style = ParagraphStyle(
name="table-body",
fontName="SrcSerif",
fontSize=9,
leading=14,
wordWrap="CJK",
)
header_style = ParagraphStyle(
name="table-header",
fontName="SrcSans-Bold",
fontSize=9,
leading=14,
wordWrap="CJK",
)
num_cols = 0
for i, line in enumerate(md_table.strip().split("\n")):
line = line.strip().strip("|")
# 跳过分隔行(如 :---: | --- 等)
if re.match(r"^[\s\-:|]+$", line):
continue
cells = [c.strip() for c in line.split("|")]
if not any(cells):
continue
num_cols = max(num_cols, len(cells))
# 第一行(表头)用 header_style
style = header_style if not rows else body_style
rows.append([Paragraph(md_inline_to_rl(c), style) for c in cells])
if not rows:
return Table([[Paragraph("", body_style)]])
# 可用宽度:A4(595pt) - 左右各2cm边距 = 595 - 4*28.35 ≈ 481pt
available_width = 17 * cm # 约 481pt,保守取 17cm
col_width = available_width / max(num_cols, 1)
col_widths = [col_width] * num_cols
# 统一列数(补齐短行)
for row in rows:
while len(row) < num_cols:
row.append(Paragraph("", body_style))
table = Table(rows, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e0e7ff")),
("FONTNAME", (0, 0), (-1, 0), "SrcSans-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cbd5e1")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
]))
return table
# ============================================================
# 主入口
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Deep Research ReportLab PDF 生成器")
parser.add_argument("--input", required=True, help="输入 Markdown 路径")
parser.add_argument("--manifest", required=True, help="manifest.json 路径")
parser.add_argument("--output", required=True, help="输出 PDF 路径")
parser.add_argument(
"--fonts-dir",
default=".opencode/templates/fonts",
help="字体目录(默认 .opencode/templates/fonts",
)
args = parser.parse_args()
md_path = Path(args.input)
manifest_path = Path(args.manifest)
output_path = Path(args.output)
fonts_dir = Path(args.fonts_dir)
# 校验输入
for p, label in [(md_path, "Markdown"), (manifest_path, "Manifest"), (fonts_dir, "字体目录")]:
if not p.exists():
print(f"❌ {label} 不存在:{p}", file=sys.stderr)
sys.exit(1)
# 注册字体
register_fonts(fonts_dir)
styles = build_styles()
manifest = Manifest.load(manifest_path)
# 解析 Markdown
md_text = md_path.read_text(encoding="utf-8")
blocks = parse_markdown(md_text)
# 构建文档
doc = BaseDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=2 * cm,
rightMargin=2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
title=manifest.topic,
author=manifest.author,
)
# 两个 Frame:封面(无页眉页脚) / 正文
cover_frame = Frame(
2 * cm, 2 * cm,
A4[0] - 4 * cm, A4[1] - 4 * cm,
id="cover",
)
normal_frame = Frame(
2 * cm, 2 * cm,
A4[0] - 4 * cm, A4[1] - 4 * cm,
id="normal",
)
decorator = make_page_decorator(manifest, styles)
doc.addPageTemplates([
PageTemplate(id="cover", frames=[cover_frame]),
PageTemplate(id="normal", frames=[normal_frame], onPage=decorator),
])
# 构建 story
story: List = []
story.extend(build_cover(manifest, styles))
story.append(NextPageTemplate("normal"))
story.extend(build_disclaimer(manifest, styles))
story.extend(build_body(blocks, md_path.parent, styles))
# 出稿
doc.build(story)
size = output_path.stat().st_size
print(f"✅ PDF 生成成功:{output_path}")
print(f" 文件大小:{size / 1024:.1f} KB")
print(f" 字体数量:{len(FONT_MAP)}")
print(f" Block 数:{len(blocks)}")
if __name__ == "__main__":
main()