Files
deep_research/.opencode/templates/report-template.py
kai c444007f04 v0.8: Serper 集成 + H1 章节标题双行居中 + 反方证据观点化 + 术语核查命令
新增:Google 系检索(SerpAPI → Serper.dev)

- scripts/lib/serper_client.py:封装 serper.dev 的 Google Search / Scholar / News / Patents
- 专利检索用 site:patents.google.com 技巧,serper.dev 没专用 endpoint 但效果很好
- Scholar 带引用数、年份、期刊信息,便于权威信源识别
- News 支持 time_range(d/w/m/y)时效性过滤

- scripts/lib/search_client.py 扩展为多路由门面:
  - search() 通用:Exa → Tavily
  - patents() 专利:Serper(Google Patents)→ 通用搜索 + site: 兜底
  - scholar() 论文:Serper Scholar → 通用搜索兜底
  - news() 新闻:Serper News → 通用搜索兜底
- 所有 httpx 客户端 trust_env=False,绕过系统 socks5 代理(v0.6 修过的 TLS EOF)

- .opencode/skills/search-strategy/SKILL.md §三重写:按查询类型路由,明确何时用哪个 API

H1 章节标题:两行居中 + 装饰横线

- 新增 ParagraphStyle: h1-chapter-num / h1-chapter-title
- 新增 parse_chapter_title() 支持中文/阿拉伯/混合空格章号:
  "第一章" / "第 9 章" / "第6章" / "Chapter 1" 全覆盖
- 分隔符支持: em dash — / en dash – / - / : / :
- 新增 build_chapter_header():章号小字居中 + 章名大字深蓝居中 + HRFlowable 3cm 装饰线
- 只对正文章节(_title_kind == "chapter")启用;前置件(免责声明/执行摘要/术语表/目录)
  仍用单行 h1 样式

