""" 匯出模組 本模組負責將時間軸圖表匯出為各種格式(PDF、PNG、SVG)。 使用 Plotly 的 kaleido 引擎進行圖片生成。 """ import os from pathlib import Path from datetime import datetime from typing import Union, Optional import logging import re try: import plotly.graph_objects as go from plotly.io import write_image PLOTLY_AVAILABLE = True except ImportError: PLOTLY_AVAILABLE = False from .schemas import ExportOptions, ExportFormat logger = logging.getLogger(__name__) class ExportError(Exception): """匯出錯誤基礎類別""" pass class FileNameSanitizer: """ 檔名淨化器 移除非法字元並處理過長的檔名。 """ # 非法字元(Windows + Unix) ILLEGAL_CHARS = r'[<>:"/\\|?*\x00-\x1f]' # 保留字(Windows) RESERVED_NAMES = [ 'CON', 'PRN', 'AUX', 'NUL', 'COM1', 'COM2', 'COM3', 'COM4', 'COM5', 'COM6', 'COM7', 'COM8', 'COM9', 'LPT1', 'LPT2', 'LPT3', 'LPT4', 'LPT5', 'LPT6', 'LPT7', 'LPT8', 'LPT9', ] MAX_LENGTH = 200 # 最大檔名長度 @classmethod def sanitize(cls, filename: str) -> str: """ 淨化檔名 Args: filename: 原始檔名 Returns: 淨化後的檔名 """ # 移除非法字元 sanitized = re.sub(cls.ILLEGAL_CHARS, '_', filename) # 移除前後空白 sanitized = sanitized.strip() # 移除尾部的點和空格(Windows 限制) sanitized = sanitized.rstrip('. ') # 檢查保留字 name_upper = sanitized.upper() if name_upper in cls.RESERVED_NAMES: sanitized = '_' + sanitized # 限制長度 if len(sanitized) > cls.MAX_LENGTH: sanitized = sanitized[:cls.MAX_LENGTH] # 如果為空,使用預設名稱 if not sanitized: sanitized = 'timeline' return sanitized class ExportEngine: """ 匯出引擎 負責將 Plotly 圖表匯出為不同格式的檔案。 """ def __init__(self): if not PLOTLY_AVAILABLE: raise ImportError("需要安裝 plotly 和 kaleido 以使用匯出功能") self.filename_sanitizer = FileNameSanitizer() def export( self, fig: go.Figure, output_path: Union[str, Path], options: ExportOptions ) -> Path: """ 匯出圖表 Args: fig: Plotly Figure 物件 output_path: 輸出路徑 options: 匯出選項 Returns: 實際輸出檔案的路徑 Raises: ExportError: 匯出失敗時拋出 """ output_path = Path(output_path) # 確保目錄存在 output_path.parent.mkdir(parents=True, exist_ok=True) # 淨化檔名 filename = self.filename_sanitizer.sanitize(output_path.stem) sanitized_path = output_path.parent / f"{filename}{output_path.suffix}" try: if options.fmt == ExportFormat.PDF: return self._export_pdf(fig, sanitized_path, options) elif options.fmt == ExportFormat.PNG: return self._export_png(fig, sanitized_path, options) elif options.fmt == ExportFormat.SVG: return self._export_svg(fig, sanitized_path, options) else: raise ExportError(f"不支援的匯出格式: {options.fmt}") except PermissionError: raise ExportError(f"無法寫入檔案(權限不足): {sanitized_path}") except OSError as e: if e.errno == 28: # ENOSPC raise ExportError("磁碟空間不足") else: raise ExportError(f"檔案系統錯誤: {str(e)}") except Exception as e: logger.error(f"匯出失敗: {str(e)}") raise ExportError(f"匯出失敗: {str(e)}") def _export_pdf(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path: """ 匯出為 PDF Args: fig: Plotly Figure output_path: 輸出路徑 options: 匯出選項 Returns: 輸出檔案路徑 """ # 確保副檔名 if output_path.suffix.lower() != '.pdf': output_path = output_path.with_suffix('.pdf') # 設定 DPI 和尺寸 scale = options.dpi / 72.0 # Plotly 使用 72 DPI 作為基準 # 匯出 write_image( fig, str(output_path), format='pdf', width=options.width, height=options.height, scale=scale ) logger.info(f"PDF 匯出成功: {output_path}") return output_path def _export_png(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path: """ 匯出為 PNG Args: fig: Plotly Figure output_path: 輸出路徑 options: 匯出選項 Returns: 輸出檔案路徑 """ # 確保副檔名 if output_path.suffix.lower() != '.png': output_path = output_path.with_suffix('.png') # 設定 DPI 和尺寸 scale = options.dpi / 72.0 # 處理透明背景 if options.transparent_background: fig.update_layout( paper_bgcolor='rgba(0,0,0,0)', plot_bgcolor='rgba(0,0,0,0)' ) # 匯出 write_image( fig, str(output_path), format='png', width=options.width, height=options.height, scale=scale ) logger.info(f"PNG 匯出成功: {output_path}") return output_path def _export_svg(self, fig: go.Figure, output_path: Path, options: ExportOptions) -> Path: """ 匯出為 SVG Args: fig: Plotly Figure output_path: 輸出路徑 options: 匯出選項 Returns: 輸出檔案路徑 """ # 確保副檔名 if output_path.suffix.lower() != '.svg': output_path = output_path.with_suffix('.svg') # SVG 是向量格式,不需要 DPI 設定 write_image( fig, str(output_path), format='svg', width=options.width, height=options.height ) logger.info(f"SVG 匯出成功: {output_path}") return output_path class TimelineExporter: """ 時間軸匯出器 高層級介面,整合渲染與匯出功能。 """ def __init__(self): self.export_engine = ExportEngine() def export_from_plotly_json( self, plotly_data: dict, plotly_layout: dict, output_path: Union[str, Path], options: ExportOptions, filename_prefix: str = "timeline" ) -> Path: """ 從 Plotly JSON 資料匯出 Args: plotly_data: Plotly data 部分 plotly_layout: Plotly layout 部分 output_path: 輸出路徑(目錄或完整路徑) options: 匯出選項 filename_prefix: 檔名前綴 Returns: 實際輸出檔案的路徑 """ # 建立 Plotly Figure fig = go.Figure(data=plotly_data.get('data', []), layout=plotly_layout) # 處理輸出路徑 output_path = Path(output_path) if output_path.is_dir(): # 如果是目錄,生成預設檔名 timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') filename = f"{filename_prefix}_{timestamp}.{options.fmt.value}" full_path = output_path / filename else: full_path = output_path # 匯出 return self.export_engine.export(fig, full_path, options) def generate_default_filename(self, fmt: ExportFormat) -> str: """ 生成預設檔名 Args: fmt: 檔案格式 Returns: 預設檔名 """ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S') return f"timeline_{timestamp}.{fmt.value}" def create_metadata(title: str = "TimeLine Designer") -> dict: """ 建立 PDF 元資料 Args: title: 文件標題 Returns: 元資料字典 """ return { 'Title': title, 'Creator': 'TimeLine Designer v1.0', 'Producer': 'Plotly + Kaleido', 'CreationDate': datetime.now().strftime('%Y-%m-%d %H:%M:%S'), } # 匯出主要介面 __all__ = [ 'ExportEngine', 'TimelineExporter', 'ExportError', 'ExportOptions', 'ExportFormat', ]