epub2md/epub2md/media.py
dschlueter 02449734a2 feat: epub2md – EPUB nach kapitelweisem Markdown
Kommandozeilenwerkzeug, das DRM-freie EPUB-2/3-Dateien offline in
kapitelweise Markdown-Dateien umwandelt. Erzeugt werden außerdem Medien,
metadata.yaml, manifest.json, conversion-report.json und Diagnose-Dateien.

- Spine-basierte Kapitelbildung mit --chapter-level, epub:type-Abschnitten,
  Fortsetzungsdateien und Erkennung von Layout-Überschriften
- Code mit exakter Einrückung, sicheren Fences und konservativer
  Spracherkennung
- MathML->TeX (konservativ), TeX aus alt-Texten, SVG-/Bildformeln
  bleiben Bilder
- Fußnoten-Modi end-of-chapter, end-of-book, inline und keep-links
- Interne Links/Anker werden umgeschrieben, Linkcheck über die Ausgabe
- Sicher: kein Entpacken, Schutz vor Path Traversal und XXE,
  DRM-Erkennung, atomares Schreiben, --force nur für frühere Ausgaben
- 82 pytest-Tests mit synthetischen EPUBs und Invariantentests für Beispiele/

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 19:33:42 +02:00

98 lines
3.7 KiB
Python

"""Media registry: stable, collision-free output names; each source extracted once."""
from __future__ import annotations
import mimetypes
import posixpath
from .models import MediaFile, Package
from .utils import safe_zip_path, slugify
KIND_DIRS = {"images": "images", "audio": "audio", "video": "video", "other": "other"}
def media_kind(media_type: str, path: str) -> str:
mt = media_type or (mimetypes.guess_type(path)[0] or "")
if mt.startswith("image/"):
return "images"
if mt.startswith("audio/"):
return "audio"
if mt.startswith("video/"):
return "video"
return "other"
class MediaRegistry:
def __init__(self, package: Package, strategy: str, slug_language: str) -> None:
self.package = package
self.strategy = strategy # relative | flat | original-path
self.slug_language = slug_language
self.files: dict[str, MediaFile] = {}
self._used: set[str] = set()
self._counter = 0
self.opf_dir = posixpath.dirname(package.opf_path)
def _media_type(self, path: str) -> str:
item = self.package.item_by_path(path)
if item and item.media_type:
return item.media_type
return mimetypes.guess_type(path)[0] or "application/octet-stream"
def _unique(self, candidate: str) -> str:
if candidate not in self._used:
self._used.add(candidate)
return candidate
stem, ext = posixpath.splitext(candidate)
n = 2
while f"{stem}-{n}{ext}" in self._used:
n += 1
result = f"{stem}-{n}{ext}"
self._used.add(result)
return result
def _output_name(self, source: str, kind: str, hint: str = "") -> str:
base = posixpath.basename(source)
stem, ext = posixpath.splitext(base)
ext = ext.lower()
if self.strategy == "original-path":
rel = source[len(self.opf_dir) + 1 :] if self.opf_dir and source.startswith(self.opf_dir + "/") else source
rel = safe_zip_path(rel) or base
return self._unique(f"media/{rel}")
if self.strategy == "flat":
return self._unique(f"media/{base}")
self._counter += 1
slug = slugify(hint or stem, self.slug_language, max_len=40, fallback="media")
return self._unique(f"media/{KIND_DIRS[kind]}/{self._counter:04d}-{slug}{ext}")
def register(self, source: str, role: str = "", hint: str = "") -> MediaFile:
existing = self.files.get(source)
if existing:
existing.references += 1
if role and not existing.role:
existing.role = role
return existing
media_type = self._media_type(source)
kind = media_kind(media_type, source)
mf = MediaFile(
source=source,
output=self._output_name(source, kind, hint),
media_type=media_type,
kind=kind,
references=1,
role=role,
)
self.files[source] = mf
return mf
def register_generated(self, name: str, ext: str, data: bytes, media_type: str, role: str) -> MediaFile:
source = f"generated:{name}{ext}"
kind = media_kind(media_type, name + ext)
if self.strategy == "relative":
self._counter += 1
out = self._unique(f"media/{KIND_DIRS[kind]}/{self._counter:04d}-{slugify(name, self.slug_language, 40, 'media')}{ext}")
else:
out = self._unique(f"media/generated/{slugify(name, self.slug_language, 40, 'media')}{ext}")
mf = MediaFile(source=source, output=out, media_type=media_type, kind=kind, size=len(data),
references=1, role=role, data=data)
self.files[source] = mf
return mf