This commit is contained in:
commit
46af290db3
100 files changed
+16663
No files matched your search
@@ -0,0 +1,239 @@
|
||||
"""
|
||||
Сервис экспорта документов в различные форматы (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()
|
||||
Reference in new issue
Block a user