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>
98 lines
3.7 KiB
Python
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
|