regenerate_latest_pdf.py 6.59 KB
"""
使用新的SVG矢量图表功能重新生成最新报告的PDF
"""

import json
import os
import sys
from datetime import datetime
from pathlib import Path
from loguru import logger

PROJECT_ROOT = Path(__file__).resolve().parents[2]
if str(PROJECT_ROOT) not in sys.path:
    sys.path.insert(0, str(PROJECT_ROOT))

from utils.runtime_paths import FINAL_REPORTS_DIR, LOGS_DIR, ensure_runtime_dirs

os.environ.setdefault("REPORT_ENGINE_OUTPUT_DIR", str(FINAL_REPORTS_DIR))
os.environ.setdefault("REPORT_ENGINE_CHAPTER_OUTPUT_DIR", str(FINAL_REPORTS_DIR / "chapters"))
os.environ.setdefault("REPORT_ENGINE_DOCUMENT_IR_OUTPUT_DIR", str(FINAL_REPORTS_DIR / "ir"))
os.environ.setdefault("REPORT_ENGINE_LOG_FILE", str(LOGS_DIR / "report.log"))

from services.engines.report.renderers import PDFRenderer


IR_OUTPUT_DIR = FINAL_REPORTS_DIR / "ir"
PDF_OUTPUT_DIR = FINAL_REPORTS_DIR / "pdf"

def find_latest_report():
    """
    在 `final_reports/ir` 中查找最新的报告 IR JSON。

    按修改时间倒序选择第一条,若目录或文件缺失则记录错误并返回 None。

    返回:
        Path | None: 最新 IR 文件路径;未找到则为 None。
    """
    ensure_runtime_dirs()
    ir_dir = IR_OUTPUT_DIR

    if not ir_dir.exists():
        logger.error(f"报告目录不存在: {ir_dir}")
        return None

    # 获取所有JSON文件并按修改时间排序
    json_files = sorted(ir_dir.glob("*.json"), key=lambda x: x.stat().st_mtime, reverse=True)

    if not json_files:
        logger.error("未找到报告文件")
        return None

    latest_file = json_files[0]
    logger.info(f"找到最新报告: {latest_file.name}")

    return latest_file

def load_document_ir(file_path):
    """
    读取指定路径的 Document IR JSON,并统计章节/图表数量。

    解析失败时返回 None;成功时会打印章节数与图表数,便于确认
    输入报告的规模。

    参数:
        file_path: IR 文件路径

    返回:
        dict | None: 解析后的 Document IR;失败返回 None。
    """
    try:
        with open(file_path, 'r', encoding='utf-8') as f:
            document_ir = json.load(f)

        logger.info(f"成功加载报告: {file_path.name}")

        # 统计图表数量
        chart_count = 0
        chapters = document_ir.get('chapters', [])

        def count_charts(blocks):
            """递归统计 block 列表中的 Chart.js 图表数量"""
            count = 0
            for block in blocks:
                if isinstance(block, dict):
                    if block.get('type') == 'widget' and block.get('widgetType', '').startswith('chart.js'):
                        count += 1
                    # 递归处理嵌套blocks
                    nested = block.get('blocks')
                    if isinstance(nested, list):
                        count += count_charts(nested)
            return count

        for chapter in chapters:
            blocks = chapter.get('blocks', [])
            chart_count += count_charts(blocks)

        logger.info(f"报告包含 {len(chapters)} 个章节,{chart_count} 个图表")

        return document_ir

    except Exception as e:
        logger.error(f"加载报告失败: {e}")
        return None

def generate_pdf_with_vector_charts(document_ir, output_path, ir_file_path=None):
    """
    使用 PDFRenderer 将 Document IR 渲染为包含 SVG 矢量图表的 PDF。

    启用布局优化,生成后输出文件大小与成功提示;异常时返回 None。

    参数:
        document_ir: 完整的 Document IR
        output_path: 目标 PDF 路径
        ir_file_path: 可选,IR 文件路径,提供时修复后会自动保存

    返回:
        Path | None: 成功时返回生成的 PDF 路径,失败返回 None。
    """
    try:
        logger.info("=" * 60)
        logger.info("开始生成PDF(带矢量图表)")
        logger.info("=" * 60)

        # 创建PDF渲染器
        renderer = PDFRenderer()

        # 渲染PDF,传入 ir_file_path 用于修复后保存
        result_path = renderer.render_to_pdf(
            document_ir,
            output_path,
            optimize_layout=True,
            ir_file_path=str(ir_file_path) if ir_file_path else None
        )

        logger.info("=" * 60)
        logger.info(f"✓ PDF生成成功: {result_path}")
        logger.info("=" * 60)

        # 显示文件大小
        file_size = result_path.stat().st_size
        size_mb = file_size / (1024 * 1024)
        logger.info(f"文件大小: {size_mb:.2f} MB")

        return result_path

    except Exception as e:
        logger.error(f"生成PDF失败: {e}", exc_info=True)
        return None

def main():
    """
    主入口:重新生成最新报告的矢量 PDF。

    步骤:
        1) 查找最新 IR 文件;
        2) 读取并统计报告结构;
        3) 构造输出文件名并确保目录存在;
        4) 调用渲染函数生成 PDF,输出路径与特性说明。

    返回:
        int: 0 表示成功,非 0 表示失败。
    """
    logger.info("🚀 使用SVG矢量图表重新生成最新报告的PDF")
    logger.info("")

    # 1. 找到最新报告
    latest_report = find_latest_report()
    if not latest_report:
        logger.error("未找到报告文件")
        return 1

    # 2. 加载报告数据
    document_ir = load_document_ir(latest_report)
    if not document_ir:
        logger.error("加载报告失败")
        return 1

    # 3. 生成输出文件名
    timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
    report_name = latest_report.stem.replace("report_ir_", "")
    output_filename = f"report_vector_{report_name}_{timestamp}.pdf"
    ensure_runtime_dirs()
    output_path = PDF_OUTPUT_DIR / output_filename

    # 确保输出目录存在
    output_path.parent.mkdir(parents=True, exist_ok=True)

    logger.info(f"输出路径: {output_path}")
    logger.info("")

    # 4. 生成PDF,传入 IR 文件路径用于修复后保存
    result = generate_pdf_with_vector_charts(document_ir, output_path, ir_file_path=latest_report)

    if result:
        logger.info("")
        logger.info("🎉 PDF生成完成!")
        logger.info("")
        logger.info("特性说明:")
        logger.info("  ✓ 图表以SVG矢量格式渲染")
        logger.info("  ✓ 支持无限缩放不失真")
        logger.info("  ✓ 保留完整的图表视觉效果")
        logger.info("  ✓ 折线图、柱状图、饼图等均为矢量曲线")
        logger.info("")
        logger.info(f"PDF文件位置: {result.absolute()}")
        return 0
    else:
        logger.error("❌ PDF生成失败")
        return 1

if __name__ == "__main__":
    sys.exit(main())