#!/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//phase4/final_zh.md \ --manifest projects//manifest.json \ --output projects//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 上标 → 常规数字/字母的映射(思源字体子集不含上标字形,需显式用 渲染) _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 标签。 例:10⁶ → 106 H₂O → H2O 思源字体子集不包含这些字形,直接放会渲染成方框。 """ def _sup(m: "re.Match") -> str: payload = "".join(_UNICODE_SUPER.get(c, c) for c in m.group(1)) return f"{payload}" def _sub(m: "re.Match") -> str: payload = "".join(_UNICODE_SUB.get(c, c) for c in m.group(1)) return f"{payload}" 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"\1", text) text = re.sub(r"(?\1", text) text = re.sub(r"`([^`]+)`", r'\1', 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: '[' + m.group(1).replace(" ", "") + ']', 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(?:第\s*[一二三四五六七八九十百零〇两廿卅\d]+\s*章)|(?:Chapter\s+\d+))" r"\s*[—–\-::]\s*" r"(?P.+?)\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 的 block(p/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 PageBreak;H2/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()