#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Deep Research 中文 PDF 报告模板（ReportLab v0.5）

v0.5 升级点（对比 v0.4）：
- 封面：主标题 + 副标题 + 保密标识（红色）+ 编制日期（参考 9MW1911 风格）
- 分页规则：widows=2, orphans=2, keepWithNext 防孤行寡行
- 表格：splitByRow=True, repeatRows=1 防断页
- 颜色层次：h1 深蓝 / h2 蓝 / h3 深灰
- 段落首行缩进 2 字符，行高 × 1.7
- 参考文献：自动解析 "## 参考文献" / "## References" 段落

用法：
    uv run python3 report-template.py \
        --input  projects/<slug>/phase4/final_zh.md \
        --manifest projects/<slug>/manifest.json \
        --output projects/<slug>/phase4/final.pdf \
        --fonts-dir .opencode/templates/fonts
"""

from __future__ import annotations

import argparse
import json
import re
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional

try:
    from reportlab.lib import colors
    from reportlab.lib.enums import TA_CENTER, TA_JUSTIFY, TA_LEFT
    from reportlab.lib.pagesizes import A4
    from reportlab.lib.styles import ParagraphStyle, StyleSheet1
    from reportlab.lib.units import cm
    from reportlab.pdfbase import pdfmetrics
    from reportlab.pdfbase.ttfonts import TTFont
    from reportlab.platypus import (
        BaseDocTemplate,
        Frame,
        Image,
        NextPageTemplate,
        PageBreak,
        PageTemplate,
        Paragraph,
        Spacer,
        Table,
        TableStyle,
    )
    from reportlab.platypus.flowables import Flowable, 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,
    ))

    # Inline evidence footnotes placed by number_citations / finalization.
    ss.add(ParagraphStyle(
        name="evidence-footnote",
        fontName="SrcSerif",
        fontSize=7.4,
        leading=10,
        alignment=TA_JUSTIFY,
        leftIndent=18,
        firstLineIndent=-18,
        spaceBefore=0,
        spaceAfter=2,
        textColor=colors.HexColor("#4b5563"),
        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,
    ))

    # Ordered list. Keep references flush-left; do not prepend decorative bullets.
    ss.add(ParagraphStyle(
        name="ordered",
        fontName="SrcSerif",
        fontSize=9,
        leading=13,
        alignment=TA_JUSTIFY,
        firstLineIndent=0,
        leftIndent=0,
        spaceBefore=0,
        spaceAfter=4,
        textColor=colors.HexColor("#374151"),
        wordWrap="CJK",
        allowWidows=0,
        allowOrphans=0,
    ))

    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


class FootnoteFlowable(Flowable):
    """Zero-height anchor that registers a footnote for the current PDF page."""

    def __init__(self, label: str, content: str):
        super().__init__()
        self.label = label
        self.content = content
        self.width = 0
        self.height = 0

    def wrap(self, availWidth, availHeight):
        return 0, 0

    def draw(self):
        page = self.canv.getPageNumber()
        notes = getattr(self.canv, "_dr_footnotes", None)
        if notes is None:
            notes = {}
            setattr(self.canv, "_dr_footnotes", notes)
        notes.setdefault(page, []).append((self.label, self.content))


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

        # Markdown footnote definition: [^1]: 原文摘录...
        m = re.match(r"^\[\^([A-Za-z0-9_-]+)\]:\s*(.+)$", stripped)
        if m:
            blocks.append(Block(
                kind="footnote",
                content=m.group(2).strip(),
                meta={"label": m.group(1)},
            ))
            i += 1
            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):
            while i < len(lines) and re.match(r"^\d+\.\s+", lines[i].strip()):
                m = re.match(r"^(\d+)\.\s+(.+)$", lines[i].strip())
                if not m:
                    break
                blocks.append(Block(kind="ordered", content=f"{m.group(1)}. {m.group(2)}"))
                i += 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 re.match(r"^\[\^[A-Za-z0-9_-]+\]:", lines[i].strip())
            or "|" in lines[i]
        ):
            para_lines.append(lines[i])
            i += 1
        blocks.append(Block(kind="p", content=" ".join(l.strip() for l in para_lines)))

    return blocks


_CJK_RE = re.compile(r"[\u4e00-\u9fff\u3400-\u4dbf]")
_CJK_ASCII_SPACE_RE = re.compile(
    r"(?<=[\u4e00-\u9fff])(?=[A-Za-z0-9])|(?<=[A-Za-z0-9)\]])(?=[\u4e00-\u9fff])"
)


def _add_cjk_spaces(text: str) -> str:
    """在中文字符与 ASCII（英文/数字）交界处加半角空格，提升可读性。

    作用范围故意保守：只在 CJK ↔ [A-Za-z0-9] 的边界插入空格，不影响
    `[src_xxx]` 这种方括号内部，也不影响数字紧跟单位（如 "300 µg"，因为
    µ 是非 ASCII）。
    """
    return _CJK_ASCII_SPACE_RE.sub(" ", text)


# Unicode 上标 → 常规数字/字母的映射（思源字体子集不含上标字形，需显式用 <super> 渲染）
_UNICODE_SUPER = {
    "⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4",
    "⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9",
    "⁺": "+", "⁻": "-", "⁼": "=", "⁽": "(", "⁾": ")",
    "ⁱ": "i", "ⁿ": "n",
}
_UNICODE_SUB = {
    "₀": "0", "₁": "1", "₂": "2", "₃": "3", "₄": "4",
    "₅": "5", "₆": "6", "₇": "7", "₈": "8", "₉": "9",
    "₊": "+", "₋": "-", "₌": "=", "₍": "(", "₎": ")",
}
_SUPER_CHARS_RE = re.compile(f"([{''.join(_UNICODE_SUPER)}]+)")
_SUB_CHARS_RE = re.compile(f"([{''.join(_UNICODE_SUB)}]+)")

# 彩色 emoji / 特殊符号 → 文字替代。思源字体子集不含这些字形，直接放会渲染成方框。
# 替换为字体里**实际存在**的符号（经过 fontTools 验证）。
# 验证命令见 scripts/lib/verify_font_glyphs.py
_EMOJI_FALLBACK = {
    "✅": "✓",    # U+2705 → U+2713 CHECK MARK（思源有）
    "❌": "×",    # U+274C → U+00D7 MULTIPLICATION SIGN（思源有，✗ U+2717 思源没有）
    "✖": "×",
    "✗": "×",
    "🔶": "◆",    # U+1F536 → U+25C6 BLACK DIAMOND（思源有）
    "🔷": "◇",    # U+25C7 WHITE DIAMOND（思源有）
    "🟢": "●",    # U+25CF BLACK CIRCLE（思源有）
    "🔴": "●",
    "🟡": "○",    # U+25CB WHITE CIRCLE
    "🟠": "○",
    "⭐": "★",    # U+2605 BLACK STAR（思源有）
    "✔": "✓",
    "☑": "[✓]",  # U+2611 思源没有，用方括号包围替代
    "☒": "[×]",
    "☐": "[ ]",
    "➔": "→",
    "➜": "→",
    "⚠️": "※",   # U+203B REFERENCE MARK（思源有）
    "⚠": "※",
    "💡": "※",
    "📌": "•",
    "🔑": "※",
    "📊": "※",
    "📈": "※",
    "📉": "※",
}


def _replace_unicode_superscripts(text: str) -> str:
    """把连续的 Unicode 上标字符替换为 ReportLab <super> 标签。

    例：10⁶ → 10<super>6</super>
        H₂O → H<sub>2</sub>O
    思源字体子集不包含这些字形，直接放会渲染成方框。
    """
    def _sup(m: "re.Match") -> str:
        payload = "".join(_UNICODE_SUPER.get(c, c) for c in m.group(1))
        return f"<super>{payload}</super>"

    def _sub(m: "re.Match") -> str:
        payload = "".join(_UNICODE_SUB.get(c, c) for c in m.group(1))
        return f"<sub>{payload}</sub>"

    text = _SUPER_CHARS_RE.sub(_sup, text)
    text = _SUB_CHARS_RE.sub(_sub, text)
    return text


def _replace_emoji(text: str) -> str:
    """把字体里没有的 emoji 替换为字体里有的等价符号。"""
    for emoji, fallback in _EMOJI_FALLBACK.items():
        if emoji in text:
            text = text.replace(emoji, fallback)
    return text


def md_inline_to_rl(text: str, *, add_cjk_space: bool = True) -> str:
    """Markdown inline → ReportLab mini HTML."""
    # 先做 Unicode 上/下标归一（字体子集不含这些字形，否则渲染为方框）
    text = _replace_unicode_superscripts(text)
    # emoji 替换为字体里有的符号
    text = _replace_emoji(text)
    # 然后在中英交界处加空格
    if add_cjk_space:
        text = _add_cjk_spaces(text)
    text = re.sub(r"\*\*([^*]+)\*\*", r"<b>\1</b>", text)
    text = re.sub(r"(?<!\*)\*([^*]+)\*(?!\*)", r"<i>\1</i>", text)
    text = re.sub(r"`([^`]+)`", r'<font face="Courier">\1</font>', text)
    # 引用 ID 支持字母+数字（src_042 / src_A14 / src_B-18）
    text = re.sub(
        r"\[((?:src_[A-Za-z0-9_-]+)(?:\s*,\s*src_[A-Za-z0-9_-]+)*)\]",
        lambda m: '<super><font size=7>['
                  + m.group(1).replace(" ", "")
                  + ']</font></super>',
        text,
    )
    text = re.sub(
        r"<sup>(.*?)</sup>",
        r"<super><font size=7>\1</font></super>",
        text,
        flags=re.IGNORECASE,
    )
    text = re.sub(
        r"\[\^([A-Za-z0-9_-]+)\]",
        lambda m: f"<super><font size=7>注{m.group(1)}</font></super>",
        text,
    )
    text = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1", text)
    return text


# H1 正文章节标题解析：拆成（章号, 章名）
# 支持：
#   第一章 — 为何...           → ("第一章", "为何...")
#   第 9 章：四大监管向量...    → ("第 9 章", "四大监管向量...")
#   第6章 — 固定化...          → ("第6章", "固定化...")
#   第十章 — 制造体系...        → ("第十章", "制造体系...")
#   Chapter 1: Why the Second... → ("Chapter 1", "Why the Second...")
# 分隔符：—（em dash） / –（en dash） / - / : / ： / 空白多于一处
_CHAPTER_HEAD_RE = re.compile(
    r"^\s*"
    r"(?P<num>(?:第\s*[一二三四五六七八九十百零〇两廿卅\d]+\s*章)|(?:Chapter\s+\d+))"
    r"\s*[—–\-：:]\s*"
    r"(?P<title>.+?)\s*$",
    re.IGNORECASE,
)


def parse_chapter_title(raw: str) -> tuple[str, str] | None:
    """解析章节标题。命中返回 (章号, 章名)，否则 None。"""
    m = _CHAPTER_HEAD_RE.match(raw.strip())
    if not m:
        return None
    num = m.group("num").strip()
    title = m.group("title").strip()
    if not title:
        return None
    # 规范化章号空白："第 6 章" 保留"第 6 章"，"第6章"保留"第6章"
    num = re.sub(r"\s+", " ", num)
    return num, title


def build_chapter_header(raw_title: str, styles: StyleSheet1) -> list:
    """生成正文章节标题：两行居中 + 装饰横线。

    解析失败时 fallback 到普通 h1 样式。
    """
    parsed = parse_chapter_title(raw_title)
    if parsed is None:
        return [Paragraph(md_inline_to_rl(raw_title), styles["h1"])]

    num, title = parsed
    return [
        Paragraph(md_inline_to_rl(num), styles["h1-chapter-num"]),
        Paragraph(md_inline_to_rl(title), styles["h1-chapter-title"]),
        # 装饰横线：居中、宽度约 3cm（视觉重量跟两行标题平衡）
        HRFlowable(
            width=3 * cm,
            thickness=1.2,
            color=colors.HexColor("#1e3a8a"),
            spaceBefore=2,
            spaceAfter=18,
            hAlign="CENTER",
        ),
    ]


# ============================================================
# Document builders
# ============================================================

@dataclass
class Manifest:
    slug: str
    report_title: str
    report_subtitle: str
    author: str
    date: str
    version: str
    type: str
    confidentiality: str
    disclaimer: str

    @classmethod
    def load(cls, path: Path) -> "Manifest":
        data = json.loads(path.read_text(encoding="utf-8"))
        return cls(
            slug=data.get("slug", ""),
            report_title=data.get("report_title") or data.get("topic", "未命名研究报告"),
            report_subtitle=data.get("report_subtitle", ""),
            author=data.get("author", "Deep Research 系统"),
            date=data.get("date", ""),
            version=data.get("version", "1.0"),
            type=data.get("type", ""),
            confidentiality=data.get("confidentiality", ""),
            disclaimer=data.get("disclaimer", "本报告基于公开信息与 AI 辅助研究生成，仅供参考，不构成投资或医疗建议。"),
        )


def make_page_decorator(manifest: Manifest):
    """Returns function drawing header/footer for normal pages."""
    short_title = manifest.report_title[:30]

    def draw(canvas, doc):
        canvas.saveState()
        # Header: short title left, report type right, gray separator
        canvas.setFont("SrcSans-Light", 8)
        canvas.setFillColor(colors.HexColor("#9ca3af"))
        canvas.drawString(2 * cm, A4[1] - 1.2 * cm, short_title)
        if manifest.type:
            canvas.drawRightString(A4[0] - 2 * cm, A4[1] - 1.2 * cm, manifest.type)
        canvas.setStrokeColor(colors.HexColor("#e5e7eb"))
        canvas.setLineWidth(0.5)
        canvas.line(2 * cm, A4[1] - 1.4 * cm, A4[0] - 2 * cm, A4[1] - 1.4 * cm)

        # Page-bottom evidence footnotes.
        notes = getattr(canvas, "_dr_footnotes", {}).get(doc.page, [])
        if notes:
            width = A4[0] - 4.4 * cm
            x = 2.2 * cm
            y = 3.45 * cm
            canvas.setStrokeColor(colors.HexColor("#cbd5e1"))
            canvas.setLineWidth(0.45)
            canvas.line(x, y + 0.16 * cm, x + 6.8 * cm, y + 0.16 * cm)
            footnote_style = build_styles()["evidence-footnote"]
            for label, content in notes:
                text = f"注{label}：{content}"
                para = Paragraph(md_inline_to_rl(text), footnote_style)
                _, h = para.wrap(width, 1.3 * cm)
                y -= h
                para.drawOn(canvas, x, y)
                y -= 0.04 * cm

        # Footer: page number centered
        canvas.setFont("SrcSans-Light", 8)
        canvas.drawCentredString(A4[0] / 2, 1.0 * 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
        if b.kind == "h1" and not re.match(r"^第\s*\d+\s*章\b", title):
            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 换行
    - 长表自动按行分页
    - 宽表（>=7 列）强制按页宽等分列宽，避免 ReportLab 自动分配失败
    """
    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]
    ncols = len(header_cells)

    # 判断是否需要强制等宽列：
    # - 列数 >= 4 时（避免自动分配使某列被挤为负宽）
    # - 或任意单元格文本超过 30 字符（中英文混排时 auto-allocation 不稳定）
    _max_cell_len = 0
    for r in raw_rows[1:]:
        for c in r:
            if len(c) > _max_cell_len:
                _max_cell_len = len(c)

    is_wide = ncols >= 4 or _max_cell_len > 30
    is_very_wide = ncols >= 7 or _max_cell_len > 80

    pad_lr = 2 if is_very_wide else (3 if is_wide else 6)
    pad_tb = 3 if is_wide else 5

    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) < ncols:
            cells.append("")
        rows.append([_render_table_cell(c, styles) for c in cells])

    # 可用页宽（A4 - margins），给 Table 分配等宽列
    # 参考 doctemplate 页面宽度：A4.width (595) - left (54) - right (54) ≈ 487 pt
    # 为安全起见，给表格留一点边距
    col_widths = None
    if is_wide:
        from reportlab.lib.pagesizes import A4
        avail_width = A4[0] - 110  # A4 宽度 - 两侧边距
        col_widths = [avail_width / ncols] * ncols

    table = Table(rows, colWidths=col_widths, 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), pad_lr),
        ("RIGHTPADDING", (0, 0), (-1, -1), pad_lr),
        ("TOPPADDING", (0, 0), (-1, -1), pad_tb),
        ("BOTTOMPADDING", (0, 0), (-1, -1), pad_tb),
    ]))
    return table


