Files
Timeline_Generator/backend/export.py
beabigegg 2d37d23bcf v9.5: 實作標籤完全不重疊算法
- 新增 _calculate_lane_conflicts_v2() 分開返回標籤重疊和線穿框分數
- 修改泳道選擇算法,優先選擇無標籤重疊的泳道
- 兩階段搜尋:優先側別無可用泳道則嘗試另一側
- 增強日誌輸出,顯示標籤範圍和詳細衝突分數

🤖 Generated with Claude Code
Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-06 11:35:29 +08:00

344 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
匯出模組
本模組負責將時間軸圖表匯出為各種格式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',
]