"""
文档转换服务
使用 LibreOffice 将 Office 文档转换为 PDF
"""
import os
import logging
import subprocess
from pathlib import Path
from typing import Optional
from datetime import datetime
from app.core.config import settings
logger = logging.getLogger(__name__)
class DocumentConverterService:
"""文档转换服务类"""
# 支持转换的文件格式
SUPPORTED_FORMATS = {'.docx', '.doc', '.pptx', '.ppt', '.xlsx', '.xls'}
# Excel文件格式(需要特殊处理页面布局)
EXCEL_FORMATS = {'.xlsx', '.xls'}
def __init__(self):
"""初始化转换服务"""
self.converted_path = Path(settings.UPLOAD_PATH) / "converted"
self.converted_path.mkdir(parents=True, exist_ok=True)
def get_converted_file_path(self, course_id: int, material_id: int) -> Path:
"""
获取转换后的文件路径
Args:
course_id: 课程ID
material_id: 资料ID
Returns:
转换后的PDF文件路径
"""
course_dir = self.converted_path / str(course_id)
course_dir.mkdir(parents=True, exist_ok=True)
return course_dir / f"{material_id}.pdf"
def need_convert(self, source_file: Path, converted_file: Path) -> bool:
"""
判断是否需要重新转换
Args:
source_file: 源文件路径
converted_file: 转换后的文件路径
Returns:
是否需要转换
"""
# 如果转换文件不存在,需要转换
if not converted_file.exists():
return True
# 如果源文件不存在,不需要转换
if not source_file.exists():
return False
# 如果源文件修改时间晚于转换文件,需要重新转换
source_mtime = source_file.stat().st_mtime
converted_mtime = converted_file.stat().st_mtime
return source_mtime > converted_mtime
def convert_excel_to_html(
self,
source_file: str,
course_id: int,
material_id: int
) -> Optional[str]:
"""
将Excel文件转换为HTML(避免PDF分页问题)
Args:
source_file: 源文件路径
course_id: 课程ID
material_id: 资料ID
Returns:
转换后的HTML文件URL,失败返回None
"""
try:
try:
import openpyxl
from openpyxl.utils import get_column_letter
except ImportError as ie:
logger.error(f"Excel转换依赖缺失: openpyxl 未安装。请运行 pip install openpyxl 或重建Docker镜像。错误: {str(ie)}")
return None
source_path = Path(source_file)
logger.info(f"开始Excel转HTML: source={source_file}, course_id={course_id}, material_id={material_id}")
# 获取HTML输出路径
course_dir = self.converted_path / str(course_id)
course_dir.mkdir(parents=True, exist_ok=True)
html_file = course_dir / f"{material_id}.html"
# 检查缓存
if html_file.exists():
source_mtime = source_path.stat().st_mtime
html_mtime = html_file.stat().st_mtime
if source_mtime <= html_mtime:
logger.info(f"使用缓存的HTML文件: {html_file}")
return f"/static/uploads/converted/{course_id}/{material_id}.html"
# 读取Excel文件
wb = openpyxl.load_workbook(source_file, data_only=True)
# 构建HTML
html_content = '''
'''
# 生成sheet选项卡
sheet_names = wb.sheetnames
html_content += '\n'
for i, name in enumerate(sheet_names):
active = 'active' if i == 0 else ''
html_content += f'
{name}
\n'
html_content += '
\n'
# 生成每个sheet的表格
for i, sheet_name in enumerate(sheet_names):
ws = wb[sheet_name]
active = 'active' if i == 0 else ''
html_content += f'\n'
html_content += '
\n'
# 获取有效数据范围
max_row = ws.max_row or 1
max_col = ws.max_column or 1
for row_idx in range(1, min(max_row + 1, 1001)): # 限制最多1000行
html_content += ''
for col_idx in range(1, min(max_col + 1, 51)): # 限制最多50列
cell = ws.cell(row=row_idx, column=col_idx)
value = cell.value if cell.value is not None else ''
tag = 'th' if row_idx == 1 else 'td'
# 转义HTML特殊字符
if isinstance(value, str):
value = value.replace('&', '&').replace('<', '<').replace('>', '>')
html_content += f'<{tag}>{value}{tag}>'
html_content += '
\n'
html_content += '
\n'
# 添加JavaScript
html_content += '''
'''
# 写入HTML文件
with open(html_file, 'w', encoding='utf-8') as f:
f.write(html_content)
logger.info(f"Excel转HTML成功: {html_file}")
return f"/static/uploads/converted/{course_id}/{material_id}.html"
except Exception as e:
logger.error(f"Excel转HTML失败: {source_file}, 错误: {str(e)}", exc_info=True)
return None
def convert_to_pdf(
self,
source_file: str,
course_id: int,
material_id: int
) -> Optional[str]:
"""
将Office文档转换为PDF
Args:
source_file: 源文件路径(绝对路径或相对路径)
course_id: 课程ID
material_id: 资料ID
Returns:
转换后的PDF文件URL,失败返回None
"""
try:
source_path = Path(source_file)
# 检查源文件是否存在
if not source_path.exists():
logger.error(f"源文件不存在: {source_file}")
return None
# 检查文件格式是否支持
file_ext = source_path.suffix.lower()
if file_ext not in self.SUPPORTED_FORMATS:
logger.error(f"不支持的文件格式: {file_ext}")
return None
# Excel文件使用HTML预览(避免分页问题)
if file_ext in self.EXCEL_FORMATS:
return self.convert_excel_to_html(source_file, course_id, material_id)
# 获取转换后的文件路径
converted_file = self.get_converted_file_path(course_id, material_id)
# 检查是否需要转换
if not self.need_convert(source_path, converted_file):
logger.info(f"使用缓存的转换文件: {converted_file}")
return f"/static/uploads/converted/{course_id}/{material_id}.pdf"
# 执行转换
logger.info(f"开始转换文档: {source_file} -> {converted_file}")
# 使用 LibreOffice 转换
# --headless: 无界面模式
# --convert-to pdf: 转换为PDF
# --outdir: 输出目录
output_dir = converted_file.parent
cmd = [
'libreoffice',
'--headless',
'--convert-to', 'pdf',
'--outdir', str(output_dir),
str(source_path)
]
# 执行转换命令(设置超时时间为60秒)
result = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=60,
check=True
)
# LibreOffice 转换后的文件名是源文件名.pdf
# 需要重命名为 material_id.pdf
temp_converted = output_dir / f"{source_path.stem}.pdf"
if temp_converted.exists() and temp_converted != converted_file:
temp_converted.rename(converted_file)
# 检查转换结果
if converted_file.exists():
logger.info(f"文档转换成功: {converted_file}")
return f"/static/uploads/converted/{course_id}/{material_id}.pdf"
else:
logger.error(f"文档转换失败,输出文件不存在: {converted_file}")
return None
except subprocess.TimeoutExpired:
logger.error(f"文档转换超时: {source_file}")
return None
except subprocess.CalledProcessError as e:
logger.error(f"文档转换失败: {source_file}, 错误: {e.stderr}")
return None
except Exception as e:
logger.error(f"文档转换异常: {source_file}, 错误: {str(e)}", exc_info=True)
return None
def is_convertible(self, file_ext: str) -> bool:
"""
判断文件格式是否可转换
Args:
file_ext: 文件扩展名(带点,如 .docx)
Returns:
是否可转换
"""
return file_ext.lower() in self.SUPPORTED_FORMATS
# 创建全局实例
document_converter = DocumentConverterService()