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:
+16
@@ -51,3 +51,19 @@ Thumbs.db
|
||||
# ============ 归档(不纳入版本控制)============
|
||||
archive/*
|
||||
!archive/.gitkeep
|
||||
|
||||
# ============ 临时 LaTeX / TeX 测试文件 ============
|
||||
xetest.*
|
||||
*.aux
|
||||
*.fls
|
||||
*.fdb_latexmk
|
||||
*.synctex.gz
|
||||
|
||||
# ============ Quarto 生成的中间文件 ============
|
||||
*_files/
|
||||
*.qmd
|
||||
_preamble.tex
|
||||
|
||||
# ============ 研究项目(实际数据,不纳入版本控制)============
|
||||
# 如需备份,请用独立的私有仓库
|
||||
projects/
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]))
|
||||
|
||||
|
||||
@@ -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 <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-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 <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 引擎,按需选择:
|
||||
|
||||
#### 引擎 A:ReportLab(默认,无额外依赖)
|
||||
|
||||
```bash
|
||||
uv run python scripts/build_report.py <slug>
|
||||
```
|
||||
|
||||
- 字体:思源宋 + 思源黑 + 霞鹜文楷(全 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 <slug> --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 <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 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 了解完整变更历史。
|
||||
|
||||
@@ -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 = """
|
||||
|
||||
+258
-1
@@ -2,7 +2,7 @@
|
||||
"""Phase 4 成稿阶段:统一入口。
|
||||
|
||||
从 final_zh_polished.md(或指定的 Markdown)+ manifest.json 生成:
|
||||
- <title>.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,6 +476,9 @@ def main() -> int:
|
||||
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)
|
||||
|
||||
Reference in New Issue
Block a user