v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數 - 修改泳道選擇算法,優先選擇無標籤重疊的泳道 - 兩階段搜尋:優先側別無可用泳道則嘗試另一側 - 增強日誌輸出,顯示標籤範圍和詳細衝突分數 🤖 Generated with Claude Code Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
343
backend/export.py
Normal file
343
backend/export.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
匯出模組
|
||||
|
||||
本模組負責將時間軸圖表匯出為各種格式(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',
|
||||
]
|
||||
Reference in New Issue
Block a user