#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ Deep Research 中文 PDF 报告模板(ReportLab 基础版) 用法: python3 report-template.py \ --input projects//phase4/final.md \ --manifest projects//manifest.json \ --output projects//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"\1", text) # 斜体 *text* text = re.sub(r"(?\1", text) # 行内代码 `code` text = re.sub(r"`([^`]+)`", r'\1', text) # 引用标签 [src_001] → 上标 text = re.sub(r"\[(src_\d+)\]", r"[\1]", 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"类型:{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)) 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()