240 lines
9.9 KiB
Python
240 lines
9.9 KiB
Python
"""
|
|
Сервис экспорта документов в различные форматы (EPUB, FB2, PDF, DOCX) через Pandoc.
|
|
"""
|
|
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
from typing import List
|
|
|
|
from fastapi import HTTPException
|
|
from models.document import DocumentNode
|
|
|
|
from services.document_service import document_service
|
|
from services.project_service import project_service
|
|
|
|
|
|
class ExportService:
|
|
def __init__(self):
|
|
# Проверка наличия pandoc при инициализации
|
|
try:
|
|
subprocess.run(["pandoc", "--version"], capture_output=True, check=True)
|
|
self.available = True
|
|
except FileNotFoundError:
|
|
print("WARNING: Pandoc not found. Export functionality will be limited.")
|
|
self.available = False
|
|
|
|
def _get_document_and_children(
|
|
self, documents: List[DocumentNode], target_id: str
|
|
) -> List[DocumentNode]:
|
|
"""Рекурсивно получает документ и всех его потомков, отсортированных по order."""
|
|
result: List[DocumentNode] = []
|
|
doc_map = {d.id: d for d in documents}
|
|
|
|
def collect_children(parent_id: str):
|
|
children = [d for d in documents if d.parent_id == parent_id]
|
|
children.sort(key=lambda x: x.order)
|
|
for child in children:
|
|
result.append(child)
|
|
collect_children(child.id)
|
|
|
|
if target_id in doc_map:
|
|
result.append(doc_map[target_id])
|
|
collect_children(target_id)
|
|
|
|
return result
|
|
|
|
def _build_book_html(
|
|
self,
|
|
project_id: str,
|
|
user_id: str,
|
|
document_id: str | None = None,
|
|
base_path: str = "", # Абсолютный путь для относительных ресурсов
|
|
) -> str:
|
|
"""Собирает HTML-контент книги или конкретного документа с изображениями."""
|
|
documents = document_service.get_all_documents(project_id, user_id)
|
|
project_dir = project_service.get_project_dir(project_id, user_id)
|
|
images_dir = project_dir / "images"
|
|
|
|
# Проверяем наличие обложки
|
|
cover_path = images_dir / "cover.png"
|
|
has_cover = cover_path.exists()
|
|
|
|
if document_id:
|
|
target_docs = self._get_document_and_children(documents, document_id)
|
|
else:
|
|
# Экспортируем всё: берем корневые документы (книги) и всех потомков
|
|
tree = document_service.get_documents_tree(project_id, user_id)
|
|
target_docs = self._flatten_tree(tree)
|
|
|
|
# Собираем все PNG-файлы изображений
|
|
all_images = []
|
|
if images_dir.exists():
|
|
all_images = [
|
|
f.name
|
|
for f in images_dir.iterdir()
|
|
if f.is_file() and f.suffix.lower() == ".png"
|
|
]
|
|
|
|
# Определяем ID всех глав для поиска привязанных изображений
|
|
chapter_ids = {d.id for d in documents if d.type == "chapter"}
|
|
|
|
# "Бесхозные" изображения — не cover.png и не {chapter_id}.png
|
|
# (старые файлы с UUID, сгенерированные до введения конвенции)
|
|
orphan_images = [
|
|
img
|
|
for img in all_images
|
|
if img != "cover.png" and img.removesuffix(".png") not in chapter_ids
|
|
]
|
|
orphan_index = (
|
|
0 # Индекс для последовательного использования orphan-изображений
|
|
)
|
|
|
|
# Формируем префикс пути: абсолютный путь если задан, иначе относительный
|
|
img_prefix = f"{base_path}/images" if base_path else "images"
|
|
|
|
html_parts: List[str] = []
|
|
|
|
# Добавляем обложку, если есть и это полная книга (не один документ)
|
|
if has_cover and not document_id:
|
|
html_parts.append(
|
|
'<div style="text-align: center; page-break-after: always;">'
|
|
)
|
|
html_parts.append(
|
|
f'<img src="{img_prefix}/cover.png" alt="Cover" style="max-width: 100%; height: auto;" />'
|
|
)
|
|
html_parts.append("</div>")
|
|
html_parts.append("<br/><br/>")
|
|
|
|
for doc in target_docs:
|
|
if doc.type == "book":
|
|
html_parts.append(f"<h1>{doc.title}</h1>")
|
|
elif doc.type == "chapter":
|
|
# Ищем изображение главы: сначала по конвенции {chapter_id}.png
|
|
chapter_image_path = images_dir / f"{doc.id}.png"
|
|
has_chapter_image = chapter_image_path.exists()
|
|
chapter_image_filename = f"{doc.id}.png" if has_chapter_image else None
|
|
|
|
# Fallback: если нет файла по конвенции, берём "бесхозное" изображение
|
|
if not has_chapter_image and orphan_index < len(orphan_images):
|
|
chapter_image_filename = orphan_images[orphan_index]
|
|
orphan_index += 1
|
|
|
|
html_parts.append(f"<h2>{doc.title}</h2>")
|
|
|
|
# Вставляем изображение главы, если есть
|
|
if chapter_image_filename:
|
|
html_parts.append(
|
|
'<div style="text-align: center; margin: 20px 0;">'
|
|
)
|
|
html_parts.append(
|
|
f'<img src="{img_prefix}/{chapter_image_filename}" alt="{doc.title}" style="max-width: 100%; height: auto;" />'
|
|
)
|
|
html_parts.append("</div>")
|
|
elif doc.type == "scene" and doc.content:
|
|
html_parts.append(f"<h3>{doc.title}</h3>")
|
|
html_parts.append(doc.content)
|
|
html_parts.append("<br/><br/>")
|
|
|
|
return "\n".join(html_parts)
|
|
|
|
def _flatten_tree(self, tree: List[dict]) -> List[DocumentNode]:
|
|
"""Разворачивает дерево документов в плоский список в порядке обхода."""
|
|
result: List[DocumentNode] = []
|
|
for node in tree:
|
|
result.append(DocumentNode(**node))
|
|
for child in node.get("children", []):
|
|
result.extend(self._flatten_tree([child]))
|
|
return result
|
|
|
|
def export_document(
|
|
self, project_id: str, user_id: str, document_id: str | None, format: str
|
|
) -> bytes:
|
|
"""Экспортирует документ (или всю книгу) в указанный формат."""
|
|
if not self.available:
|
|
raise HTTPException(
|
|
status_code=503,
|
|
detail="Pandoc is not installed. Export functionality is unavailable.",
|
|
)
|
|
|
|
valid_formats = ["epub", "fb2", "pdf", "docx"]
|
|
if format not in valid_formats:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Unsupported format. Choose from {valid_formats}",
|
|
)
|
|
|
|
# Получаем проект для метаданных
|
|
project = project_service.get_project(project_id)
|
|
title = project.title if project else "Без названия"
|
|
|
|
# Собираем HTML
|
|
html_content = self._build_book_html(project_id, user_id, document_id)
|
|
|
|
# Получаем путь к папке проекта для копирования изображений
|
|
project_dir = project_service.get_project_dir(project_id, user_id)
|
|
images_dir = project_dir / "images"
|
|
|
|
# Создаём временную директорию
|
|
temp_dir = tempfile.mkdtemp()
|
|
temp_html = os.path.join(temp_dir, "content.html")
|
|
output_path = os.path.join(temp_dir, f"output.{format}")
|
|
|
|
# Копируем папку images во временную директорию для Pandoc
|
|
if images_dir.exists():
|
|
shutil.copytree(images_dir, os.path.join(temp_dir, "images"))
|
|
|
|
try:
|
|
# Записываем HTML
|
|
html_content = self._build_book_html(
|
|
project_id, user_id, document_id, base_path=temp_dir
|
|
)
|
|
with open(temp_html, "w", encoding="utf-8") as f:
|
|
f.write(html_content)
|
|
|
|
# Формируем команду pandoc
|
|
cmd = [
|
|
"pandoc",
|
|
temp_html,
|
|
"-o",
|
|
output_path,
|
|
"--from",
|
|
"html",
|
|
"--to",
|
|
format,
|
|
]
|
|
|
|
# Метаданные для EPUB/FB2
|
|
if format in ["epub", "fb2"]:
|
|
cmd.extend(["--metadata", f"title={title}"])
|
|
cmd.extend(["--metadata", "author=Author"])
|
|
# Указываем путь к ресурсам (изображениям)
|
|
cmd.extend(["--resource-path", temp_dir])
|
|
|
|
# PDF через WeasyPrint (не требует LaTeX)
|
|
if format == "pdf":
|
|
cmd.extend(["--pdf-engine=weasyprint"])
|
|
|
|
# Выполняем
|
|
subprocess.run(cmd, check=True, capture_output=True, text=True)
|
|
|
|
# Читаем результат
|
|
with open(output_path, "rb") as f:
|
|
file_content = f.read()
|
|
|
|
return file_content
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
raise HTTPException(status_code=500, detail=f"Pandoc error: {e.stderr}")
|
|
except Exception as e:
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
finally:
|
|
# Очистка временных файлов
|
|
if os.path.exists(temp_dir):
|
|
shutil.rmtree(temp_dir)
|
|
|
|
|
|
# Singleton instance
|
|
export_service = ExportService()
|