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:
+259
-2
@@ -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,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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user