diff --git a/packages/markitdown/src/markitdown/converters/_epub_converter.py b/packages/markitdown/src/markitdown/converters/_epub_converter.py index 0ad1cd2c2..04a74503d 100644 --- a/packages/markitdown/src/markitdown/converters/_epub_converter.py +++ b/packages/markitdown/src/markitdown/converters/_epub_converter.py @@ -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 @@ -24,6 +24,10 @@ ".xhtml": "application/xhtml+xml", } +CONTAINER_NAMESPACE = "urn:oasis:names:tc:opendocument:xmlns:container" +PACKAGE_NAMESPACE = "http://www.idpf.org/2007/opf" +DC_NAMESPACE = "http://purl.org/dc/elements/1.1/" + class EpubConverter(HtmlConverter): """ @@ -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_package_elements( + container_dom, CONTAINER_NAMESPACE, "rootfile" + )[0].getAttribute("full-path") # Parse content.opf opf_dom = minidom.parse(z.open(opf_path)) @@ -82,11 +86,15 @@ 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_package_elements( + opf_dom, PACKAGE_NAMESPACE, "item" + ) } # Extract spine order (ID refs) - spine_items = opf_dom.getElementsByTagName("itemref") + spine_items = self._get_package_elements( + opf_dom, PACKAGE_NAMESPACE, "itemref" + ) spine_order = [item.getAttribute("idref") for item in spine_items] # Convert spine order to actual file paths @@ -156,6 +164,16 @@ def _resolve_manifest_href( return candidates[0] + def _get_package_elements( + self, dom: Document, namespace: str, local_name: str + ) -> List[Element]: + # Keep accepting unqualified elements from older, non-conforming files. + return [ + node + for node in dom.getElementsByTagNameNS("*", local_name) + if node.namespaceURI in (namespace, None) + ] + 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) @@ -167,7 +185,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 dom.getElementsByTagNameNS( + DC_NAMESPACE, tag_name.removeprefix("dc:") + ): text_parts: List[str] = [] self._collect_node_text(node, text_parts) text_val = "".join(text_parts).strip() diff --git a/packages/markitdown/tests/test_epub_converter.py b/packages/markitdown/tests/test_epub_converter.py index ac8edd320..63e432df6 100644 --- a/packages/markitdown/tests/test_epub_converter.py +++ b/packages/markitdown/tests/test_epub_converter.py @@ -1,7 +1,9 @@ import io import zipfile -from markitdown import StreamInfo +import pytest + +from markitdown import MarkItDown, StreamInfo from markitdown.converters import EpubConverter CONTAINER_XML = """ @@ -18,27 +20,57 @@ """ -def _build_epub(manifest_items, spine_ids, documents) -> io.BytesIO: +def _build_epub( + manifest_items, + spine_ids, + documents, + *, + container_prefix="", + package_prefix="", + dc_prefix="dc", +) -> io.BytesIO: """Assemble a minimal EPUB from manifest entries and ZIP member names.""" + package_xmlns = f"xmlns:{package_prefix}" if package_prefix else "xmlns" + dc_xmlns = f"xmlns:{dc_prefix}" if dc_prefix else "xmlns" + package_tag = f"{package_prefix}:" if package_prefix else "" + dc_tag = f"{dc_prefix}:" if dc_prefix else "" manifest = "\n".join( - f'' + f'<{package_tag}item id="{item_id}" href="{href}" media-type="application/xhtml+xml"/>' for item_id, href in manifest_items ) - spine = "\n".join(f'' for item_id in spine_ids) + spine = "\n".join( + f'<{package_tag}itemref idref="{item_id}"/>' for item_id in spine_ids + ) opf = f""" - - - Encoded Hrefs - - {manifest} - {spine} - +<{package_tag}package {package_xmlns}="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="id"> + <{package_tag}metadata {dc_xmlns}="http://purl.org/dc/elements/1.1/"> + <{dc_tag}title>Encoded Hrefs + <{dc_tag}creator>First Author + <{dc_tag}creator>Second Author + + <{package_tag}manifest>{manifest} + <{package_tag}spine>{spine} + """ + container_xml = CONTAINER_XML + if container_prefix: + container_xml = container_xml.replace("xmlns=", f"xmlns:{container_prefix}=") + for tag in ("container", "rootfiles", "rootfile"): + container_xml = container_xml.replace( + f"<{tag} ", f"<{container_prefix}:{tag} " + ) + container_xml = container_xml.replace( + f"<{tag}>", f"<{container_prefix}:{tag}>" + ) + container_xml = container_xml.replace( + f"", f"" + ) + buffer = io.BytesIO() with zipfile.ZipFile(buffer, "w") as z: z.writestr("mimetype", "application/epub+zip") - z.writestr("META-INF/container.xml", CONTAINER_XML) + z.writestr("META-INF/container.xml", container_xml) z.writestr("OEBPS/content.opf", opf) for name, (title, body) in documents.items(): z.writestr(name, CHAPTER_XHTML.format(title=title, body=body)) @@ -107,6 +139,96 @@ def test_parent_relative_href_resolves() -> None: assert "SHARED_BODY" in _convert(stream) +@pytest.mark.parametrize("public_api", [False, True]) +@pytest.mark.parametrize( + "container_prefix, package_prefix, dc_prefix", + [ + ("", "", "dc"), + ("ocf", "", "dc"), + ("", "opf", "dc"), + ("", "", "meta"), + ("ocf", "opf", ""), + ], +) +def test_namespace_prefixes_preserve_metadata_and_spine_order( + public_api, container_prefix, package_prefix, dc_prefix +) -> None: + stream = _build_epub( + manifest_items=[("c1", "first.xhtml"), ("c2", "second.xhtml")], + spine_ids=["c2", "c1"], + documents={ + "OEBPS/first.xhtml": ("First", "First chapter body"), + "OEBPS/second.xhtml": ("Second", "Second chapter body"), + }, + container_prefix=container_prefix, + package_prefix=package_prefix, + dc_prefix=dc_prefix, + ) + stream_info = StreamInfo(mimetype="application/epub+zip", extension=".epub") + if public_api: + result = MarkItDown().convert_stream(stream, stream_info=stream_info) + else: + result = EpubConverter().convert(stream, stream_info) + + assert result.title == "Encoded Hrefs" + assert "**Authors:** First Author, Second Author" in result.markdown + assert "First chapter body" in result.markdown + assert "Second chapter body" in result.markdown + assert result.markdown.index("Second chapter body") < result.markdown.index( + "First chapter body" + ) + + +@pytest.mark.parametrize("unqualified", [False, True]) +def test_package_elements_ignore_foreign_namespaces(unqualified) -> None: + stream = _build_epub( + manifest_items=[("c1", "chapter.xhtml")], + spine_ids=["c1"], + documents={"OEBPS/chapter.xhtml": ("Chapter", "Expected chapter body")}, + ) + modified = io.BytesIO() + with zipfile.ZipFile(stream) as source, zipfile.ZipFile(modified, "w") as target: + for name in source.namelist(): + data = source.read(name) + if name == "META-INF/container.xml": + data = data.replace( + b"", + b'', + ) + if unqualified: + data = data.replace( + b' xmlns="urn:oasis:names:tc:opendocument:xmlns:container"', b"" + ) + elif name == "OEBPS/content.opf": + data = ( + data.replace( + b"", + b'', + ) + .replace( + b"", + b'', + ) + .replace( + b"", + b'Wrong title', + ) + ) + if unqualified: + data = data.replace(b' xmlns="http://www.idpf.org/2007/opf"', b"") + target.writestr(name, data) + modified.seek(0) + + result = MarkItDown().convert_stream( + modified, stream_info=StreamInfo(extension=".epub") + ) + assert result.title == "Encoded Hrefs" + assert result.markdown.count("Expected chapter body") == 1 + assert "Wrong title" not in result.markdown + + if __name__ == "__main__": test_percent_encoded_href_resolves_to_zip_entry() test_non_ascii_percent_encoded_href_resolves()