"""
Экспорт в CBZ, PDF, EPUB с поддержкой метаданных для Komga.
"""
import zipfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from typing import Literal, Optional
from loguru import logger
ExportFormat = Literal["cbz", "pdf", "epub"]
@dataclass
class MangaMeta:
"""Метаданные манги и главы для встраивания в файлы."""
series: str = "" # Название серии (title_ru)
series_full: str = "" # Полное название
chapter_title: str = "" # Название главы
number: float = 0.0 # Номер главы
volume: int = 0 # Том
chapters_total: int = 0 # Всего глав в серии (для completed)
pub_status: str = "unknown" # completed / ongoing / unknown
source_url: str = "" # URL источника
language: str = "ru"
summary: str = "" # Описание/синопсис серии
genre: str = "" # Жанры через запятую (для ComicInfo Genre)
tags: str = "" # Теги через запятую (для ComicInfo Tags)
series_group: str = "" # Группа/коллекция (для ComicInfo SeriesGroup)
def export(
image_paths: list[Path],
output_path: Path,
fmt: ExportFormat,
title: str = "Manga",
chapter: str = "",
meta: Optional[MangaMeta] = None,
):
# Строим meta из legacy-аргументов если не передан явно
if meta is None:
meta = MangaMeta(series=title, chapter_title=chapter)
output_path.parent.mkdir(parents=True, exist_ok=True)
logger.info("Экспортирую {} страниц → {} ({})", len(image_paths), output_path.name, fmt)
if fmt == "cbz":
_export_cbz(image_paths, output_path, meta)
elif fmt == "pdf":
_export_pdf(image_paths, output_path, meta)
elif fmt == "epub":
_export_epub(image_paths, output_path, meta)
else:
raise ValueError(f"Неизвестный формат: {fmt}")
logger.info("Сохранено: {}", output_path)
# ── CBZ + ComicInfo.xml ───────────────────────
def _make_comic_info(meta: MangaMeta) -> str:
"""Генерирует ComicInfo.xml по спецификации Anansi v2.1 (Komga-совместимый)."""
root = ET.Element("ComicInfo")
root.set("xmlns:xsi", "http://www.w3.org/2001/XMLSchema-instance")
root.set("xsi:noNamespaceSchemaLocation",
"https://raw.githubusercontent.com/anansi-project/comicinfo/main/schema/v2.1/ComicInfo.xsd")
def add(tag: str, value):
if value is None:
return
s = str(value).strip()
if s:
ET.SubElement(root, tag).text = s
add("Series", meta.series)
add("Title", meta.chapter_title)
add("Summary", meta.summary)
# Номер главы: целое если без дроби, иначе float
if meta.number:
num_str = str(int(meta.number)) if meta.number == int(meta.number) else str(meta.number)
add("Number", num_str)
if meta.volume:
add("Volume", meta.volume)
# Count — только для завершённых серий
if meta.pub_status == "completed" and meta.chapters_total:
add("Count", meta.chapters_total)
add("Genre", meta.genre)
add("Tags", meta.tags)
add("LanguageISO", meta.language)
# Manga = YesAndRightToLeft — стандартная японская манга
ET.SubElement(root, "Manga").text = "YesAndRightToLeft"
if meta.source_url:
add("Web", meta.source_url)
# SeriesGroup — Komga создаёт коллекцию с этим именем
if meta.series_group:
add("SeriesGroup", meta.series_group)
ET.indent(root, space=" ")
return '\n' + ET.tostring(root, encoding="unicode")
def _export_cbz(images: list[Path], out: Path, meta: MangaMeta):
with zipfile.ZipFile(out, "w", compression=zipfile.ZIP_DEFLATED) as zf:
# ComicInfo.xml первым файлом — Komga ищет его в корне архива
zf.writestr("ComicInfo.xml", _make_comic_info(meta))
for i, img in enumerate(images):
zf.write(img, f"{i:04d}{img.suffix}")
# ── PDF ───────────────────────────────────────
def _export_pdf(images: list[Path], out: Path, meta: MangaMeta):
try:
import img2pdf
pdf_bytes = img2pdf.convert([str(p) for p in images])
out.write_bytes(pdf_bytes)
except Exception as e:
logger.warning("img2pdf не сработал ({}), использую Pillow", e)
_export_pdf_pillow(images, out)
# Записываем метаданные поверх готового PDF через pypdf
_patch_pdf_meta(out, meta)
def _export_pdf_pillow(images: list[Path], out: Path):
from PIL import Image
pil_images = [Image.open(p).convert("RGB") for p in images]
try:
if pil_images:
pil_images[0].save(out, save_all=True, append_images=pil_images[1:], format="PDF")
finally:
for img in pil_images:
img.close()
def _patch_pdf_meta(pdf_path: Path, meta: MangaMeta):
"""Добавляет /Info и XMP метаданные в PDF через pypdf."""
try:
from pypdf import PdfReader, PdfWriter
import io
reader = PdfReader(str(pdf_path))
writer = PdfWriter()
writer.append(reader)
ch_num = int(meta.number) if meta.number == int(meta.number) else meta.number
full_title = (f"{meta.series} — Том {meta.volume}, Глава {ch_num}"
if meta.volume else f"{meta.series} — Глава {ch_num}")
if meta.chapter_title:
full_title += f": {meta.chapter_title}"
# Стандартные PDF /Info поля
writer.add_metadata({
"/Title": full_title,
"/Subject": meta.series_full or meta.series,
"/Creator": "Manga Downloader",
"/Producer": "Manga Downloader",
})
# XMP-метаданные (Dublin Core + PDF) — Komga читает их при сканировании
xmp = _build_xmp(meta, full_title)
writer.add_metadata_xmp(xmp.encode("utf-8"))
buf = io.BytesIO()
writer.write(buf)
pdf_path.write_bytes(buf.getvalue())
except ImportError:
logger.debug("pypdf не установлен — PDF-метаданные пропущены")
except Exception as e:
logger.warning("Ошибка записи PDF-метаданных: {}", e)
def _build_xmp(meta: MangaMeta, full_title: str) -> str:
ch_num = int(meta.number) if meta.number == int(meta.number) else meta.number
return f"""