反方证据段规范化(用户反馈 v0.7 问题 #5)

- skill:evidence-table 新增 §"正文中反方证据段落的写作规范":
  - 禁止机械标题"反驳证据" / "Counter-Evidence" / "反方观点"
  - 必须观点化,包含具体判断(如"另一种声音:管线虚胖还是真实进展?")
  - 用 H2 或 H3,禁止加粗段冒充标题
  - 给出段落结构模板(1-2 句过渡 → 列表型反方论点 → 整合判断)
- dr-analyst.md Hard Rules #3 改为引用该规范

术语表事实核查前置(新 command /dr-glossary)

- 新增 .opencode/commands/dr-glossary.md,支持 --from phase1|phase2|phase4 三个时机
- Phase 1 末 / Phase 2 初:从 framework.md 抽取专有名词种子表,在 dr-analyst 起草前
  预先核查公司名/产品名/技术名拼写,避免编造错误(Mabwell → Maywavee 这类)
- Phase 4:维持当前用法,对 glossary.json 全量核查

实测:dual-target-rnai-pipeline-2026 重生 PDF 55 页,所有 10 章标题双行居中正确渲染
(第一章/第二章/... 第十章 / 第 6 章 / 第 9 章 多种形式都识别)。
2026-04-22 17:04:05 +08:00

1487 lines
50 KiB
Python
Executable File
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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,
)
from reportlab.platypus.flowables import HRFlowable
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 _resolve_font(fonts_dir: Path, fname: str) -> Optional[Path]:
"""查找字体文件:先在 fonts_dir 根下找 OTF/TTF,再看 ttf/ 子目录的 TTF 兜底。
ReportLab 的 TTFont 只支持 TrueType(无 PostScript outlines)。
思源字体的 OTF 是 PS outlines 版本,注册会失败,必须用 TTF 版本。
"""
# 优先级:
# 1. 直接给的文件名(例如已经是 .ttf)
direct = fonts_dir / fname
if direct.exists() and direct.suffix.lower() == ".ttf":
return direct
# 2. 如果 fname 是 .otf,尝试在 ttf/ 子目录找同 stem 的 .ttf
if fname.lower().endswith(".otf"):
ttf_candidate = fonts_dir / "ttf" / (fname[:-4] + ".ttf")
if ttf_candidate.exists():
return ttf_candidate
# 3. 原始 OTF 文件(让调用者自己处理错误)
if direct.exists():
return direct
return None
def register_fonts(fonts_dir: Path) -> None:
missing = []
resolved: dict[str, Path] = {}
for logical, fname in FONT_MAP.items():
path = _resolve_font(fonts_dir, fname)
if not path:
missing.append(f"{logical} (looked for {fname} / ttf/{fname.replace('.otf','.ttf')})")
continue
resolved[logical] = path
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)
for logical, path in resolved.items():
try:
pdfmetrics.registerFont(TTFont(logical, str(path)))
except Exception as e:
print(
f"ERROR: font registration failed {logical} ({path}): {e}\n"
f"Hint: ReportLab needs TrueType outlines. "
f"If this is an .otf with PostScript outlines, use the TTF version in fonts/ttf/.",
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 (used for 前置件标题如"免责声明")
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",
))
# H1 章号(正文章节第一行):小字号居中,浅色
ss.add(ParagraphStyle(
name="h1-chapter-num",
fontName="SrcSans-Medium",
fontSize=13,
leading=20,
alignment=TA_CENTER,
spaceBefore=18,
spaceAfter=6,
textColor=colors.HexColor("#6b7280"),
letterSpacing=3, # 章号加字距,视觉更稳
keepWithNext=1,
wordWrap="CJK",
))
# H1 章名(正文章节第二行):大字号居中加粗深蓝
ss.add(ParagraphStyle(
name="h1-chapter-title",
fontName="SrcSans-Bold",
fontSize=20,
leading=32,
alignment=TA_CENTER,
spaceBefore=0,
spaceAfter=12,
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,
))
# Table cell - no first-line indent, smaller font, CJK wrap for auto line break
ss.add(ParagraphStyle(
name="table-cell",
fontName="SrcSerif",
fontSize=9,
leading=13,
alignment=TA_LEFT,
firstLineIndent=0,
spaceBefore=0,
spaceAfter=0,
textColor=colors.HexColor("#1a1a1a"),
wordWrap="CJK",
))
# 表头水平居中,略加粗
ss.add(ParagraphStyle(
name="table-header",
fontName="SrcSans-Bold",
fontSize=9.5,
leading=14,
alignment=TA_CENTER,
firstLineIndent=0,
spaceBefore=0,
spaceAfter=0,
textColor=colors.HexColor("#1e3a8a"),
wordWrap="CJK",
))
# 短文本数字 cell(用于纯数字/短标签列,水平居中)
ss.add(ParagraphStyle(
name="table-cell-center",
fontName="SrcSerif",
fontSize=9,
leading=13,
alignment=TA_CENTER,
firstLineIndent=0,
spaceBefore=0,
spaceAfter=0,
textColor=colors.HexColor("#1a1a1a"),
wordWrap="CJK",
))
# TOC entry styles
ss.add(ParagraphStyle(
name="toc-h1",
fontName="SrcSans-Bold",
fontSize=11,
leading=18,
alignment=TA_LEFT,
firstLineIndent=0,
spaceBefore=6,
spaceAfter=2,
textColor=colors.HexColor("#1e3a8a"),
wordWrap="CJK",
))
ss.add(ParagraphStyle(
name="toc-h2",
fontName="SrcSerif",
fontSize=10,
leading=16,
alignment=TA_LEFT,
leftIndent=18,
firstLineIndent=0,
spaceBefore=1,
spaceAfter=1,
textColor=colors.HexColor("#374151"),
wordWrap="CJK",
))
# 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
_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]")
_CJK_ASCII_SPACE_RE = re.compile(
r"(?<=[\u4e00-\u9fff])(?=[A-Za-z0-9])|(?<=[A-Za-z0-9)\]])(?=[\u4e00-\u9fff])"
)
def _add_cjk_spaces(text: str) -> str:
"""在中文字符与 ASCII(英文/数字)交界处加半角空格,提升可读性。
作用范围故意保守:只在 CJK ↔ [A-Za-z0-9] 的边界插入空格,不影响
`[src_xxx]` 这种方括号内部,也不影响数字紧跟单位(如 "300 µg",因为
µ 是非 ASCII)。
"""
return _CJK_ASCII_SPACE_RE.sub(" ", text)
# Unicode 上标 → 常规数字/字母的映射(思源字体子集不含上标字形,需显式用 <super> 渲染)
_UNICODE_SUPER = {
"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4",
"⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9",
"⁺": "+", "⁻": "-", "⁼": "=", "⁽": "(", "⁾": ")",
"ⁱ": "i", "ⁿ": "n",
}
_UNICODE_SUB = {
"₀": "0", "₁": "1", "₂": "2", "₃": "3", "₄": "4",
"₅": "5", "₆": "6", "₇": "7", "₈": "8", "₉": "9",
"₊": "+", "₋": "-", "₌": "=", "₍": "(", "₎": ")",
}
_SUPER_CHARS_RE = re.compile(f"([{''.join(_UNICODE_SUPER)}]+)")
_SUB_CHARS_RE = re.compile(f"([{''.join(_UNICODE_SUB)}]+)")
# 彩色 emoji / 特殊符号 → 文字替代。思源字体子集不含这些字形,直接放会渲染成方框。
# 替换为字体里**实际存在**的符号(经过 fontTools 验证)。
# 验证命令见 scripts/lib/verify_font_glyphs.py
_EMOJI_FALLBACK = {
"✅": "✓", # U+2705 → U+2713 CHECK MARK(思源有)
"❌": "×", # U+274C → U+00D7 MULTIPLICATION SIGN(思源有,✗ U+2717 思源没有)
"✖": "×",
"✗": "×",
"🔶": "◆", # U+1F536 → U+25C6 BLACK DIAMOND(思源有)
"🔷": "◇", # U+25C7 WHITE DIAMOND(思源有)
"🟢": "●", # U+25CF BLACK CIRCLE(思源有)
"🔴": "●",
"🟡": "○", # U+25CB WHITE CIRCLE
"🟠": "○",
"⭐": "★", # U+2605 BLACK STAR(思源有)
"✔": "✓",
"☑": "[✓]", # U+2611 思源没有,用方括号包围替代
"☒": "[×]",
"☐": "[ ]",
"➔": "→",
"➜": "→",
"⚠️": "※", # U+203B REFERENCE MARK(思源有)
"⚠": "※",
"💡": "※",
"📌": "•",
"🔑": "※",
"📊": "※",
"📈": "※",
"📉": "※",
}
def _replace_unicode_superscripts(text: str) -> str:
"""把连续的 Unicode 上标字符替换为 ReportLab <super> 标签。
例:10⁶ → 10<super>6</super>
H₂O → H<sub>2</sub>O
思源字体子集不包含这些字形,直接放会渲染成方框。
"""
def _sup(m: "re.Match") -> str:
payload = "".join(_UNICODE_SUPER.get(c, c) for c in m.group(1))
return f"<super>{payload}</super>"
def _sub(m: "re.Match") -> str:
payload = "".join(_UNICODE_SUB.get(c, c) for c in m.group(1))
return f"<sub>{payload}</sub>"
text = _SUPER_CHARS_RE.sub(_sup, text)
text = _SUB_CHARS_RE.sub(_sub, text)
return text
def _replace_emoji(text: str) -> str:
"""把字体里没有的 emoji 替换为字体里有的等价符号。"""
for emoji, fallback in _EMOJI_FALLBACK.items():
if emoji in text:
text = text.replace(emoji, fallback)
return text
def md_inline_to_rl(text: str, *, add_cjk_space: bool = True) -> str:
"""Markdown inline → ReportLab mini HTML."""
# 先做 Unicode 上/下标归一(字体子集不含这些字形,否则渲染为方框)
text = _replace_unicode_superscripts(text)
# emoji 替换为字体里有的符号
text = _replace_emoji(text)
# 然后在中英交界处加空格
if add_cjk_space:
text = _add_cjk_spaces(text)
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)
# 引用 ID 支持字母+数字(src_042 / src_A14 / src_B-18
text = re.sub(
r"\[((?:src_[A-Za-z0-9_-]+)(?:\s*,\s*src_[A-Za-z0-9_-]+)*)\]",
lambda m: '<super><font size=7>['
+ m.group(1).replace(" ", "")
+ ']</font></super>',
text,
)
text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
return text
# H1 正文章节标题解析:拆成(章号, 章名)
# 支持:
# 第一章 — 为何... → ("第一章", "为何...")
# 第 9 章:四大监管向量... → ("第 9 章", "四大监管向量...")
# 第6章 — 固定化... → ("第6章", "固定化...")
# 第十章 — 制造体系... → ("第十章", "制造体系...")
# Chapter 1: Why the Second... → ("Chapter 1", "Why the Second...")
# 分隔符:—(em dash / –(en dash / - / : / / 空白多于一处
_CHAPTER_HEAD_RE = re.compile(
r"^\s*"
r"(?P<num>(?:第\s*[一二三四五六七八九十百零〇两廿卅\d]+\s*章)|(?:Chapter\s+\d+))"
r"\s*[—–\-:]\s*"
r"(?P<title>.+?)\s*$",
re.IGNORECASE,
)
def parse_chapter_title(raw: str) -> tuple[str, str] | None:
"""解析章节标题。命中返回 (章号, 章名),否则 None。"""
m = _CHAPTER_HEAD_RE.match(raw.strip())
if not m:
return None
num = m.group("num").strip()
title = m.group("title").strip()
if not title:
return None
# 规范化章号空白:"第 6 章" 保留"第 6 章""第6章"保留"第6章"
num = re.sub(r"\s+", " ", num)
return num, title
def build_chapter_header(raw_title: str, styles: StyleSheet1) -> list:
"""生成正文章节标题:两行居中 + 装饰横线。
解析失败时 fallback 到普通 h1 样式。
"""
parsed = parse_chapter_title(raw_title)
if parsed is None:
return [Paragraph(md_inline_to_rl(raw_title), styles["h1"])]
num, title = parsed
return [
Paragraph(md_inline_to_rl(num), styles["h1-chapter-num"]),
Paragraph(md_inline_to_rl(title), styles["h1-chapter-title"]),
# 装饰横线:居中、宽度约 3cm(视觉重量跟两行标题平衡)
HRFlowable(
width=3 * cm,
thickness=1.2,
color=colors.HexColor("#1e3a8a"),
spaceBefore=2,
spaceAfter=18,
hAlign="CENTER",
),
]
# ============================================================
# 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, blocks: List[Block], styles: StyleSheet1) -> List:
"""构建封面:以 manifest 为准,完全不依赖正文第一段。
正文里的 H1 标题 + 元信息段会在 build_body 阶段被识别并跳过,
避免"封面和第一页重复"的 v0.5 老问题。
"""
story = []
story.append(Spacer(1, 5 * cm))
story.append(Paragraph(manifest.report_title, styles["cover-title"]))
if manifest.report_subtitle:
story.append(Spacer(1, 0.5 * cm))
story.append(Paragraph(manifest.report_subtitle, styles["cover-subtitle"]))
story.append(Spacer(1, 4.5 * cm))
if manifest.confidentiality:
story.append(Paragraph(manifest.confidentiality, styles["cover-confidential"]))
story.append(Spacer(1, 1.5 * cm))
if manifest.type:
story.append(Paragraph(f"类型:{manifest.type}", styles["cover-meta"]))
story.append(Paragraph(f"作者:{manifest.author}", styles["cover-meta"]))
if manifest.date:
story.append(Paragraph(f"编制日期:{manifest.date}", styles["cover-meta"]))
story.append(Paragraph(f"版本:v{manifest.version}", styles["cover-meta"]))
story.append(PageBreak())
return story
# ============================================================
# 占位符识别 & 自动生成内容
# ============================================================
# 识别"目录将在最终渲染时自动生成"这类占位段落(dr-editor-in-chief 写的模板行)
_TOC_PLACEHOLDER_RE = re.compile(r"目录将在最终渲染时自动生成|TOC will be generated|\[TOC\]", re.IGNORECASE)
_REF_PLACEHOLDER_RE = re.compile(
r"完整编号参考文献列表将在此处呈现|将正文中每个.*src_xxx.*标识符映射|\[REFERENCES\]",
re.IGNORECASE,
)
# 跳过封面 H1(正文第一个 H1 + 其后直到第一个 "---" 或 "## " 的所有段落)
# 这部分内容由 build_cover 从 manifest 生成。
_COVER_FRONTMATTER_PATTERNS = (
"confidentiality", "date:", "version:", "system:", "机密",
)
def is_cover_frontmatter(text: str) -> bool:
"""判断一段正文是否是封面元信息(Confidentiality/Date/Version/System 混排)。"""
low = text.lower()
hits = sum(1 for pat in _COVER_FRONTMATTER_PATTERNS if pat in low)
return hits >= 2
def collect_toc_entries(blocks: List[Block]) -> List[tuple[int, str]]:
"""从 blocks 里抽 H1/H2 生成 TOC 条目。返回 [(level, title)]。
跳过一些不该进 TOC 的标题:目录本身、免责声明、摘要、术语表、参考文献、版本历史、附录。
"""
skip_titles_substr = (
"目录", "table of contents",
"免责声明", "disclaimer",
"执行摘要", "executive summary",
"摘要", "abstract",
"术语表", "glossary",
"参考文献", "references",
"版本历史", "version history",
"附录", "appendix",
)
entries: list[tuple[int, str]] = []
for b in blocks:
if b.kind not in ("h1", "h2"):
continue
title = b.content.strip()
if any(s in title.lower() for s in skip_titles_substr):
continue
level = 1 if b.kind == "h1" else 2
entries.append((level, title))
return entries
def build_toc(blocks: List[Block], styles: StyleSheet1) -> List:
"""生成目录条目。
不在本函数内部 PageBreak——前面由调用方(H1/H2 分支)插入 PageBreak
后面靠下一个章节的 H1 PageBreak 自然起作用。避免"连续 PageBreak 产生空页"。
"""
story: list = []
story.append(Paragraph("目录", styles["h1"]))
story.append(Spacer(1, 0.4 * cm))
for level, title in collect_toc_entries(blocks):
style_name = "toc-h1" if level == 1 else "toc-h2"
story.append(Paragraph(md_inline_to_rl(title), styles[style_name]))
return story
# ============================================================
# sources.jsonl → 参考文献列表
# ============================================================
_SRC_ID_RE = re.compile(r"\[(src_[A-Za-z0-9_-]+(?:\s*,\s*src_[A-Za-z0-9_-]+)*)\]")
def collect_cited_src_ids(blocks: List[Block]) -> List[str]:
"""扫描全文收集被引用的 src_xxx(保序去重)。"""
seen: set[str] = set()
order: list[str] = []
for b in blocks:
if b.kind in ("image",):
continue
for m in _SRC_ID_RE.finditer(b.content):
for sid in m.group(1).split(","):
sid = sid.strip()
if sid and sid not in seen:
seen.add(sid)
order.append(sid)
return order
def load_sources_jsonl(path: Path) -> dict[str, dict]:
"""加载 sources.jsonl,返回 {src_id: record}。"""
if not path or not path.exists():
return {}
out: dict[str, dict] = {}
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line:
continue
try:
rec = json.loads(line)
sid = rec.get("id")
if sid:
out[sid] = rec
except Exception:
continue
return out
def format_gb7714(rec: dict) -> str:
"""按 GB/T 7714-2015 生成参考文献条目(简化版)。
字段容错:authors/title/year/venue/url/doi/type 都可能缺失。
"""
authors = rec.get("authors") or rec.get("author") or ""
title = rec.get("title", "").strip()
year = rec.get("year", "")
venue = rec.get("venue", "")
url = rec.get("url", "")
doi = rec.get("doi", "")
rec_type = (rec.get("type") or "").lower()
type_tag = {
"journal": "[J]",
"article": "[J]",
"book": "[M]",
"report": "[R]",
"patent": "[P]",
"thesis": "[D]",
"standard": "[S]",
"news": "[N/OL]",
"web": "[EB/OL]",
"preprint": "[J/OL]",
"database": "[DB/OL]",
}.get(rec_type, "[EB/OL]")
parts: list[str] = []
if authors:
parts.append(str(authors).rstrip("."))
if title:
parts.append(f"{title}{type_tag}")
tail: list[str] = []
if venue:
tail.append(str(venue))
if year:
tail.append(str(year))
if tail:
parts.append(", ".join(tail) + ".")
if doi:
parts.append(f"DOI: {doi}.")
if url:
parts.append(f"[{rec.get('accessed_at', '')}]. {url}" if rec.get("accessed_at") else url)
body = " ".join(p for p in parts if p).strip()
return body
def _sort_src_id(sid: str) -> tuple:
"""为 src_id 生成排序键:按字母段分组(A/B/C/E/...),组内按数字升序。"""
m = re.match(r"src_([A-Za-z]+)?(\d+)?([A-Za-z0-9_\-]*)", sid)
if not m:
return ("~", 0, sid)
alpha, num, rest = m.group(1) or "", m.group(2) or "0", m.group(3) or ""
try:
num_int = int(num)
except ValueError:
num_int = 0
return (alpha, num_int, rest)
def build_references(
blocks: List[Block],
sources_path: Optional[Path],
styles: StyleSheet1,
) -> List:
"""生成参考文献段落。
v0.7 改变:**不再按出现顺序重编号**(之前会导致正文中 `[src_E43]` 和参考文献
区的 `[27]` 对不上)。改为:
- 参考文献条目直接用原始 `src_id` 作为编号(如 `[src_E43] Alnylam..., 2025.`
- 按 src_id 字母数字排序分组
- 缺失的 src_id 单独一段列出,明显标注供人工核查
- 顶部给一条"引文健康状态"小结
"""
story: list = []
story.append(Paragraph("参考文献", styles["h1"]))
story.append(Spacer(1, 0.4 * cm))
sources = load_sources_jsonl(sources_path) if sources_path else {}
cited_ids = collect_cited_src_ids(blocks)
if not cited_ids:
story.append(Paragraph(
"(正文未发现 [src_xxx] 引用标注)",
styles["caption"],
))
return story
cited_set = set(cited_ids)
matched = [sid for sid in cited_ids if sid in sources]
missing = [sid for sid in cited_ids if sid not in sources]
# sources.jsonl 里有但正文没引用的——列为"备选"不展示,只统计
unused = [sid for sid in sources if sid not in cited_set]
# 头部健康状态
health = (
f"正文引用 <b>{len(cited_set)}</b> 条独立标识符;"
f"sources.jsonl 收录 <b>{len(sources)}</b> 条,"
f"<b>{len(matched)}</b> 条可对应,"
f"<b>{len(missing)}</b> 条在 sources.jsonl 中未找到。"
)
if unused:
health += f" 另有 {len(unused)} 条收录来源未在正文中引用,已省略展示。"
story.append(Paragraph(
f"<font color='#6b7280' size=8>引文健康状态:{health}</font>",
styles["caption"],
))
story.append(Spacer(1, 0.3 * cm))
# 主列表:按 src_id 字母数字排序
if matched:
story.append(Paragraph(
"<b>收录来源</b>",
styles["h3"],
))
for sid in sorted(matched, key=_sort_src_id):
rec = sources[sid]
text = format_gb7714(rec)
# 编号就是原始 sid,便于和正文中的 [src_E43] 上标对应
entry = f"<b>[{sid}]</b> {text}"
story.append(Paragraph(entry, styles["footnote"]))
# 缺失列表:明显标注
if missing:
story.append(Spacer(1, 0.4 * cm))
story.append(Paragraph(
f"<b>未找到来源({len(missing)} 条)</b>",
styles["h3"],
))
story.append(Paragraph(
"<font color='#b45309' size=8>"
"以下标识符在正文中出现但未在 <code>sources.jsonl</code> 中找到对应记录。"
"可能是编写阶段的占位符未回填,或原始研究员引用不规范,请核查后补充。"
"</font>",
styles["caption"],
))
# 按字母数字排序分组展示,一行三个,节省篇幅
sorted_missing = sorted(missing, key=_sort_src_id)
# 每 4 个一行
row_size = 4
for k in range(0, len(sorted_missing), row_size):
chunk = sorted_missing[k : k + row_size]
row_text = "  ".join(f"[{sid}]" for sid in chunk)
story.append(Paragraph(
f"<font color='#b45309'>{row_text}</font>",
styles["footnote"],
))
# 打印到 stderr
if missing:
print(
f"WARNING: {len(missing)} cited src_ids not found in sources.jsonl: "
f"{', '.join(missing[:5])}{'...' if len(missing) > 5 else ''}",
file=sys.stderr,
)
return story
def _is_short_cell(text: str) -> bool:
"""判断 cell 文本是否短到适合居中(简单表格风格)。
规则:
- 纯数字/范围(含 ±, %, –, nm, µg 等常见单位)居中
- 短标签(<= 10 字符,不含标点)居中
- 其它(段落级文本)左对齐
"""
t = text.strip()
if not t:
return True
# 纯数字 / 范围 / 单位
if re.match(r"^[\d.,\s\-\\—±×/%]+\s*[A-Za-zµ°%]*$", t):
return True
# 短标签(排除常见句末标点)
if len(t) <= 10 and not any(p in t for p in ",。;:!?,.;:!?"):
return True
return False
def _render_table_cell(text: str, styles: StyleSheet1) -> "Paragraph":
content = md_inline_to_rl(text)
style = styles["table-cell-center"] if _is_short_cell(text) else styles["table-cell"]
return Paragraph(content, style)
def render_table(md_table: str, styles: StyleSheet1) -> Table:
"""渲染 Markdown 表格为 ReportLab Table。
规则:
- 首行用 table-header 样式(水平居中 + 加粗 + 深蓝色)
- 短 cell(纯数字/单位/短标签)水平居中
- 长 cell(段落级文本)左对齐
- 所有 cell 垂直居中
- 长文字自动 CJK 换行
- 长表自动按行分页
"""
rows: list[list] = []
raw_rows = []
for line in md_table.strip().split("\n"):
line = line.strip().strip("|")
cells = [c.strip() for c in line.split("|")]
raw_rows.append(cells)
if not raw_rows:
return Table([[""]])
header_cells = raw_rows[0]
rows.append([
Paragraph(md_inline_to_rl(c), styles["table-header"]) for c in header_cells
])
for cells in raw_rows[1:]:
# 补齐列数(防御性)
while len(cells) < len(header_cells):
cells.append("")
rows.append([_render_table_cell(c, styles) for c in cells])
table = Table(rows, repeatRows=1, splitByRow=True)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#e0e7ff")),
("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), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
]))
return table
def _render_generic_block(block: Block, story: list, base_dir: Path, styles: StyleSheet1, *, in_summary: bool) -> None:
"""渲染一个非 H1/H2 的 blockp/quote/bullet/hr/image/table/h3)。
提取出来的帮助函数,给术语表内部循环和主循环复用。
"""
if block.kind == "h3":
story.append(Paragraph(md_inline_to_rl(block.content), styles["h3"]))
elif block.kind == "p":
if _TOC_PLACEHOLDER_RE.search(block.content) or _REF_PLACEHOLDER_RE.search(block.content):
return
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"]))
def build_body(
blocks: List[Block],
base_dir: Path,
styles: StyleSheet1,
*,
sources_path: Optional[Path] = None,
) -> List:
"""把 Markdown blocks 渲染为 flowable。
v0.6 升级:
- 跳过正文开头的封面 H1 + 紧跟的元信息段(由 build_cover 独立生成,避免重复)
- 识别"目录"占位段落 → 自动生成 TOC
- 识别"参考文献"占位段落 → 自动读 sources.jsonl 生成 GB/T 7714 列表
- H1 triggers PageBreakH2/H3 keepWithNext;表格 splitByRow
"""
story: list = []
in_summary = False
# 第一步:跳过"封面块"——从正文开头一直跳到第一个 H2/H3 前。
# 封面块 = 首个 H1(主标题) + 副标题(加粗 p) + 元信息段(Confidentiality/Date/Version + 分隔线(hr)。
# 这些已由 build_cover 从 manifest 独立生成,正文里再出现就是重复。
# 规则简单可靠:跳过所有 block 直到遇到第一个 H2/H3(如 "## 免责声明")。
n = len(blocks)
first_section_idx = n
for k, b in enumerate(blocks):
if b.kind in ("h2", "h3"):
first_section_idx = k
break
i = first_section_idx # 从第一个 section 开始处理
def _title_kind(title_raw: str) -> str:
"""识别标题的"语义类别"。无论原文 H1 或 H2,统一归类。
返回:
'abstract' — 摘要(将被跳过)
'appendix' — 附录(将被跳过)
'version_history' — 版本历史(将被跳过)
'toc' — 目录(自动生成)
'references' — 参考文献(自动生成)
'glossary' — 术语表(独立章节)
'disclaimer' — 免责声明(独立章节)
'executive_summary' — 执行摘要(独立章节)
'chapter' — 正文章节(默认)
"""
t = title_raw.strip().lower()
# 跳过类
if t in ("摘要", "abstract"):
return "abstract"
if t.startswith("附录") or t.startswith("appendix"):
return "appendix"
if t in ("版本历史", "version history", "版本"):
return "version_history"
# 自动生成类
if t in ("目录", "table of contents"):
return "toc"
if t in ("参考文献", "references", "bibliography"):
return "references"
# 识别类(带 PageBreak 独立成章)
if t in ("术语表", "glossary"):
return "glossary"
if t in ("免责声明", "disclaimer"):
return "disclaimer"
if t in ("执行摘要", "executive summary", "管理层摘要"):
return "executive_summary"
return "chapter"
def _consume_until_next_section(start: int) -> int:
"""从 start 开始收集内容(非 H1/H2 的 block),返回下一个 H1/H2 的索引。"""
j = start
while j < n and blocks[j].kind not in ("h1", "h2"):
_render_generic_block(blocks[j], story, base_dir, styles, in_summary=in_summary)
j += 1
return j
def _skip_until_next_section(start: int) -> int:
"""从 start 开始跳过内容,返回下一个 H1/H2 的索引。"""
j = start
while j < n and blocks[j].kind not in ("h1", "h2"):
j += 1
return j
# 收集前置件(在第一个"正文 H1 章节"之前的所有内容)按 title_kind 分组。
# 然后按固定顺序输出:免责声明 → 执行摘要 → 目录 → 术语表 → 正文 → 参考文献。
# 这样无论 Markdown 里写的顺序如何,最终排版都一致(目录在术语表之前)。
first_h1_idx = n
for k in range(i, n):
if blocks[k].kind == "h1" and _title_kind(blocks[k].content) == "chapter":
first_h1_idx = k
break
# 收集"前置件段":从 i 到 first_h1_idx
front_sections: dict[str, list[Block]] = {}
def _collect_section(start: int, until: int) -> tuple[str, list[Block], int]:
"""从 start 处的 H1/H2 开始,收集这一 section 直到下一个 H1/H2(或 until)。
返回 (kind, blocks 列表, 下一个 section 的起始索引)。
"""
head = blocks[start]
kind = _title_kind(head.content)
sec = [head]
k = start + 1
while k < until and blocks[k].kind not in ("h1", "h2"):
sec.append(blocks[k])
k += 1
return kind, sec, k
# 在前置件区域内遍历
k = i
while k < first_h1_idx:
b = blocks[k]
if b.kind not in ("h1", "h2"):
k += 1
continue
kind, sec, next_k = _collect_section(k, first_h1_idx)
if kind in ("abstract", "appendix", "version_history"):
pass # 丢弃
elif kind in front_sections:
# 重复出现:保留第一份
pass
else:
front_sections[kind] = sec
k = next_k
# 前置件输出顺序(固定)
front_order = [
"disclaimer", # 免责声明
"executive_summary", # 执行摘要
"toc", # 目录
"glossary", # 术语表
]
def _render_head_section(kind: str, sec: list[Block]) -> None:
"""渲染一个前置件 section。sec[0] 是标题,其余是正文。"""
nonlocal in_summary
# 独立章节前加 PageBreak(但第一个除外,避免封面后空白页)
if len(story) > 0:
story.append(PageBreak())
in_summary = (kind == "executive_summary")
head = sec[0]
# TOC 和 references 调用专门的生成器
if kind == "toc":
story.extend(build_toc(blocks, styles))
return
if kind == "references":
story.extend(build_references(blocks, sources_path, styles))
return
# 其它前置件:H1 样式渲染标题 + 内容
story.append(Paragraph(md_inline_to_rl(head.content), styles["h1"]))
for sub in sec[1:]:
# 跳过占位符段
if sub.kind == "p" and (
_TOC_PLACEHOLDER_RE.search(sub.content)
or _REF_PLACEHOLDER_RE.search(sub.content)
):
continue
_render_generic_block(sub, story, base_dir, styles, in_summary=in_summary)
for kind in front_order:
if kind in front_sections:
_render_head_section(kind, front_sections[kind])
in_summary = False
# 现在输出正文(从 first_h1_idx 开始)
i = first_h1_idx
while i < n:
block = blocks[i]
if block.kind not in ("h1", "h2"):
_render_generic_block(block, story, base_dir, styles, in_summary=in_summary)
i += 1
continue
kind = _title_kind(block.content)
# 跳过类
if kind in ("abstract", "appendix", "version_history"):
i = _skip_until_next_section(i + 1)
continue
# 参考文献:自动生成
if kind == "references":
story.append(PageBreak())
story.extend(build_references(blocks, sources_path, styles))
j = i + 1
while j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content):
j += 1
i = j
continue
# 目录/术语表不应在正文中出现(已经作为前置件处理)
# 如果原文里在正文中又写了一遍目录/术语表,则跳过
if kind in ("toc", "glossary", "disclaimer", "executive_summary"):
i = _skip_until_next_section(i + 1)
continue
# H1 正文章节(chapter):PageBreak + 章号/章名双行居中 + 装饰线
if block.kind == "h1":
story.append(PageBreak())
story.extend(build_chapter_header(block.content, styles))
else:
# H2 正文小节:h2 样式(不分页)
story.append(Paragraph(md_inline_to_rl(block.content), styles["h2"]))
i += 1
return story
# ============================================================
# Main
# ============================================================
def main():
parser = argparse.ArgumentParser(description="Deep Research PDF Generator (v0.6)")
parser.add_argument("--input", required=True, help="Input markdown (final_zh_polished.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",
)
parser.add_argument(
"--sources",
default=None,
help="sources.jsonl 路径(默认自动在 <project>/phase2/sources.jsonl 查找)",
)
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)
# Sources.jsonl 自动发现
if args.sources:
sources_path = Path(args.sources)
else:
# 默认 <project_root>/phase2/sources.jsonl
project_root = manifest_path.parent
candidate = project_root / "phase2" / "sources.jsonl"
sources_path = candidate if candidate.exists() else None
if sources_path and not sources_path.exists():
print(f"WARNING: sources file not found: {sources_path}", file=sys.stderr)
sources_path = None
# 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
# 注意:封面只从 manifest 构建,正文中的封面 H1+元信息会被 build_body 自动跳过。
# 免责声明来自 Markdown## 免责声明),不再从 manifest 额外构建(避免重复)。
story: List = []
story.extend(build_cover(manifest, blocks, styles))
story.append(NextPageTemplate("normal"))
story.extend(build_body(blocks, md_path.parent, styles, sources_path=sources_path))
# 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 sources_path:
print(f" Sources: {sources_path}")
else:
print(f" Sources: (none — references section will show placeholder)")
if size < 500 * 1024:
print(f" WARNING: PDF size < 500KB, fonts may not be properly embedded", file=sys.stderr)
if __name__ == "__main__":
main()