diff --git a/packages/markitdown/src/markitdown/converters/_epub_converter.py b/packages/markitdown/src/markitdown/converters/_epub_converter.py
index 224ea7202..0ad1cd2c2 100644
--- a/packages/markitdown/src/markitdown/converters/_epub_converter.py
+++ b/packages/markitdown/src/markitdown/converters/_epub_converter.py
@@ -1,9 +1,11 @@
import os
+import posixpath
import zipfile
+from urllib.parse import unquote
from defusedxml import minidom
from xml.dom.minidom import Document
-from typing import BinaryIO, Any, Dict, List
+from typing import BinaryIO, Any, Dict, List, Set
from ._html_converter import HtmlConverter
from .._base_converter import DocumentConverterResult
@@ -91,8 +93,9 @@ def convert(
base_path = "/".join(
opf_path.split("/")[:-1]
) # Get base directory of content.opf
+ zip_names = set(z.namelist())
spine = [
- f"{base_path}/{manifest[item_id]}" if base_path else manifest[item_id]
+ self._resolve_manifest_href(manifest[item_id], base_path, zip_names)
for item_id in spine_order
if item_id in manifest
]
@@ -130,6 +133,29 @@ def convert(
markdown="\n\n".join(markdown_content), title=metadata["title"]
)
+ def _resolve_manifest_href(
+ self, href: str, base_path: str, zip_names: Set[str]
+ ) -> str:
+ """Resolve a manifest href to the matching ZIP entry name.
+
+ Manifest hrefs are URI references relative to the OPF, so reserved
+ characters such as spaces arrive percent-encoded, while ZIP entry names
+ are not encoded. Prefer the decoded form, but fall back to the raw href
+ so archives that store a literally-encoded name still resolve.
+ """
+ candidates: List[str] = []
+ for candidate in (unquote(href), href):
+ resolved = posixpath.join(base_path, candidate) if base_path else candidate
+ resolved = posixpath.normpath(resolved)
+ if resolved not in candidates:
+ candidates.append(resolved)
+
+ for candidate in candidates:
+ if candidate in zip_names:
+ return candidate
+
+ return candidates[0]
+
def _get_text_from_node(self, dom: Document, tag_name: str) -> str | None:
"""Convenience function to extract a single occurrence of a tag (e.g., title)."""
texts = self._get_all_texts_from_nodes(dom, tag_name)
diff --git a/packages/markitdown/tests/test_epub_converter.py b/packages/markitdown/tests/test_epub_converter.py
new file mode 100644
index 000000000..ac8edd320
--- /dev/null
+++ b/packages/markitdown/tests/test_epub_converter.py
@@ -0,0 +1,115 @@
+import io
+import zipfile
+
+from markitdown import StreamInfo
+from markitdown.converters import EpubConverter
+
+CONTAINER_XML = """
+
{body}
+ +""" + + +def _build_epub(manifest_items, spine_ids, documents) -> io.BytesIO: + """Assemble a minimal EPUB from manifest entries and ZIP member names.""" + manifest = "\n".join( + f'