清理內容: - 移除所有開發元資訊(Author, Version, DocID, Rationale等) - 刪除註解掉的代碼片段(力導向演算法等24行) - 移除調試用的 logger.debug 語句 - 簡化冗餘的內聯註解(emoji、"重要"等標註) - 刪除 TDD 文件引用 清理檔案: - backend/main.py - 移除調試日誌和元資訊 - backend/importer.py - 移除詳細類型檢查調試 - backend/export.py - 簡化 docstring - backend/schemas.py - 移除元資訊 - backend/renderer.py - 移除 TDD 引用 - backend/renderer_timeline.py - 移除註解代碼和冗餘註解 - backend/path_planner.py - 簡化策略註解 保留內容: - 所有函數的 docstring(功能說明、參數、返回值) - 必要的業務邏輯註解 - 簡潔的模組功能說明 效果: - 刪除約 100+ 行冗餘註解 - 代碼更加簡潔專業 - 功能完整性 100% 保留 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
337 lines
8.6 KiB
Python
337 lines
8.6 KiB
Python
"""
|
||
匯出模組
|
||
|
||
本模組負責將時間軸圖表匯出為各種格式(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',
|
||
]
|