Files
deep_research/.opencode/templates/report-template.py
T
kaiandUser <human> a86010e9a7 v0.6-wip: polish pipeline + PDF template fixes
Phase 4 \u6da6\u8272\u5c42\u4e0e PDF \u6a21\u677f\u4fee\u590d\uff0c\u63a5\u7740\u4e0a\u4e00\u4e2a commit\u3002

polish.py\uff08\u65b0\u589e\uff09\uff1a
- \u548c translate.py \u5bf9\u79f0\uff0c\u6309 H2 section \u5207\u5757 \u2192 \u5faa\u73af\u6da6\u8272 \u2192 \u62fc\u63a5
- \u4f7f\u7528 <<<POLISHED>>>/<<<NOTES>>> \u5206\u9694\u7b26 prompt\uff08\u907f\u5f00 Markdown-in-JSON \u95ee\u9898\uff09
- \u65ad\u70b9\u7eed\u4f20\u3001\u6a21\u578b\u81ea\u8bc4\u6ce8\u8bb0\u843d\u76d8 polish_notes.jsonl
- \u5728\u53cc\u9776\u70b9 RNAi \u9879\u76ee\u8dd1\u901a\uff1a60 \u5757\u5168\u6210\u529f\uff0c10.7 \u5206\u949f\uff0c$1.20\uff0c\u5b57\u6570 -0.2%

report-template.py\uff08\u5927\u6539\u4e00\u6279 P0 bug\uff09\uff1a
- \u5b57\u4f53\u6ce8\u518c\u652f\u6301 fonts/ttf/ \u5b50\u76ee\u5f55\uff08\u89e3\u51b3 OTF PostScript outlines \u4e0d\u517c\u5bb9\uff09
- \u5220\u9664 build_disclaimer \u91cd\u590d\u8c03\u7528\uff08\u514d\u8d23\u58f0\u660e\u4ece Markdown \u8bfb\uff0cmanifest \u4e0d\u518d\u91cd\u590d\uff09
- build_body \u81ea\u52a8\u8df3\u8fc7\u6b63\u6587\u9996\u4e2a H1+\u5c01\u9762\u5143\u4fe1\u606f\u6bb5\uff08\u4e0e\u5c01\u9762\u91cd\u590d\uff09
- \u5360\u4f4d\u7b26 \u201c\u76ee\u5f55\u5c06\u5728\u6700\u7ec8\u6e32\u67d3\u65f6\u81ea\u52a8\u751f\u6210\u201d \u2192 \u81ea\u52a8\u751f\u6210 TOC
- \u5360\u4f4d\u7b26 \u201c\u5b8c\u6574\u7f16\u53f7\u53c2\u8003\u6587\u732e\u5217\u8868\u2026\u201d \u2192 \u4ece phase2/sources.jsonl \u81ea\u52a8\u751f\u6210 GB/T 7714 \u683c\u5f0f\u5f15\u6587
- src \u4e0a\u6807\u6b63\u5219\u6269\u5c55\uff1a\u652f\u6301 src_A14 / src_B-18 \u7b49\u5b57\u6bcd+\u6570\u5b57\u7ec4\u5408 ID\uff08\u539f\u53ea\u652f\u6301 src_\d+\uff09
- Unicode \u4e0a/\u4e0b\u6807\u8f6c <super>/<sub>\uff1a10\u2076 \u2192 10<super>6</super>\uff08\u601d\u6e90\u5b57\u4f53\u5b50\u96c6\u4e0d\u542b\u4e0a\u6807\u5b57\u5f62\uff0c\u5426\u5219\u6e32\u67d3\u65b9\u6846\uff09
- \u4e2d\u82f1\u6df7\u6392\u81ea\u52a8\u52a0\u7a7a\u683c\uff08CJK \u2194 [A-Za-z0-9] \u8fb9\u754c\uff09
- \u8868\u683c\u6837\u5f0f\u91cd\u505a\uff1atable-header/table-cell/table-cell-center\uff1b\u5782\u76f4\u5c45\u4e2d\uff1b\u77ed cell\uff08\u7eaf\u6570\u5b57/\u77ed\u6807\u7b7e\uff09\u6c34\u5e73\u5c45\u4e2d\uff1b\u957f cell \u81ea\u52a8 CJK \u6362\u884c
- TOC \u672b\u5c3e PageBreak\uff08\u76ee\u5f55\u72ec\u5360\u6574\u9875\uff09

\u5df2\u77e5\u672a\u4fee\u590d\uff1a
- Maywavee \u662f LLM \u5728 dr-analyst \u9636\u6bb5\u7f16\u9020\uff0c\u6b63\u786e\u4e3a Mabwell\uff08\u8fc8\u5a01\u751f\u7269\uff09\u3002\u4fe1\u6e90\u4fa7 bug\uff0c\u9700\u5728\u540e\u7eed build_glossary.py \u4e2d\u505a\u4e8b\u5b9e\u6838\u67e5\u3002
- \u6b63\u6587 101 \u4e2a src_id\u3001sources.jsonl \u53ea\u670947 \u4e2a\u3001\u4ea4\u96c6 39 \u4e2a\u2014\u2014\u662f v0.4 \u9057\u7559\u7684\u6ce8\u5165 bug\uff0cbuild_references \u73b0\u5728\u4f1a\u5217\u51fa\u7f3a\u5931\u7684 id \u4f9b\u4eba\u5de5\u6838\u5bf9
- \u53cd\u9a73\u8bc1\u636e\u6bb5\u683c\u5f0f\u4e0d\u7edf\u4e00\u662f dr-analyst/skill \u89c4\u8303\u95ee\u9898\uff0c\u4e0b\u4e00\u6279\u6539 skill

