- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
344 lines
8.7 KiB
Python
344 lines
8.7 KiB
Python
"""
|
||
匯出模組
|
||
|
||
本模組負責將時間軸圖表匯出為各種格式(PDF、PNG、SVG)。
|
||
使用 Plotly 的 kaleido 引擎進行圖片生成。
|
||
|
||
Author: AI Agent
|
||
Version: 1.0.0
|
||
DocID: SDD-EXP-001
|
||
Related: TDD-UT-EXP-001
|
||
Rationale: 實現 SDD.md 定義的 POST /export API 功能
|
||
"""
|
||
|
||
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 圖表匯出為不同格式的檔案。
|
||
對應 TDD.md - UT-EXP-01
|
||
"""
|
||
|
||
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',
|
||
]
|