""" Сервис экспорта документов в различные форматы (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( '
' ) html_parts.append( f'Cover' ) html_parts.append("
") html_parts.append("

") for doc in target_docs: if doc.type == "book": html_parts.append(f"

{doc.title}

") 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"

{doc.title}

") # Вставляем изображение главы, если есть if chapter_image_filename: html_parts.append( '
' ) html_parts.append( f'{doc.title}' ) html_parts.append("
") elif doc.type == "scene" and doc.content: html_parts.append(f"

{doc.title}

") html_parts.append(doc.content) html_parts.append("

") 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()