Co-authored-by: User <human>
2026-04-22 12:58:07 +08:00

1212 lines
40 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 _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
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,
))
# 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)}]+)")
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 md_inline_to_rl(text: str, *, add_cjk_space: bool = True) -> str:
"""Markdown inline → ReportLab mini HTML."""
# 先做 Unicode 上/下标归一(字体子集不含这些字形,否则渲染为方框)
text = _replace_unicode_superscripts(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
# ============================================================
# 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 让后续内容独立成页。开头不 PageBreak,
调用方(H1 分支)已经负责在 H1 前另起一页。
"""
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]))
story.append(PageBreak())
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 build_references(
blocks: List[Block],
sources_path: Optional[Path],
styles: StyleSheet1,
) -> List:
"""生成参考文献段落。
引用顺序:按正文首次出现的先后排列(GB/T 7714 顺序编码制)。
"""
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
if not sources:
# 至少列出所有被引用的 ID,供人工回填
story.append(Paragraph(
f"(未找到 sources.jsonl 或其内容为空。以下为正文出现的 {len(cited_ids)} 个引用标识符)",
styles["caption"],
))
for i, sid in enumerate(cited_ids, 1):
story.append(Paragraph(f"[{i}] {sid}", styles["footnote"]))
return story
missing: list[str] = []
for i, sid in enumerate(cited_ids, 1):
rec = sources.get(sid)
if not rec:
missing.append(sid)
story.append(Paragraph(
f"[{i}] {sid}(来源记录缺失,请核查 sources.jsonl",
styles["footnote"],
))
continue
text = format_gb7714(rec)
# 前面加序号,后面追加 [sid] 便于正文回溯
entry = f"[{i}] {text} <font color='#6b7280' size=7>【{sid}】</font>"
story.append(Paragraph(entry, styles["footnote"]))
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 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 = []
first_h1_seen = False # 是否已跳过正文首个 H1
skipping_cover_meta = False # 是否在吞掉封面元信息段
# Summary 样式
in_summary = False
# 准备跳过标志:标题级别下一个 "目录""参考文献" 见到时替换掉它(包含其下紧跟的占位段)
# 采用简单索引遍历以便向前看。
i = 0
n = len(blocks)
while i < n:
block = blocks[i]
# --- 跳过正文首个 H1(封面标题)+ 紧跟的元信息/hr ---
if not first_h1_seen and block.kind == "h1":
first_h1_seen = True
skipping_cover_meta = True
i += 1
continue
if skipping_cover_meta:
# 吞掉 p(元信息)、hr、quote(副标题可能被当成加粗段)
# 遇到 h1/h2/h3 就停止吞
if block.kind in ("h1", "h2", "h3"):
skipping_cover_meta = False
# 不 continue,让当前 block 正常处理
elif block.kind == "p" and is_cover_frontmatter(block.content):
i += 1
continue
elif block.kind in ("hr", "quote", "p", "bullet"):
# 第一个 hr 标记封面结束
if block.kind == "hr":
skipping_cover_meta = False
i += 1
continue
# 普通段落:如果不是封面元信息,就认为封面已结束
if block.kind == "p":
skipping_cover_meta = False
# fall through to normal handling
else:
i += 1
continue
else:
i += 1
continue
# --- H1 处理(非首个)---
if block.kind == "h1":
story.append(PageBreak())
content = block.content
if any(k in content for k in ("执行摘要", "Executive Summary", "管理层摘要")):
in_summary = True
else:
in_summary = False
# 目录 / 参考文献:替换为自动生成的内容
title_low = content.strip().lower()
if any(s in title_low for s in ("目录", "table of contents")):
story.extend(build_toc(blocks, styles))
# 跳过紧随其后的占位段
j = i + 1
while j < n and blocks[j].kind == "p" and _TOC_PLACEHOLDER_RE.search(blocks[j].content):
j += 1
i = j
continue
if any(s in title_low for s in ("参考文献", "references")):
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
story.append(Paragraph(md_inline_to_rl(content), styles["h1"]))
i += 1
continue
# --- H2 同样检测占位符 ---
if block.kind == "h2":
title_low = block.content.strip().lower()
if any(s in title_low for s in ("目录", "table of contents")):
story.extend(build_toc(blocks, styles))
j = i + 1
while j < n and blocks[j].kind == "p" and _TOC_PLACEHOLDER_RE.search(blocks[j].content):
j += 1
i = j
continue
if any(s in title_low for s in ("参考文献", "references")):
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
story.append(Paragraph(md_inline_to_rl(block.content), styles["h2"]))
i += 1
continue
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):
i += 1
continue
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"]))
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()