From 0ec60fff2351d0dd028ab57a5433f6ae6eceb382 Mon Sep 17 00:00:00 2001 From: AC Date: Thu, 8 Oct 2026 11:53:29 +0800 Subject: [PATCH 1/2] fix(epub): resolve XML elements by namespace instead of prefix --- .../markitdown/converters/_epub_converter.py | 32 ++++++++--- packages/markitdown/tests/test_epub.py | 55 +++++++++++++++++++ 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_epub_converter.py b/packages/markitdown/src/markitdown/converters/_epub_converter.py index 0ad1cd2c21..146758fcc5 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 @@ -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", @@ -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)) @@ -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 @@ -156,6 +160,20 @@ def _resolve_manifest_href( return candidates[0] + def _get_elements( + self, dom: Document, namespace: str, local_name: str + ) -> List[Element]: + # Match namespace URIs, not the arbitrary prefix chosen by the author. + nodes = list(dom.getElementsByTagNameNS(namespace, local_name)) + if nodes: + return nodes + # Retain support for older files that omit the container/OPF namespace. + return [ + node + for node in dom.getElementsByTagName(local_name) + if node.namespaceURI is 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,7 @@ 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:")): 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.py b/packages/markitdown/tests/test_epub.py index f87f4d4437..7fe4ac3233 100644 --- a/packages/markitdown/tests/test_epub.py +++ b/packages/markitdown/tests/test_epub.py @@ -221,3 +221,58 @@ def test_epub_metadata_and_text_are_unchanged() -> None: if __name__ == "__main__": raise SystemExit(pytest.main([__file__])) + + +@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" Date: Sun, 11 Oct 2026 12:10:45 +0800 Subject: [PATCH 2/2] fix: preserve legacy EPUB namespace fallback --- .../markitdown/converters/_epub_converter.py | 19 ++++--- packages/markitdown/tests/test_epub.py | 52 +++++++++++++++++-- 2 files changed, 59 insertions(+), 12 deletions(-) diff --git a/packages/markitdown/src/markitdown/converters/_epub_converter.py b/packages/markitdown/src/markitdown/converters/_epub_converter.py index 146758fcc5..48d7f3d7c9 100644 --- a/packages/markitdown/src/markitdown/converters/_epub_converter.py +++ b/packages/markitdown/src/markitdown/converters/_epub_converter.py @@ -161,18 +161,19 @@ def _resolve_manifest_href( return candidates[0] def _get_elements( - self, dom: Document, namespace: str, local_name: str + 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 - # Retain support for older files that omit the container/OPF namespace. - return [ - node - for node in dom.getElementsByTagName(local_name) - if node.namespaceURI is None - ] + # 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).""" @@ -185,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 self._get_elements(dom, DC_NAMESPACE, tag_name.removeprefix("dc:")): + 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() diff --git a/packages/markitdown/tests/test_epub.py b/packages/markitdown/tests/test_epub.py index 7fe4ac3233..d7eeeb0af5 100644 --- a/packages/markitdown/tests/test_epub.py +++ b/packages/markitdown/tests/test_epub.py @@ -219,10 +219,6 @@ def test_epub_metadata_and_text_are_unchanged() -> None: assert "Chapter text." in result.markdown -if __name__ == "__main__": - raise SystemExit(pytest.main([__file__])) - - @pytest.mark.parametrize("prefixed_part", ["container", "package", "metadata"]) def test_namespace_prefixes_do_not_change_epub_conversion(prefixed_part): original = _build_epub( @@ -276,3 +272,51 @@ def test_namespace_prefixes_do_not_change_epub_conversion(prefixed_part): 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( + "", + "Example Author", + ) + 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__]))