From 3bd6310974c53a362324b53255874e02e6dc2737 Mon Sep 17 00:00:00 2001 From: Mattias Svensson Date: Tue, 7 Jul 2026 21:22:23 +0200 Subject: [PATCH] Preserve original chapter files when merging epub parts Rebuilding every chapter through ebooklib's EpubHtml regenerated the and dropped all stylesheet links, so books from sources that deliver epubs in parts (Nextory) rendered completely unstyled. Store content documents byte-for-byte instead and only generate the packaging. Also fixes: text/html spine media-types, part packaging (mimetype, META-INF, opf/ncx) and zip directory entries leaking into the manifest, toc entries never matching srcs with directory prefixes, missing dc:title/creator/language, unflagged cover image, and a leftover exit() that killed the process after the first book. Co-Authored-By: Claude Fable 5 --- grawlix/output/epub.py | 124 ++++++++++++++++++++++++++--------------- 1 file changed, 80 insertions(+), 44 deletions(-) diff --git a/grawlix/output/epub.py b/grawlix/output/epub.py index a73a23e..4e79dd1 100644 --- a/grawlix/output/epub.py +++ b/grawlix/output/epub.py @@ -84,55 +84,91 @@ class Epub(OutputFormat): async def _download_epub_in_parts(self, data: EpubInParts, metadata: Metadata, location: str, update: Update) -> None: files = data.files - file_count = len(files) - progress = 1/(file_count) + progress = 1/len(files) temporary_file_location = f"{location}.tmp" - added_files: set[str] = set() - def get_new_files(zipfile: ZipFile): - """Returns files in zipfile not already added to file""" - for filename in zipfile.namelist(): - if filename in added_files or filename.endswith(".opf") or filename.endswith(".ncx"): - continue - yield filename + # First occurrence wins; parts arrive in reading order and share most + # files, so insertion order doubles as spine order. + collected: dict[str, bytes] = {} + try: + for file in files: + await self._download_and_write_file(file, temporary_file_location) + with ZipFile(temporary_file_location, "r") as zipfile: + for filepath in zipfile.namelist(): + if filepath.endswith("/") or filepath in collected or self._is_part_packaging(filepath): + continue + collected[filepath] = zipfile.read(filepath) + if update: + update(progress) + finally: + if os.path.exists(temporary_file_location): + os.remove(temporary_file_location) + self._build_epub_from_files(collected, data.files_in_toc, metadata, location) + + + @staticmethod + def _is_part_packaging(filename: str) -> bool: + """ + True for files that belong to a part's own epub packaging. Each part is + a complete epub, so copying these into the merged book would leave + stray mimetype/container/package files next to the generated ones. + """ + lowered = filename.lower() + return ( + lowered == "mimetype" + or lowered.startswith("meta-inf/") + or lowered.endswith((".opf", ".ncx")) + ) + + + @staticmethod + def _build_epub_from_files( + files: dict[str, bytes], + files_in_toc: dict[str, str], + metadata: Metadata, + location: str, + ) -> None: + """ + Assemble an epub from the raw files collected from its parts. Content + documents are stored byte-for-byte as delivered by the source, so their + stylesheet links, language attributes and markup survive; only the + packaging (opf, ncx, nav) is generated. + """ output = epub.EpubBook() - for file in files: - await self._download_and_write_file(file, temporary_file_location) - with ZipFile(temporary_file_location, "r") as zipfile: - for filepath in get_new_files(zipfile): - content = zipfile.read(filepath) - if filepath.endswith("html"): - filename = os.path.basename(filepath) - is_in_toc = False - title = None - for key, value in data.files_in_toc.items(): - toc_filename = key.split("#")[0] - if filename == toc_filename: - title = value - is_in_toc = True - break - epub_file = epub.EpubHtml( - title = title, - file_name = filepath, - content = content - ) - output.add_item(epub_file) - output.spine.append(epub_file) - if is_in_toc: - output.toc.append(epub_file) - else: - epub_file = epub.EpubItem( - file_name = filepath, - content = content - ) - output.add_item(epub_file) - added_files.add(filepath) - if update: - update(progress) - os.remove(temporary_file_location) + output.set_title(metadata.title) + if metadata.language: + output.set_language(metadata.language) + for author in metadata.authors: + output.add_author(author) + + # Toc keys may or may not carry directory prefixes and fragments + # ("Text/chap01.html#nav_1"); compare by basename like the spine files. + toc_titles = { + os.path.basename(key.split("#")[0]): title + for key, title in files_in_toc.items() + } + cover_flagged = False + for filepath, content in files.items(): + is_content_document = filepath.lower().endswith((".html", ".xhtml", ".htm")) + item = epub.EpubItem( + file_name = filepath, + # Empty media type makes ebooklib guess from the extension + media_type = "application/xhtml+xml" if is_content_document else "", + content = content, + ) + output.add_item(item) + if is_content_document: + output.spine.append(item) + title = toc_titles.get(os.path.basename(filepath)) + if title is not None: + output.toc.append(epub.Link(filepath, title, uid=item.get_id())) + elif not cover_flagged and os.path.basename(filepath).lower().startswith("cover") \ + and item.media_type.startswith("image/"): + item.properties = ["cover-image"] + output.add_metadata(None, "meta", "", {"name": "cover", "content": item.get_id()}) + cover_flagged = True output.add_item(epub.EpubNcx()) output.add_item(epub.EpubNav()) epub.write_epub(location, output) - exit()