#!/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, ) 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 上标 → 常规数字/字母的映射(思源字体子集不含上标字形,需显式用 渲染) _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 标签。 例: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 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"\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 # ============================================================ # 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} 【{sid}】" 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 PageBreak;H2/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 路径(默认自动在 /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: # 默认 /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()