Files
deep_research/scripts/build_report.py
T
Deep Research System d1169646b8 v0.13: add Quarto/xelatex PDF engine and fix ReportLab wide-table rendering
build_report.py:
- add --engine quarto option: Quarto 1.9 + xelatex pipeline with
  CJK font setup (Source Han Serif/Sans CN via fontspec),
  automatic {.landscape} wrapping for wide tables (>=8 cols),
  TOC/references placeholder replacement, sources.jsonl backfill
- prepare_qmd(): converts Markdown to .qmd with proper YAML front matter,
  writes _preamble.tex for longtable/pdflscape/lscape packages
- _detect_wide_tables(), _build_references_block(): helper functions
- ReportLab path unchanged (remains default)

report-template.py:
- render_table(): force equal-width column distribution for tables
  with >=4 cols or any cell >30 chars, preventing negative availWidth
  crash on mixed CJK/English content
- render_table_blocks(): split long tables (>25 rows) into chunks to
  avoid NoneType comparison crash in ReportLab splitByRow logic

.gitignore:
- add rules for LaTeX temp files (*.aux, xetest.*, *.qmd, _preamble.tex)
- add projects/ to gitignore (research data, not source code)

README.md:
- update status to v0.13
- rewrite PDF section as dual-engine guide with install steps,
  comparison table, and landscape table chunking guidance
- add Quarto troubleshooting (font italic mapping, tlmgr path, param_size)
- add v0.13 to changelog
2026-05-05 11:50:32 +08:00