def render_table_blocks(md_table: str, styles: StyleSheet1, max_rows_per_chunk: int = 25) -> list:
    """Render a markdown table as potentially multiple Table objects.

    When the body has more than ``max_rows_per_chunk`` rows we slice it into
    smaller chunks (each re-printing the header). This avoids ReportLab's
    split-by-row bug on very long tables which manifests as
    ``TypeError: '>' not supported between instances of 'NoneType' and 'NoneType'``.
    """
    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 = raw_rows[0]
    body = raw_rows[1:]
    if len(body) <= max_rows_per_chunk:
        return [render_table(md_table, styles)]

    # Split into chunks
    out = []
    from reportlab.platypus import Spacer
    for start in range(0, len(body), max_rows_per_chunk):
        chunk = body[start:start + max_rows_per_chunk]
        lines_md = [
            "| " + " | ".join(header) + " |",
            "|" + "|".join(["---"] * len(header)) + "|",
        ]
        for row in chunk:
            lines_md.append("| " + " | ".join(row) + " |")
        out.append(render_table("\n".join(lines_md), styles))
        out.append(Spacer(1, 4))
    return out


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 == "footnote":
        label = block.meta.get("label") if block.meta else ""
        story.append(FootnoteFlowable(str(label), block.content))
    elif block.kind == "bullet":
        story.append(Paragraph("• " + md_inline_to_rl(block.content), styles["bullet"]))
    elif block.kind == "ordered":
        story.append(Paragraph(md_inline_to_rl(block.content), styles["ordered"]))
    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:
            for _tbl_block in render_table_blocks(block.content, styles):
                story.append(_tbl_block)
        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

    # If the markdown does not contain a TOC marker, still insert a generated TOC.
    # This keeps PDF output stable when Phase 4 emits a clean markdown body.
    if "toc" not in front_sections:
        front_sections["toc"] = [Block(kind="h2", content="目录")]

    # 前置件输出顺序（固定）
    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

        # 参考文献：如果正文仍使用 [src_xxx]，则从 sources.jsonl 自动生成；
        # 如果正文已被 number_citations.py 转为数字编号，则保留 Markdown 内的编号清单。
        if kind == "references":
            story.append(PageBreak())
            j = i + 1
            has_src_citations = bool(collect_cited_src_ids(blocks))
            has_ref_placeholder = (
                j < n and blocks[j].kind == "p" and _REF_PLACEHOLDER_RE.search(blocks[j].content)
            )
            if has_src_citations or has_ref_placeholder:
                story.extend(build_references(blocks, sources_path, styles))
                while j < n and blocks[j].kind not in ("h1", "h2"):
                    j += 1
            else:
                story.append(Paragraph(md_inline_to_rl(block.content), styles["h1"]))
                while j < n and blocks[j].kind not in ("h1", "h2"):
                    _render_generic_block(blocks[j], story, base_dir, styles, in_summary=False)
                    j += 1
                i = j
                continue
            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=4.0 * 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, 4.0 * cm,
        A4[0] - 4.4 * cm, A4[1] - 6.0 * cm,
        id="normal",
    )
    decorator = make_page_decorator(manifest)
    doc.addPageTemplates([
        PageTemplate(id="cover", frames=[cover_frame]),
        PageTemplate(id="normal", frames=[normal_frame], onPageEnd=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()
