v9.5: 實作標籤完全不重疊算法

- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
beabigegg
2025-11-06 11:35:29 +08:00
commit 2d37d23bcf
83 changed files with 22971 additions and 0 deletions

343
backend/export.py Normal file
View 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',
]