496 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Phase 4 成稿阶段:统一入口。
从 final_zh_polished.md(或指定的 Markdown+ manifest.json 生成:
- <title>.pdf PDFReportLab 或 Quarto/xelatex
- <title>.docx Pandoc 出 DOCX
- <title>-en.pdf 如果存在 final_en.md 也一并出英文版(可选)
文件名来自 manifest.report_title(去掉非法字符),不再用 "final.pdf" 这种通用名。
用法:
uv run python scripts/build_report.py <project_slug>
# 使用 Quarto/xelatex 引擎(推荐,更好的中文+宽表支持):
uv run python scripts/build_report.py <project_slug> --engine quarto
# 只生成 PDF
uv run python scripts/build_report.py <project_slug> --no-docx
# 从自定义 md 生成:
uv run python scripts/build_report.py <project_slug> --input phase4/final_zh.md
环境依赖:
- reportlab, pypandoc, 思源字体(bash .opencode/templates/fonts/download-fonts.sh
- pandoc 可执行文件在 PATH
- Quarto(可选,--engine quarto 时需要):https://quarto.org/docs/get-started/
安装后运行:quarto install tinytex
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from scripts.lib.zenmux_client import load_secrets # noqa: F401 (为一致性)
REPO_ROOT = Path(__file__).resolve().parent.parent
PDF_TEMPLATE = REPO_ROOT / ".opencode" / "templates" / "report-template.py"
DEFAULT_FONTS_DIR = REPO_ROOT / ".opencode" / "templates" / "fonts"
def resolve_project(arg: str) -> Path:
p = Path(arg)
if p.is_dir():
return p.resolve()
cand = REPO_ROOT / "projects" / arg
if cand.is_dir():
return cand.resolve()
raise SystemExit(f"project not found: {arg}")
# 文件系统对文件名的常见限制:Windows 更严格,按最小公倍数来
_FS_ILLEGAL_RE = re.compile(r'[\\/:*?"<>|\r\n\t]+')
_WHITESPACE_RE = re.compile(r"\s+")
def sanitize_filename(name: str, max_len: int = 120) -> str:
"""把报告标题变成可跨 OS 使用的文件名。"""
if not name:
return "report"
# 去掉非法字符
cleaned = _FS_ILLEGAL_RE.sub(" ", name)
# 合并空白
cleaned = _WHITESPACE_RE.sub(" ", cleaned).strip()
# 首尾 . 空格 . Windows 要求)
cleaned = cleaned.strip(". ").strip()
if len(cleaned) > max_len:
cleaned = cleaned[:max_len].rstrip()
return cleaned or "report"
def load_manifest(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def determine_input_md(project_root: Path, preferred: str | None) -> Path:
"""决定用哪个 Markdown 出稿。
优先级:--input 指定 > final_zh_polished.md > final_zh.md > final_en.md
"""
if preferred:
p = project_root / preferred
if not p.exists():
raise SystemExit(f"指定的 --input 不存在:{p}")
return p
for candidate in (
"phase4/final_zh_polished.md",
"phase4/final_zh.md",
"phase4/final_en.md",
):
p = project_root / candidate
if p.exists():
return p
raise SystemExit(
f"找不到任何 Markdown 源。项目根:{project_root}\n"
"先跑 translate.py / polish.py 或使用 --input 指定路径。"
)
def build_pdf(
md_path: Path,
manifest_path: Path,
output_pdf: Path,
fonts_dir: Path,
sources_path: Path | None,
) -> None:
"""调用 report-template.py 生成 PDF。"""
cmd = [
sys.executable,
str(PDF_TEMPLATE),
"--input", str(md_path),
"--manifest", str(manifest_path),
"--output", str(output_pdf),
"--fonts-dir", str(fonts_dir),
]
if sources_path and sources_path.exists():
cmd += ["--sources", str(sources_path)]
print(f"\n→ 生成 PDF{output_pdf.name}")
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
raise SystemExit(f"PDF 生成失败,返回码 {result.returncode}")
def _detect_wide_tables(md_text: str, min_cols: int = 8) -> list[tuple[int, int]]:
"""返回所有列数 >= min_cols 的 Markdown 表格的 (start_line, end_line) 区间(0-based)。"""
lines = md_text.split("\n")
ranges = []
i = 0
while i < len(lines):
line = lines[i]
if line.startswith("|") and line.count("|") - 1 >= min_cols:
# Possible table header — next line should be separator
if i + 1 < len(lines) and re.match(r"^\|[\s\-:|]+\|", lines[i + 1]):
start = i
j = i + 2
while j < len(lines) and lines[j].strip().startswith("|"):
j += 1
ranges.append((start, j))
i = j
continue
i += 1
return ranges
def prepare_qmd(
md_path: Path,
manifest: dict,
output_qmd: Path,
fonts_dir: Path,
sources_path: Path | None,
wide_table_cols: int = 8,
) -> None:
"""将普通 Markdown 转换为带 Quarto front matter 的 .qmd 文件。
主要处理:
1. 插入 YAML front matter(标题、字体、页面设置等)
2. 用 {.landscape} div 包裹列数 >= wide_table_cols 的宽表
3. 将 [TOC will be generated...] 占位符替换为真实 TOC 指令
4. 将 [REFERENCES will be filled...] 占位符替换为参考文献内容
"""
title = manifest.get("report_title", "报告")
subtitle = manifest.get("report_subtitle", "")
date = manifest.get("date", "")
# 决定字体名称:思源宋体 CN 作正文,思源黑体 CN 作标题
main_font = "Source Han Serif CN"
sans_font = "Source Han Sans CN"
# Write LaTeX header file for CJK font setup.
# Using a separate .tex file avoids YAML escape issues with backslashes.
mf = main_font # "Source Han Serif CN"
sf = sans_font # "Source Han Sans CN"
front_matter = textwrap.dedent(f"""\
---
title: "{title}"
subtitle: "{subtitle}"
date: "{date}"
lang: zh
format:
pdf:
pdf-engine: xelatex
CJKmainfont: "{mf}"
mainfont: "{mf}"
mainfontoptions:
- BoldFont={mf}
- ItalicFont={mf}
- BoldItalicFont={mf}
CJKoptions:
- BoldFont={mf}
- ItalicFont={mf}
- BoldItalicFont={mf}
sansfont: "{sf}"
sansfontoptions:
- BoldFont={sf}
- ItalicFont={sf}
- BoldItalicFont={sf}
monofont: "Liberation Mono"
papersize: a4
documentclass: scrartcl
classoption:
- DIV=11
- headinclude
toc: true
toc-depth: 2
toc-title: "目录"
number-sections: false
colorlinks: true
linkcolor: NavyBlue
urlcolor: NavyBlue
geometry:
- top=25mm
- bottom=25mm
- left=25mm
- right=20mm
pdf-engine-opts:
- "-stack-size=32768"
- "-extra-mem-top=2000000"
include-in-header:
- file: _preamble.tex
---
""")
md_text = md_path.read_text(encoding="utf-8")
# Remove existing YAML front matter if any (between first two ---)
if md_text.startswith("---"):
end = md_text.find("\n---", 3)
if end != -1:
md_text = md_text[end + 4:].lstrip("\n")
# Replace TOC placeholder
md_text = re.sub(
r"\[TOC will be generated.*?\]",
"", # Quarto handles TOC via front matter
md_text,
)
# Replace REFERENCES placeholder with actual references from sources.jsonl
ref_block = _build_references_block(sources_path, md_text)
md_text = re.sub(
r"\[REFERENCES will be filled.*?\]",
ref_block,
md_text,
)
# Wrap wide tables in {.landscape} divs
lines = md_text.split("\n")
wide_ranges = _detect_wide_tables(md_text, min_cols=wide_table_cols)
if wide_ranges:
# Insert landscape wrappers from bottom up (so line numbers stay valid)
for start, end in reversed(wide_ranges):
lines.insert(end, "\n:::")
lines.insert(start, "::: {.landscape}\n")
md_text = "\n".join(lines)
# Write LaTeX preamble file (table + landscape support)
preamble_tex = output_qmd.parent / "_preamble.tex"
preamble_tex.write_text(
"\\usepackage{longtable}\n"
"\\usepackage{booktabs}\n"
"\\usepackage{array}\n"
# Use lscape instead of pdflscape to avoid \LS@makefcolumn recursion
# which exhausts TeX param_size on large longtables.
# lscape rotates content without changing page media box (reader must rotate).
"\\usepackage{lscape}\n"
"\\setlength{\\LTpre}{6pt}\n"
"\\setlength{\\LTpost}{6pt}\n"
"\\setlength{\\tabcolsep}{3pt}\n",
encoding="utf-8",
)
output_qmd.write_text(front_matter + md_text, encoding="utf-8")
print(f" .qmd prepared: {output_qmd.name} ({len(wide_ranges)} landscape table(s))")
def _build_references_block(sources_path: Path | None, md_text: str) -> str:
"""从 sources.jsonl 生成参考文献列表,只包含在正文中实际引用的信源。"""
if not sources_path or not sources_path.exists():
return "(参考文献列表:sources.jsonl 未找到)"
# Find cited src_ids
cited = set(re.findall(r"\[src_([a-z0-9_]+)\]", md_text))
if not cited:
return ""
sources: dict[str, dict] = {}
with open(sources_path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
sid = obj.get("id", "")
key = sid.replace("src_", "")
if key in cited:
sources[sid] = obj
except json.JSONDecodeError:
pass
if not sources:
return ""
lines = ["## 参考文献\n"]
for sid in sorted(sources.keys()):
s = sources[sid]
authors = ", ".join(s.get("authors", [])) if s.get("authors") else ""
year = s.get("year", "")
title = s.get("title", sid)
venue = s.get("venue", "")
url = s.get("url", "")
entry = f"- **[{sid}]** "
if authors:
entry += f"{authors}. "
if year:
entry += f"({year}). "
entry += f"*{title}*"
if venue:
entry += f". {venue}"
if url:
entry += f". <{url}>"
lines.append(entry)
return "\n".join(lines)
def build_pdf_quarto(
md_path: Path,
manifest: dict,
output_pdf: Path,
fonts_dir: Path,
sources_path: Path | None,
) -> None:
"""使用 Quarto + xelatex 生成 PDF。"""
if not shutil.which("quarto"):
raise SystemExit(
"quarto 命令未找到。请先安装 Quartohttps://quarto.org/docs/get-started/\n"
"安装后运行:quarto install tinytex"
)
# Prepare .qmd in the same dir as output_pdf
qmd_path = output_pdf.parent / (output_pdf.stem + ".qmd")
prepare_qmd(md_path, manifest, qmd_path, fonts_dir, sources_path)
print(f"\n→ 生成 PDFQuarto/xelatex):{output_pdf.name}")
cmd = [
"quarto", "render", str(qmd_path),
"--to", "pdf",
"--output", output_pdf.name,
]
result = subprocess.run(cmd, cwd=str(output_pdf.parent), check=False)
if result.returncode != 0:
raise SystemExit(f"Quarto PDF 生成失败,返回码 {result.returncode}")
# Clean up auxiliary files Quarto leaves behind
for ext in (".tex", ".log", ".aux", ".toc", ".out", "-files"):
candidate = output_pdf.parent / (output_pdf.stem + ext)
if candidate.exists():
candidate.unlink(missing_ok=True)
def build_docx(md_path: Path, output_docx: Path, title: str) -> None:
"""用 pandoc 生成 DOCX。"""
if not shutil.which("pandoc"):
print(f" ⚠ pandoc 不在 PATH,跳过 DOCX 生成", file=sys.stderr)
return
print(f"\n→ 生成 DOCX{output_docx.name}")
# 关闭 tex_math_dollars/tex_math_single_backslash 防止文中 "$100" "$10^6" 被当数学公式
cmd = [
"pandoc",
str(md_path),
"-o", str(output_docx),
"--from=markdown-tex_math_dollars-tex_math_single_backslash-raw_tex",
"--to=docx",
"--standalone",
"-M", f"title={title}",
"--wrap=preserve",
]
# reference-doc 如果存在就用
ref_doc = REPO_ROOT / ".opencode" / "templates" / "reference.docx"
if ref_doc.exists():
cmd += ["--reference-doc", str(ref_doc)]
result = subprocess.run(cmd, check=False)
if result.returncode != 0:
print(f" ⚠ DOCX 生成失败(返回码 {result.returncode}),但不阻断流程", file=sys.stderr)
def main() -> int:
parser = argparse.ArgumentParser(description="Phase 4 成稿(PDF + DOCX")
parser.add_argument("project", help="项目 slug 或完整路径")
parser.add_argument(
"--input",
default=None,
help="Markdown 源(默认自动寻找 phase4/final_zh_polished.md",
)
parser.add_argument(
"--output-dir",
default="phase4",
help="输出目录(相对项目根,默认 phase4)",
)
parser.add_argument(
"--fonts-dir",
default=str(DEFAULT_FONTS_DIR),
help="字体目录",
)
parser.add_argument(
"--sources",
default=None,
help="sources.jsonl 路径(默认 phase2/sources.jsonl",
)
parser.add_argument("--no-docx", action="store_true", help="跳过 DOCX 生成")
parser.add_argument("--no-pdf", action="store_true", help="跳过 PDF 生成")
parser.add_argument(
"--engine",
choices=["reportlab", "quarto"],
default="reportlab",
help="PDF 渲染引擎:reportlab(默认,Python 原生)或 quartoxelatex,更好的中文+宽表支持)",
)
parser.add_argument(
"--basename",
default=None,
help="文件名 stem(不带扩展名),默认从 manifest.report_title 生成",
)
args = parser.parse_args()
project_root = resolve_project(args.project)
manifest_path = project_root / "manifest.json"
if not manifest_path.exists():
raise SystemExit(f"manifest.json not found: {manifest_path}")
manifest = load_manifest(manifest_path)
md_path = determine_input_md(project_root, args.input)
title = manifest.get("report_title") or manifest.get("topic") or "Deep Research Report"
basename = args.basename or sanitize_filename(title)
output_dir = project_root / args.output_dir
output_dir.mkdir(parents=True, exist_ok=True)
pdf_path = output_dir / f"{basename}.pdf"
docx_path = output_dir / f"{basename}.docx"
fonts_dir = Path(args.fonts_dir)
if not fonts_dir.is_absolute():
fonts_dir = REPO_ROOT / fonts_dir
sources_path: Path | None = None
if args.sources:
sources_path = Path(args.sources)
else:
default_src = project_root / "phase2" / "sources.jsonl"
if default_src.exists():
sources_path = default_src
print("========== Phase 4 成稿 ==========")
print(f"项目: {project_root.name}")
print(f"Markdown {md_path.relative_to(project_root)}")
print(f"标题: {title}")
print(f"输出名: {basename}")
print(f"字体目录: {fonts_dir}")
print(f"Sources {sources_path if sources_path else '(缺失)'}")
if not args.no_pdf:
if args.engine == "quarto":
build_pdf_quarto(md_path, manifest, pdf_path, fonts_dir, sources_path)
else:
build_pdf(md_path, manifest_path, pdf_path, fonts_dir, sources_path)
if not args.no_docx:
build_docx(md_path, docx_path, title)
print("\n========== 完成 ==========")
if pdf_path.exists():
print(f" PDF: {pdf_path.relative_to(project_root)} ({pdf_path.stat().st_size // 1024} KB)")
if docx_path.exists():
print(f" DOCX: {docx_path.relative_to(project_root)} ({docx_path.stat().st_size // 1024} KB)")
return 0
if __name__ == "__main__":
sys.exit(main())