Files
deep_research/.opencode/templates/report-template.py
T
kai a092af4398 v0.5: deep quality refactor (P0+P1+P2)
- Split dr-chief-editor (Phase 3 read-only) vs new dr-editor-in-chief (Opus, Phase 4 lead)
- New dr-translator (en->zh) and new humanizer-cn / output-hygiene / en-zh-translation skills
- Switch to English working language (Phase 2-3), final Chinese translation (Phase 4)
- /dr-init: add report title proposals + word budget mode
- /dr-frame: bilingual framework
- /dr-finalize: new chain editor->translator->polisher->reporter
- report-template.py: widows/orphans/keepWithNext, 3-color hierarchy, confidentiality banner
- dr-reporter: mandatory citations backfill + output hygiene check
- dr-pm: batch-level context compression via manifest.batches_summary
- mckinsey-method: SCQA only for Executive Summary + chapter intros (no explicit labels)
- length-budget: 4 word-budget modes + en/zh 1:1.4 ratio
2026-04-21 13:02:54 +08:00

675 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 v0.5
v0.5 升级点(对比 v0.4):
- 封面:主标题 + 副标题 + 保密标识(红色)+ 编制日期(参考 9MW1911 风格)
- 分页规则:widows=2, orphans=2, keepWithNext 防孤行寡行
- 表格:splitByRow=True, repeatRows=1 防断页
- 颜色层次:h1 深蓝 / h2 蓝 / h3 深灰
- 段落首行缩进 2 字符,行高 × 1.7
- 参考文献:自动解析 "## 参考文献" / "## References" 段落
用法:
uv run python3 report-template.py \
--input projects/<slug>/phase4/final_zh.md \
--manifest projects/<slug>/manifest.json \
--output projects/<slug>/phase4/final.pdf \
--fonts-dir .opencode/templates/fonts
"""
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
try:
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, StyleSheet1
from reportlab.lib.units import cm
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("ERROR: missing reportlab. Run: uv sync", file=sys.stderr)
sys.exit(1)
# ============================================================
# Font registration
# ============================================================
FONT_MAP = {
"SrcSerif": "SourceHanSerifSC-Regular.otf",
"SrcSerif-Bold": "SourceHanSerifSC-Bold.otf",
"SrcSans-Light": "SourceHanSansSC-Light.otf",
"SrcSans-Medium": "SourceHanSansSC-Medium.otf",
"SrcSans-Bold": "SourceHanSansSC-Bold.otf",
"SrcSans-Heavy": "SourceHanSansSC-Heavy.otf",
"Kai": "LXGWWenKai-Regular.ttf",
}
def register_fonts(fonts_dir: Path) -> None:
missing = []
for logical, fname in FONT_MAP.items():
path = fonts_dir / fname
if not path.exists():
missing.append(str(path))
continue
try:
pdfmetrics.registerFont(TTFont(logical, str(path)))
except Exception as e:
print(f"ERROR: font registration failed {logical} ({path}): {e}", file=sys.stderr)
sys.exit(1)
if missing:
print("ERROR: missing fonts:", file=sys.stderr)
for m in missing:
print(f" - {m}", file=sys.stderr)
print("\nRun: 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",
)
# ============================================================
# StyleSheet (centralized styles)
# ============================================================
def build_styles() -> StyleSheet1:
ss = StyleSheet1()
# Body
ss.add(ParagraphStyle(
name="body",
fontName="SrcSerif",
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
firstLineIndent=21,
spaceBefore=3,
spaceAfter=3,
textColor=colors.HexColor("#1a1a1a"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
# H1 (chapter) - page break before, deep blue
ss.add(ParagraphStyle(
name="h1",
fontName="SrcSans-Bold",
fontSize=18,
leading=28,
alignment=TA_LEFT,
spaceBefore=0,
spaceAfter=14,
textColor=colors.HexColor("#1e3a8a"),
keepWithNext=1,
wordWrap="CJK",
))
# H2 (section) - blue, no page break, keep with next
ss.add(ParagraphStyle(
name="h2",
fontName="SrcSans-Bold",
fontSize=14,
leading=22,
alignment=TA_LEFT,
spaceBefore=16,
spaceAfter=8,
textColor=colors.HexColor("#2c5282"),
keepWithNext=1,
wordWrap="CJK",
))
# H3 (subsection) - dark gray
ss.add(ParagraphStyle(
name="h3",
fontName="SrcSans-Medium",
fontSize=12,
leading=18,
alignment=TA_LEFT,
spaceBefore=10,
spaceAfter=6,
textColor=colors.HexColor("#374151"),
keepWithNext=1,
wordWrap="CJK",
))
# Quote - Kai (Wenkai), light background
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"),
borderPadding=8,
backColor=colors.HexColor("#f9fafb"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
# Caption (figure/table title)
ss.add(ParagraphStyle(
name="caption",
fontName="SrcSans-Medium",
fontSize=9,
leading=13,
alignment=TA_CENTER,
spaceBefore=4,
spaceAfter=12,
textColor=colors.HexColor("#6b7280"),
wordWrap="CJK",
))
# Footnote (references)
ss.add(ParagraphStyle(
name="footnote",
fontName="SrcSerif",
fontSize=9,
leading=13,
alignment=TA_JUSTIFY,
leftIndent=20,
firstLineIndent=-20, # hanging indent
spaceAfter=4,
textColor=colors.HexColor("#374151"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
# Cover - main title (heavy, centered, large)
ss.add(ParagraphStyle(
name="cover-title",
fontName="SrcSans-Heavy",
fontSize=28,
leading=40,
alignment=TA_CENTER,
spaceBefore=10,
spaceAfter=10,
textColor=colors.HexColor("#0f172a"),
wordWrap="CJK",
))
ss.add(ParagraphStyle(
name="cover-subtitle",
fontName="SrcSans-Medium",
fontSize=15,
leading=24,
alignment=TA_CENTER,
spaceBefore=6,
spaceAfter=30,
textColor=colors.HexColor("#475569"),
wordWrap="CJK",
))
# Cover - confidentiality marker (red)
ss.add(ParagraphStyle(
name="cover-confidential",
fontName="SrcSans-Bold",
fontSize=11,
leading=16,
alignment=TA_CENTER,
spaceBefore=8,
spaceAfter=8,
textColor=colors.HexColor("#dc2626"),
wordWrap="CJK",
))
# Cover - meta info (date, version, author)
ss.add(ParagraphStyle(
name="cover-meta",
fontName="SrcSerif",
fontSize=11,
leading=18,
alignment=TA_CENTER,
textColor=colors.HexColor("#334155"),
wordWrap="CJK",
))
# Summary (Executive Summary)
ss.add(ParagraphStyle(
name="summary",
fontName="SrcSerif",
fontSize=11,
leading=20,
alignment=TA_JUSTIFY,
firstLineIndent=22,
spaceBefore=4,
spaceAfter=4,
textColor=colors.HexColor("#1a1a1a"),
wordWrap="CJK",
allowWidows=0,
allowOrphans=0,
))
# Bullet list
ss.add(ParagraphStyle(
name="bullet",
parent=ss["body"],
firstLineIndent=0,
leftIndent=24,
bulletIndent=8,
))
return ss
# ============================================================
# Markdown parser (lightweight)
# ============================================================
@dataclass
class Block:
kind: str # h1 / h2 / h3 / p / quote / bullet / image / table / hr
content: str
meta: Optional[dict] = None
def parse_markdown(md_text: str) -> List[Block]:
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
# hr / page break
if stripped in ("---", "***", "___"):
blocks.append(Block(kind="hr", content=""))
i += 1
continue
# Heading
if stripped.startswith("#"):
m = re.match(r"^(#{1,6})\s+(.+)$", stripped)
if m:
level = min(len(m.group(1)), 3)
blocks.append(Block(kind=f"h{level}", content=m.group(2).strip()))
i += 1
continue
# Image
m = re.match(r"^!\[([^\]]*)\]\(([^)]+)\)", stripped)
if m:
blocks.append(Block(
kind="image",
content=m.group(2),
meta={"caption": m.group(1)},
))
i += 1
continue
# Blockquote
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
# Unordered list
if re.match(r"^[-*+]\s+", stripped):
while i < len(lines) and re.match(r"^[-*+]\s+", lines[i].strip()):
item = re.sub(r"^[-*+]\s+", "", lines[i].strip())
blocks.append(Block(kind="bullet", content=item))
i += 1
continue
# Ordered list
if re.match(r"^\d+\.\s+", stripped):
idx = 1
while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
item = re.sub(r"^\d+\.\s+", "", lines[i].strip())
blocks.append(Block(kind="bullet", content=f"{idx}. {item}"))
i += 1
idx += 1
continue
# Table
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 # skip separator
while i < len(lines) and "|" in lines[i] and lines[i].strip():
table_lines.append(lines[i])
i += 1
blocks.append(Block(kind="table", content="\n".join(table_lines)))
continue
# Paragraph (merge continuation lines)
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 = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<i>\1</i>", text)
text = re.sub(r"`([^`]+)`", r'<font face="Courier">\1</font>', text)
text = re.sub(r"\[(src_\d+)\]", r"<super><font size=8>[\1]</font></super>", text)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
return text
# ============================================================
# Document builders
# ============================================================
@dataclass
class Manifest:
slug: str
report_title: str
report_subtitle: str
author: str
date: str
version: str
type: str
confidentiality: 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", ""),
report_title=data.get("report_title") or data.get("topic", "未命名研究报告"),
report_subtitle=data.get("report_subtitle", ""),
author=data.get("author", "Deep Research 系统"),
date=data.get("date", ""),
version=data.get("version", "1.0"),
type=data.get("type", ""),
confidentiality=data.get("confidentiality", ""),
disclaimer=data.get("disclaimer", "本报告基于公开信息与 AI 辅助研究生成,仅供参考,不构成投资或医疗建议。"),
)
def make_page_decorator(manifest: Manifest):
"""Returns function drawing header/footer for normal pages."""
short_title = manifest.report_title[:30]
def draw(canvas, doc):
canvas.saveState()
# Header: short title left, report type right, gray separator
canvas.setFont("SrcSans-Light", 8)
canvas.setFillColor(colors.HexColor("#9ca3af"))
canvas.drawString(2 * cm, A4[1] - 1.2 * cm, short_title)
if manifest.type:
canvas.drawRightString(A4[0] - 2 * cm, A4[1] - 1.2 * cm, manifest.type)
canvas.setStrokeColor(colors.HexColor("#e5e7eb"))
canvas.setLineWidth(0.5)
canvas.line(2 * cm, A4[1] - 1.4 * cm, A4[0] - 2 * cm, A4[1] - 1.4 * cm)
# Footer: page number centered
canvas.setFont("SrcSans-Light", 8)
canvas.drawCentredString(A4[0] / 2, 1.2 * cm, f"— {doc.page} —")
canvas.restoreState()
return draw
def build_cover(manifest: Manifest, styles: StyleSheet1) -> List:
story = []
story.append(Spacer(1, 6 * cm))
story.append(Paragraph(manifest.report_title, styles["cover-title"]))
if manifest.report_subtitle:
story.append(Paragraph(manifest.report_subtitle, styles["cover-subtitle"]))
story.append(Spacer(1, 5 * cm))
if manifest.confidentiality:
story.append(Paragraph(manifest.confidentiality, styles["cover-confidential"]))
story.append(Spacer(1, 2 * cm))
story.append(Paragraph(f"类型:{manifest.type}", styles["cover-meta"]))
story.append(Paragraph(f"作者:{manifest.author}", styles["cover-meta"]))
story.append(Paragraph(f"编制日期:{manifest.date}", styles["cover-meta"]))
story.append(Paragraph(f"版本: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))
story.append(Paragraph(manifest.disclaimer, styles["body"]))
story.append(PageBreak())
return story
def render_table(md_table: str, styles: StyleSheet1) -> Table:
rows = []
for line in md_table.strip().split("\n"):
line = line.strip().strip("|")
cells = [c.strip() for c in line.split("|")]
rows.append([Paragraph(md_inline_to_rl(c), styles["body"]) for c in cells])
table = Table(rows, repeatRows=1, splitByRow=True)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e0e7ff")),
("FONTNAME", (0, 0), (-1, 0), "SrcSans-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 9.5),
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#cbd5e1")),
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
("LEFTPADDING", (0, 0), (-1, -1), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
]))
return table
def build_body(blocks: List[Block], base_dir: Path, styles: StyleSheet1) -> List:
"""
Render markdown blocks to flowables.
v0.5 upgrade: h1 triggers PageBreak; h2/h3 use keepWithNext; tables splitByRow.
"""
story = []
first_h1 = True
# Track whether we're in a special section that uses different body style
in_summary = False
for block in blocks:
if block.kind == "h1":
# PageBreak before every h1 EXCEPT the very first
if not first_h1:
story.append(PageBreak())
first_h1 = False
# Check if this is Executive Summary / 执行摘要 - use summary style for following body
content = block.content
if any(keyword in content for keyword in ["执行摘要", "Executive Summary", "管理层摘要"]):
in_summary = True
else:
in_summary = False
story.append(Paragraph(md_inline_to_rl(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":
style = styles["summary"] if in_summary else styles["body"]
story.append(Paragraph(md_inline_to_rl(block.content), style))
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(Spacer(1, 0.3 * cm))
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:
story.append(render_table(block.content, styles))
except Exception as e:
story.append(Paragraph(f"[表格渲染失败: {e}]", styles["caption"]))
return story
# ============================================================
# Main
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Deep Research PDF Generator (v0.5)")
parser.add_argument("--input", required=True, help="Input markdown (final_zh.md)")
parser.add_argument("--manifest", required=True, help="manifest.json path")
parser.add_argument("--output", required=True, help="Output PDF path")
parser.add_argument(
"--fonts-dir",
default=".opencode/templates/fonts",
help="Fonts directory",
)
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)
# Validate inputs
for p, label in [(md_path, "Markdown"), (manifest_path, "Manifest"), (fonts_dir, "Fonts dir")]:
if not p.exists():
print(f"ERROR: {label} not found: {p}", file=sys.stderr)
sys.exit(1)
# Register fonts and build styles
register_fonts(fonts_dir)
styles = build_styles()
manifest = Manifest.load(manifest_path)
# Parse markdown
md_text = md_path.read_text(encoding="utf-8")
blocks = parse_markdown(md_text)
# Build document
doc = BaseDocTemplate(
str(output_path),
pagesize=A4,
leftMargin=2.2 * cm,
rightMargin=2.2 * cm,
topMargin=2 * cm,
bottomMargin=2 * cm,
title=manifest.report_title,
author=manifest.author,
subject=manifest.type,
)
# Frames
cover_frame = Frame(
2 * cm, 2 * cm,
A4[0] - 4 * cm, A4[1] - 4 * cm,
id="cover",
)
normal_frame = Frame(
2.2 * cm, 2 * cm,
A4[0] - 4.4 * cm, A4[1] - 4 * cm,
id="normal",
)
decorator = make_page_decorator(manifest)
doc.addPageTemplates([
PageTemplate(id="cover", frames=[cover_frame]),
PageTemplate(id="normal", frames=[normal_frame], onPage=decorator),
])
# Assemble 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))
# Build
doc.build(story)
# Report
size = output_path.stat().st_size
print(f"PDF generated: {output_path}")
print(f" Size: {size / 1024:.1f} KB")
print(f" Fonts: {len(FONT_MAP)}")
print(f" Blocks: {len(blocks)}")
if size < 500 * 1024:
print(f" WARNING: PDF size < 500KB, fonts may not be properly embedded", file=sys.stderr)
if __name__ == "__main__":
main()