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
+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"]))