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:
@@ -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"]))
|
||||
|
||||
|
||||
Reference in New Issue
Block a user