From d1169646b86bec38b8b85c307a3af254e9d3aa6e Mon Sep 17 00:00:00 2001 From: Deep Research System Date: Tue, 5 May 2026 11:50:32 +0800 Subject: [PATCH] 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 --- .gitignore | 16 ++ .opencode/agents/dr-verifier.md | 2 +- .opencode/templates/report-template.py | 80 +++++- README.md | 206 ++++++++------ .../codex/agents/dr-verifier.toml | 2 +- scripts/build_report.py | 261 +++++++++++++++++- uv.lock | 2 +- 7 files changed, 464 insertions(+), 105 deletions(-) diff --git a/.gitignore b/.gitignore index 0d76717..a1ac5d0 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,19 @@ Thumbs.db # ============ 归档(不纳入版本控制)============ archive/* !archive/.gitkeep + +# ============ 临时 LaTeX / TeX 测试文件 ============ +xetest.* +*.aux +*.fls +*.fdb_latexmk +*.synctex.gz + +# ============ Quarto 生成的中间文件 ============ +*_files/ +*.qmd +_preamble.tex + +# ============ 研究项目(实际数据,不纳入版本控制)============ +# 如需备份,请用独立的私有仓库 +projects/ diff --git a/.opencode/agents/dr-verifier.md b/.opencode/agents/dr-verifier.md index 3a90ebe..b824099 100644 --- a/.opencode/agents/dr-verifier.md +++ b/.opencode/agents/dr-verifier.md @@ -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 diff --git a/.opencode/templates/report-template.py b/.opencode/templates/report-template.py index d0b74bb..8b3e9a6 100755 --- a/.opencode/templates/report-template.py +++ b/.opencode/templates/report-template.py @@ -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 的 block(p/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"])) diff --git a/README.md b/README.md index 0b9a8e0..0842126 100644 --- a/README.md +++ b/README.md @@ -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 超宽表格渲染 bug;ReportLab 引擎保留为默认后备。Quarto 依赖独立安装,不影响现有环境。 详见 `PLAN.md` 了解完整方案、版本记录与迭代路径。 --- @@ -87,94 +87,8 @@ opencode # 启动 TUI **跑单个 Python 脚本**(不用先激活): ```bash -uv run python .opencode/templates/report-template.py --input ... --output ... -``` - -**加新依赖**: -```bash -uv add # 自动更新 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 ` | 把模型预设写入 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-searcher(Haiku,轻量)并行初扫 - → 生成 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 -# 等价入口(支持模型预设) -uv run python scripts/dr.py finalize --model-profile medium -``` - -默认行为: -- 自动估算 translate/polish 并发(`--translate-workers 0` / `--polish-workers 0`) -- glossary 仅核查低置信度术语(`--glossary-mode low-confidence`) - -你也可以手动分步执行: - -```bash -uv run python scripts/translate.py --workers 4 -uv run python scripts/build_glossary.py --workers 4 -uv run python scripts/apply_glossary.py --input phase4/final_zh.md --dry-run -uv run python scripts/apply_glossary.py --input phase4/final_zh.md -uv run python scripts/polish.py --workers 4 -uv run python scripts/build_report.py +uv run python scripts/build_report.py # 默认 ReportLab +uv run python scripts/build_report.py --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 引擎,按需选择: + +#### 引擎 A:ReportLab(默认,无额外依赖) + +```bash +uv run python scripts/build_report.py +``` - 字体:思源宋 + 思源黑 + 霞鹜文楷(全 SIL OFL,可商用嵌入) - 样式:集中在 `build_styles()`,所有字号行距单点维护 -- 引擎:ReportLab(纯 Python,30,000 字 3-5 秒出稿) -- 图表:matplotlib 预渲染 300 DPI PNG 嵌入 +- 速度:30,000 字 3-5 秒出稿 +- 局限:超宽表格(≥4 列且含长文本)需借助列宽 patch 或改为 bullet list 格式 + +#### 引擎 B:Quarto / xelatex(`--engine quarto`,推荐用于宽表报告) + +```bash +uv run python scripts/build_report.py --engine quarto +``` + +- 排版引擎:xelatex(TeX 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. 安装 TinyTeX(Quarto 内置命令) +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 双 provider(Claude 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 # Linux +~/.TinyTeX/bin/universal-darwin/tlmgr install # 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 Servers:https://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 了解完整变更历史。 diff --git a/codex_adapter_templates/codex/agents/dr-verifier.toml b/codex_adapter_templates/codex/agents/dr-verifier.toml index 4c6b4fe..8f1245a 100644 --- a/codex_adapter_templates/codex/agents/dr-verifier.toml +++ b/codex_adapter_templates/codex/agents/dr-verifier.toml @@ -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 = """ diff --git a/scripts/build_report.py b/scripts/build_report.py index aced9ff..83a3348 100644 --- a/scripts/build_report.py +++ b/scripts/build_report.py @@ -2,7 +2,7 @@ """Phase 4 成稿阶段:统一入口。 从 final_zh_polished.md(或指定的 Markdown)+ manifest.json 生成: -- .pdf ReportLab 出中文 PDF +- <title>.pdf PDF(ReportLab 或 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 命令未找到。请先安装 Quarto:https://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→ 生成 PDF(Quarto/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 原生)或 quarto(xelatex,更好的中文+宽表支持)", + ) 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) diff --git a/uv.lock b/uv.lock index e63f2ef..336346e 100644 --- a/uv.lock +++ b/uv.lock @@ -361,7 +361,7 @@ wheels = [ [[package]] name = "deep-research" -version = "0.3.0" +version = "0.12.0" source = { virtual = "." } dependencies = [ { name = "biopython" },