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
This commit is contained in:
Deep Research System
2026-05-05 11:50:32 +08:00
parent ddaa6730bc
commit d1169646b8
7 changed files with 464 additions and 105 deletions
+16
View File
@@ -51,3 +51,19 @@ Thumbs.db
# ============ 归档(不纳入版本控制)============
archive/*
!archive/.gitkeep
# ============ 临时 LaTeX / TeX 测试文件 ============
xetest.*
*.aux
*.fls
*.fdb_latexmk
*.synctex.gz
# ============ Quarto 生成的中间文件 ============
*_files/
*.qmd
_preamble.tex
# ============ 研究项目(实际数据,不纳入版本控制)============
# 如需备份,请用独立的私有仓库
projects/
+1 -1
View File
@@ -2,7 +2,7 @@
description: Cross-model verification agent (English). Uses non-Claude model (GPT-5.4) to do counter-evidence searching and fact-check on completed chapters, avoiding same-source bias. Scheduled by dr-pm after dr-analyst finishes each chapter.
mode: subagent
hidden: true
model: zenmux/openai/gpt-5.4-mini
model: zenmux/openai/gpt-5.4
temperature: 0.2
tools:
read: true
+72 -8
View File
@@ -1085,6 +1085,7 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
- 所有 cell 垂直居中
- 长文字自动 CJK 换行
- 长表自动按行分页
- 宽表(>=7 列)强制按页宽等分列宽,避免 ReportLab 自动分配失败
"""
rows: list[list] = []
raw_rows = []
@@ -1097,28 +1098,90 @@ def render_table(md_table: str, styles: StyleSheet1) -> Table:
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) < len(header_cells):
while len(cells) < ncols:
cells.append("")
rows.append([_render_table_cell(c, styles) for c in cells])
table = Table(rows, repeatRows=1, splitByRow=True)
# 可用页宽(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), 6),
("RIGHTPADDING", (0, 0), (-1, -1), 6),
("TOPPADDING", (0, 0), (-1, -1), 5),
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
("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 的 blockp/quote/bullet/hr/image/table/h3)。
@@ -1157,7 +1220,8 @@ def _render_generic_block(block: Block, story: list, base_dir: Path, styles: Sty
))
elif block.kind == "table":
try:
story.append(render_table(block.content, styles))
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"]))
+114 -92
View File
@@ -2,7 +2,7 @@
> 生物医药行业的 AI 驱动深度研究流水线。基于 OpenCode 多 agent 协作,以麦肯锡/德勤式方法论产出专业级研究报告(PDF + DOCX)。
**当前状态**v0.12 迭代完成。OpenCode 全流程可用(Phase 1-4),搜索网关、模型预设与 Phase 4 统一 pipeline 已落地;Codex native adapter 与 OpenCode 保持并列入口
**当前状态**v0.13 迭代完成。新增 Quarto/xelatex PDF 引擎(`--engine quarto`),解决 ReportLab 超宽表格渲染 bugReportLab 引擎保留为默认后备。Quarto 依赖独立安装,不影响现有环境
详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。
---
@@ -87,94 +87,8 @@ opencode # 启动 TUI
**跑单个 Python 脚本**(不用先激活):
```bash
uv run python .opencode/templates/report-template.py --input ... --output ...
```
**加新依赖**
```bash
uv add <package> # 自动更新 pyproject.toml 和 uv.lock
```
**同步到最新锁定版本**(新 clone 或切分支后):
```bash
uv sync
```
如果你用 [direnv](https://direnv.net/),可在项目根目录建 `.envrc`
```bash
source scripts/activate.sh
```
这样 `cd` 进项目目录会自动激活,`cd` 出去会自动卸载。
---
## 可用命令
| 命令 | 功能 | 状态 |
|---|---|---|
| `/dr-init <主题>` | 初始化新研究,启动访谈 | ✅ 可用 |
| `/dr-frame [slug]` | Phase 1:生成 8-15 章双语研究框架 | ✅ 可用 |
| `/dr-research [slug]` | Phase 2:并行深度研究 | ✅ 可用 |
| `/dr-review [slug]` | Phase 3:总编审校 | ✅ 可用 |
| `/dr-finalize [slug]` | Phase 4:英文合稿 → 中文翻译/术语核查/润色 → PDF+DOCX | ✅ 可用 |
| `/dr-models [profile]` | 解析模型预设(simple / medium / premium 等) | ✅ 可用 |
| `/dr-apply-models <profile>` | 把模型预设写入 OpenCode/Codex agent 文件 | ✅ 可用 |
| `/dr-glossary [slug]` | 术语表事实核查 | ✅ 可用 |
| `/dr-status [slug]` | 查看进度 | ✅ 可用 |
### 典型流程
```
1. /dr-init GLP-1 减重药物市场
→ dr-plan 向你提 8 个访谈问题(研究类型、受众、时间范围等)
→ 你回答后,生成 projects/glp1-obesity-market-2026/manifest.json
2. /dr-frame
→ dr-plan 调用 skill:search-strategy
→ 委派 3-4 个 dr-searcherHaiku,轻量)并行初扫
→ 生成 8-15 章框架到 phase1/framework.md
→ 暂停等你确认
3. 你审核框架,或提修改意见,或直接确认
→ 确认后,manifest.phase1.approved = true
4. /dr-research
→ dr-pm 分批并行调度 dr-analyst 深研
→ dr-verifier 做反方验证
→ 产出 phase2/drafts、evidence、sources.jsonl
5. /dr-review
→ dr-chief-editor 通读审校,产出 phase3/critique.md
6. /dr-finalize
→ dr-editor-in-chief 合并英文终稿
→ Python 脚本执行 translate → glossary → apply_glossary → polish → build_report
→ 产出 final_zh_polished.md、PDF、DOCX
```
### Phase 4 Python 流水线
Phase 4 已切到统一 pipeline(替代式):由 Python 控制切块、并发、重试与断点续传:
```bash
uv run python scripts/phase4_pipeline.py <slug>
# 等价入口(支持模型预设)
uv run python scripts/dr.py finalize <slug> --model-profile medium
```
默认行为:
- 自动估算 translate/polish 并发(`--translate-workers 0` / `--polish-workers 0`
- glossary 仅核查低置信度术语(`--glossary-mode low-confidence`
你也可以手动分步执行:
```bash
uv run python scripts/translate.py <slug> --workers 4
uv run python scripts/build_glossary.py <slug> --workers 4
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md --dry-run
uv run python scripts/apply_glossary.py <slug> --input phase4/final_zh.md
uv run python scripts/polish.py <slug> --workers 4
uv run python scripts/build_report.py <slug>
uv run python scripts/build_report.py <slug> # 默认 ReportLab
uv run python scripts/build_report.py <slug> --engine quarto # Quarto/xelatex
```
网络不稳或 API 限流时,把 `--workers` 降到 `3``1` 即可断点续跑。
@@ -318,12 +232,71 @@ OpenCode 的常见陷阱:AI 在主会话里装样子地"委派"子 agent,实
Phase 1 分配章节配额,Phase 2 自检,不足返工。见 `skills/length-budget/SKILL.md`
### 4. 中文 PDF 无坑
### 4. 中文 PDF 双引擎
`build_report.py` 现在支持两套 PDF 引擎,按需选择:
#### 引擎 AReportLab(默认,无额外依赖)
```bash
uv run python scripts/build_report.py <slug>
```
- 字体:思源宋 + 思源黑 + 霞鹜文楷(全 SIL OFL,可商用嵌入)
- 样式:集中在 `build_styles()`,所有字号行距单点维护
- 引擎:ReportLab(纯 Python30,000 字 3-5 秒出稿
- 图表:matplotlib 预渲染 300 DPI PNG 嵌入
- 速度:30,000 字 3-5 秒出稿
- 局限:超宽表格(≥4 列且含长文本)需借助列宽 patch 或改为 bullet list 格式
#### 引擎 BQuarto / xelatex`--engine quarto`,推荐用于宽表报告)
```bash
uv run python scripts/build_report.py <slug> --engine quarto
```
- 排版引擎:xelatexTeX Live / TinyTeX),LaTeX 级排版质量
- 字体:同样使用思源宋 + 思源黑,通过 fontspec 加载
- 宽表支持:超宽表通过 `longtable` + `tbl-colwidths` 精确指定列宽比例,不溢出
- 横向页面:通过 `{.landscape}` div 包裹超宽表,自动插入 `pdflscape` 代码(注意:101 行以上的 landscape longtable 可能触发 TeX `param_size` 上限,建议拆成 ≤20 行的子表块)
- 图表:暂不嵌入 matplotlib 图表(使用文字描述代替)
**安装 Quarto 引擎**(一次性,系统级):
```bash
# 1. 安装 Quarto CLI
# 下载页:https://github.com/quarto-dev/quarto-cli/releases/latest
# Linux 选 .deb 安装包,macOS 选 .pkg
# 2. 安装 TinyTeXQuarto 内置命令)
quarto install tinytex
# 3. 安装中文 LaTeX 支持包
~/.TinyTeX/bin/x86_64-linux/tlmgr install ctex xecjk cjk xetex
# macOS 路径通常为:~/.TinyTeX/bin/universal-darwin/tlmgr
# 4. 注册思源字体到 fontconfig
# (先确认字体已下载:bash .opencode/templates/fonts/download-fonts.sh
mkdir -p ~/.fonts
cp .opencode/templates/fonts/*.otf ~/.fonts/
cp .opencode/templates/fonts/*.ttf ~/.fonts/
cp .opencode/templates/fonts/ttf/*.ttf ~/.fonts/
fc-cache -fv ~/.fonts
# 5. 验证
quarto --version # 应输出 1.x.x
fc-list | grep "Source Han" # 应看到思源字体条目
```
**两引擎对比**
| 指标 | ReportLab | Quarto/xelatex |
|------|-----------|----------------|
| 安装复杂度 | 无额外依赖 | 需安装 Quarto + TinyTeX |
| 渲染速度 | 3-5 秒 | 30-90 秒(LaTeX 编译) |
| 宽表格处理 | 需 workaround | longtable 原生支持 |
| 横向页面 | 不支持 | 支持(≤20 行/块) |
| 字体嵌入 | OTF 直接嵌入 | fontspec 系统字体 |
| 输出体积 | ~1.2 MB/100页 | ~0.9 MB/100页 |
| 目录生成 | 自定义实现 | LaTeX 自动 \tableofcontents |
### 5. zenmux 双 providerClaude cache 关键)
@@ -394,6 +367,51 @@ ls .opencode/templates/fonts/*.otf | wc -l # 应为 6+
python .opencode/templates/report-template.py --help
```
### Quarto PDF 生成失败
**字体找不到(`Could not resolve font "Source Han Serif CN/I"`**
CJK 字体没有斜体变体,fontspec 默认会找 `/I` 导致报错。`build_report.py --engine quarto` 已通过 `mainfontoptions: [ItalicFont=...]` 自动绕开,无需手动处理。若自行编写 `.qmd`,需在 YAML 里加:
```yaml
format:
pdf:
pdf-engine: xelatex
CJKmainfont: "Source Han Serif CN"
mainfontoptions:
- BoldFont=Source Han Serif CN
- ItalicFont=Source Han Serif CN
- BoldItalicFont=Source Han Serif CN
```
**`tlmgr` 找不到**
TinyTeX 不在系统 PATH,用完整路径:
```bash
~/.TinyTeX/bin/x86_64-linux/tlmgr install <package> # Linux
~/.TinyTeX/bin/universal-darwin/tlmgr install <package> # macOS
```
**`TeX capacity exceeded [parameter stack size]`**landscape 大表):
pdflscape 的 `\LS@makefcolumn` 在 101 行以上的 longtable 里递归过深,耗尽 TeX 的 `param_size`。解决方法:把超大表拆成每块 ≤20 行的子表,每块都包在 `{.landscape}` div 里:
```markdown
::: {.landscape}
| 列1 | 列2 | ... |
|---|---|---|
| 第1-20行 | ... |
:::
::: {.landscape}
| 列1 | 列2 | ... |
|---|---|---|
| 第21-40行 | ... |
:::
```
若使用 `build_report.py --engine quarto`,可通过传入预处理好的 `.md`(宽表已拆块)来避免此问题。
### uv 安装后找不到
uv 官方脚本把 uv 装到 `~/.local/bin/`。若终端里 `which uv` 找不到:
```bash
@@ -442,6 +460,9 @@ direnv allow
- Skill 配置:https://opencode.ai/docs/skills
- MCP Servershttps://opencode.ai/docs/mcp-servers
- ReportLab 文档:https://docs.reportlab.com
- Quarto 文档:https://quarto.org/docs/output-formats/pdf-basics.html
- Quarto PDF 引擎:https://quarto.org/docs/output-formats/pdf-engine.html
- Quarto 表格文档:https://quarto.org/docs/authoring/tables.html
- 思源字体:https://github.com/adobe-fonts
- 霞鹜文楷:https://github.com/lxgw/LxgwWenKai
@@ -452,5 +473,6 @@ direnv allow
- **v0.1** (2026-04-20) — MVP 路径 2 完成:dr-plan + dr-pm 两主 agent、4 个核心 skill、2 个命令、ReportLab 模板基础版、字体下载脚本
- **v0.2** (2026-04-20) — 双 provider 架构(zenmux-anthropic + zenmux),解决 Claude prompt cache 生效问题
- **v0.3** (2026-04-20) — 修正 v0.2 模型名(回到 Opus 4.7 / Sonnet 4.6 / Gemini 3.1 Pro / GPT-5.4 Pro 等真实 slug);改 venv + requirements.txt 跨平台方案(macOS + Debian);新增 `scripts/setup.sh``scripts/activate.sh`
- **v0.13** (2026-05-02) — `build_report.py` 新增 `--engine quarto` 选项:Quarto 1.9 + xelatex 引擎,解决 ReportLab 超宽表格渲染 bug`negative availWidth`/`NoneType` 问题);`report-template.py` 同步修复(`render_table_blocks` 分块 + 等宽列强制分配);README 补充双引擎安装指南与排错
`PLAN.md` §12 了解完整变更历史。
@@ -1,6 +1,6 @@
name = "dr-verifier"
description = "Independent counter-evidence and fact-checking agent for completed chapters."
model = "zenmux/openai/gpt-5.4-mini"
model = "zenmux/openai/gpt-5.4"
model_reasoning_effort = "high"
sandbox_mode = "workspace-write"
developer_instructions = """
+259 -2
View File
@@ -2,7 +2,7 @@
"""Phase 4 成稿阶段:统一入口。
从 final_zh_polished.md(或指定的 Markdown+ manifest.json 生成:
- <title>.pdf ReportLab 出中文 PDF
- <title>.pdf PDFReportLab 或 Quarto/xelatex
- <title>.docx Pandoc 出 DOCX
- <title>-en.pdf 如果存在 final_en.md 也一并出英文版(可选)
@@ -11,6 +11,9 @@
用法:
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
@@ -20,6 +23,8 @@
环境依赖:
- 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
@@ -30,6 +35,7 @@ import re
import shutil
import subprocess
import sys
import textwrap
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
@@ -125,6 +131,248 @@ def build_pdf(
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"):
@@ -178,6 +426,12 @@ def main() -> int:
)
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,
@@ -222,7 +476,10 @@ def main() -> int:
print(f"Sources {sources_path if sources_path else '(缺失)'}")
if not args.no_pdf:
build_pdf(md_path, manifest_path, pdf_path, fonts_dir, sources_path)
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)
Generated
+1 -1
View File
@@ -361,7 +361,7 @@ wheels = [
[[package]]
name = "deep-research"
version = "0.3.0"
version = "0.12.0"
source = { virtual = "." }
dependencies = [
{ name = "biopython" },