Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 28 additions & 7 deletions packages/markitdown/src/markitdown/converters/_epub_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import zipfile
from urllib.parse import unquote
from defusedxml import minidom
from xml.dom.minidom import Document
from xml.dom.minidom import Document, Element

from typing import BinaryIO, Any, Dict, List, Set

Expand All @@ -19,6 +19,10 @@

ACCEPTED_FILE_EXTENSIONS = [".epub"]

CONTAINER_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:container"
OPF_NAMESPACE = "http://www.idpf.org/2007/opf"
DC_NAMESPACE = "http://purl.org/dc/elements/1.1/"

MIME_TYPE_MAPPING = {
".html": "text/html",
".xhtml": "application/xhtml+xml",
Expand Down Expand Up @@ -63,9 +67,9 @@ def convert(

# Locate content.opf
container_dom = minidom.parse(z.open("META-INF/container.xml"))
opf_path = container_dom.getElementsByTagName("rootfile")[0].getAttribute(
"full-path"
)
opf_path = self._get_elements(
container_dom, CONTAINER_NAMESPACE, "rootfile"
)[0].getAttribute("full-path")

# Parse content.opf
opf_dom = minidom.parse(z.open(opf_path))
Expand All @@ -82,11 +86,11 @@ def convert(
# Extract manifest items (ID → href mapping)
manifest = {
item.getAttribute("id"): item.getAttribute("href")
for item in opf_dom.getElementsByTagName("item")
for item in self._get_elements(opf_dom, OPF_NAMESPACE, "item")
}

# Extract spine order (ID refs)
spine_items = opf_dom.getElementsByTagName("itemref")
spine_items = self._get_elements(opf_dom, OPF_NAMESPACE, "itemref")
spine_order = [item.getAttribute("idref") for item in spine_items]

# Convert spine order to actual file paths
Expand Down Expand Up @@ -156,6 +160,21 @@ def _resolve_manifest_href(

return candidates[0]

def _get_elements(
self,
dom: Document,
namespace: str,
local_name: str,
legacy_name: str | None = None,
) -> List[Element]:
# Match namespace URIs, not the arbitrary prefix chosen by the author.
nodes = list(dom.getElementsByTagNameNS(namespace, local_name))
if nodes:
return nodes
# Preserve the original tag-name lookup for legacy/malformed namespace
# URIs, but only when there are no elements in the standard namespace.
return list(dom.getElementsByTagName(legacy_name or local_name))

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)
Expand All @@ -167,7 +186,9 @@ def _get_text_from_node(self, dom: Document, tag_name: str) -> str | None:
def _get_all_texts_from_nodes(self, dom: Document, tag_name: str) -> List[str]:
"""Helper function to extract all occurrences of a tag (e.g., multiple authors)."""
texts: List[str] = []
for node in dom.getElementsByTagName(tag_name):
for node in self._get_elements(
dom, DC_NAMESPACE, tag_name.removeprefix("dc:"), legacy_name=tag_name
):
text_parts: List[str] = []
self._collect_node_text(node, text_parts)
text_val = "".join(text_parts).strip()
Expand Down
99 changes: 99 additions & 0 deletions packages/markitdown/tests/test_epub.py
Original file line number Diff line number Diff line change
Expand Up @@ -219,5 +219,104 @@ def test_epub_metadata_and_text_are_unchanged() -> None:
assert "Chapter text." in result.markdown


@pytest.mark.parametrize("prefixed_part", ["container", "package", "metadata"])
def test_namespace_prefixes_do_not_change_epub_conversion(prefixed_part):
original = _build_epub(
[("c1", "one.xhtml"), ("c2", "two.xhtml")],
["c2", "c1"],
{
"OEBPS/one.xhtml": ("First", "FIRST_BODY"),
"OEBPS/two.xhtml": ("Second", "SECOND_BODY"),
},
)
archive = io.BytesIO()
with zipfile.ZipFile(original) as source, zipfile.ZipFile(archive, "w") as target:
for entry in source.infolist():
content = source.read(entry)
if (
prefixed_part == "container"
and entry.filename == "META-INF/container.xml"
):
text = content.decode().replace("xmlns=", "xmlns:ocf=")
for tag in ("container", "rootfiles", "rootfile"):
text = text.replace(f"<{tag}", f"<ocf:{tag}").replace(
f"</{tag}", f"</ocf:{tag}"
)
content = text.encode()
elif entry.filename == "OEBPS/content.opf":
text = content.decode()
if prefixed_part == "package":
text = text.replace("xmlns=", "xmlns:opf=")
for tag in (
"package",
"metadata",
"manifest",
"spine",
"itemref",
"item",
):
text = text.replace(f"<{tag}", f"<opf:{tag}").replace(
f"</{tag}", f"</opf:{tag}"
)
elif prefixed_part == "metadata":
text = (
text.replace("xmlns:dc=", "xmlns:book=")
.replace("<dc:", "<book:")
.replace("</dc:", "</book:")
)
content = text.encode()
target.writestr(entry, content)
archive.seek(0)
result = EpubConverter().convert(archive, StreamInfo(extension=".epub"))
assert result.title == "Encoded Hrefs"
text = result.markdown.replace("\\", "")
assert "SECOND_BODY" in text and "FIRST_BODY" in text
assert text.index("SECOND_BODY") < text.index("FIRST_BODY")


@pytest.mark.parametrize(
"original_namespace,replacement_namespace",
[
(
"http://www.idpf.org/2007/opf",
"http://openebook.org/namespaces/oeb-package/1.0/",
),
("http://purl.org/dc/elements/1.1/", "http://purl.org/dc/elements/1.0/"),
("http://purl.org/dc/elements/1.1/", "http://purl.org/dc/elements/1.1"),
("urn:oasis:names:tc:opendocument:xmlns:container", "urn:legacy:container"),
("urn:oasis:names:tc:opendocument:xmlns:container", ""),
("http://www.idpf.org/2007/opf", ""),
],
)
def test_legacy_namespace_uris_keep_metadata_and_chapters(
original_namespace, replacement_namespace
):
original = _build_epub(
[("c1", "one.xhtml")],
["c1"],
{"OEBPS/one.xhtml": ("First", "CHAPTER_BODY")},
)
archive = io.BytesIO()
with zipfile.ZipFile(original) as source, zipfile.ZipFile(archive, "w") as target:
for entry in source.infolist():
content = source.read(entry)
if entry.filename in ("META-INF/container.xml", "OEBPS/content.opf"):
text = content.decode().replace(
original_namespace, replacement_namespace
)
if entry.filename == "OEBPS/content.opf":
text = text.replace(
"</metadata>",
"<dc:creator>Example Author</dc:creator></metadata>",
)
content = text.encode()
target.writestr(entry, content)
archive.seek(0)
result = EpubConverter().convert(archive, StreamInfo(extension=".epub"))
assert result.title == "Encoded Hrefs"
assert "**Authors:** Example Author" in result.markdown
assert "CHAPTER_BODY" in result.markdown.replace("\\", "")


if __name__ == "__main__":
raise SystemExit(pytest.main([__file__]))