From 55c1b3ea50d01234df784d9161053dec83f81465 Mon Sep 17 00:00:00 2001 From: Taran Mamidala Date: Tue, 20 Oct 2026 13:56:33 -0400 Subject: [PATCH] fix(images): resolve relative images when base path is a file URI Allow file:// URIs as base_path in ImageResourceLoader.resolve_relative_path and normalize them to filesystem paths for directory confinement and local resolution. Support file URI base paths in HTML and JATS figure loading, and add unit tests. Fixes #4911. Signed-off-by: Taran Mamidala --- docling/backend/html_backend.py | 19 ++++---- .../backend/utils/image_resource_loader.py | 44 +++++++++++++++---- docling/backend/xml/jats_backend.py | 5 ++- tests/test_backend_html.py | 32 ++++++++++++++ tests/test_backend_markdown.py | 32 ++++++++++++++ 5 files changed, 113 insertions(+), 19 deletions(-) diff --git a/docling/backend/html_backend.py b/docling/backend/html_backend.py index da645055bc..a1b2fc065f 100644 --- a/docling/backend/html_backend.py +++ b/docling/backend/html_backend.py @@ -722,14 +722,8 @@ def _decode_mhtml_html(cls, part: Message) -> bytes: def _mhtml_local_path(value: str) -> str | None: """Return a filesystem spelling for local paths and file URIs.""" value = value.strip() - parsed = urlparse(value) - if parsed.scheme.lower() == "file": - path = unquote(parsed.path) - if parsed.netloc and parsed.netloc.lower() != "localhost": - path = f"//{parsed.netloc}{path}" - if re.match(r"^/[A-Za-z]:[/\\]", path): - path = path[1:] - return path + if ImageResourceLoader.is_file_uri(value): + return ImageResourceLoader.file_uri_to_path(value) if ImageResourceLoader.is_local_path(value): return value return None @@ -1057,8 +1051,13 @@ def _get_browser_local_base_path(self) -> Optional[Path]: if isinstance(self.path_or_stream, Path): return self.path_or_stream.resolve() - if self.base_path and ImageResourceLoader.is_local_path(self.base_path): - return Path(self.base_path).resolve() + if self.base_path: + if ImageResourceLoader.is_local_path(self.base_path): + return Path(self.base_path).resolve() + if ImageResourceLoader.is_file_uri(self.base_path): + return Path( + ImageResourceLoader.file_uri_to_path(self.base_path) + ).resolve() return None diff --git a/docling/backend/utils/image_resource_loader.py b/docling/backend/utils/image_resource_loader.py index 5d498761a9..a7b7c7e3eb 100644 --- a/docling/backend/utils/image_resource_loader.py +++ b/docling/backend/utils/image_resource_loader.py @@ -20,9 +20,9 @@ from collections.abc import Iterable, Iterator from dataclasses import dataclass from io import BytesIO -from pathlib import Path +from pathlib import Path, PurePosixPath from typing import Optional, Union -from urllib.parse import urljoin, urlparse, urlsplit +from urllib.parse import unquote, urljoin, urlparse, urlsplit import certifi import requests @@ -300,22 +300,50 @@ def is_local_path(value: str) -> bool: @staticmethod def is_absolute_path(loc: str) -> bool: - return Path(loc).is_absolute() or ( # Windows-specific absolute paths: - len((parsed_loc := urlparse(loc)).scheme) == 1 - and parsed_loc.scheme.isalpha() - and not parsed_loc.netloc + return ( + Path(loc).is_absolute() + or PurePosixPath(loc).is_absolute() + or ( # Windows-specific absolute paths: + len((parsed_loc := urlparse(loc)).scheme) == 1 + and parsed_loc.scheme.isalpha() + and not parsed_loc.netloc + ) ) + @staticmethod + def is_file_uri(value: str) -> bool: + """Check if value is a file URI.""" + parsed = urlparse(value) + return parsed.scheme.lower() == "file" + + @staticmethod + def file_uri_to_path(value: str) -> str: + """Convert a file URI to a local filesystem path.""" + parsed = urlparse(value) + if parsed.scheme.lower() != "file": + return value + path = unquote(parsed.path) + if parsed.netloc and parsed.netloc.lower() != "localhost": + path = f"//{parsed.netloc}{path}" + if re.match(r"^/[A-Za-z]:[/\\]", path): + path = path[1:] + return path + def resolve_relative_path(self, loc: str, base_path: Optional[str]) -> str: loc = loc.strip() - # Strip file:// prefix for validation as local path - if loc.startswith(file_prefix := "file://"): + # Strip file:// prefix or normalize file URI for validation as local path + if ImageResourceLoader.is_file_uri(loc): + loc = ImageResourceLoader.file_uri_to_path(loc) + elif loc.startswith(file_prefix := "file://"): loc = loc[len(file_prefix) :] abs_loc = loc if base_path: + if ImageResourceLoader.is_file_uri(base_path): + base_path = ImageResourceLoader.file_uri_to_path(base_path) + if loc.startswith("//"): abs_loc = "https:" + loc elif not loc.startswith(("http://", "https://", "data:", "#")): diff --git a/docling/backend/xml/jats_backend.py b/docling/backend/xml/jats_backend.py index e1474a1def..e16e7e3eef 100644 --- a/docling/backend/xml/jats_backend.py +++ b/docling/backend/xml/jats_backend.py @@ -965,7 +965,10 @@ def _load_figure_image(self, node: etree._Element) -> ImageRef | None: if ( not self.options.fetch_images or self.base_path is None - or not ImageResourceLoader.is_local_path(self.base_path) + or not ( + ImageResourceLoader.is_local_path(self.base_path) + or ImageResourceLoader.is_file_uri(self.base_path) + ) ): return None diff --git a/tests/test_backend_html.py b/tests/test_backend_html.py index a1818b2cb5..c99981ebb8 100644 --- a/tests/test_backend_html.py +++ b/tests/test_backend_html.py @@ -153,6 +153,38 @@ def test_resolve_relative_path(): assert html_doc._resolve_relative_path("#section1") == "#section1" +def test_resolve_relative_path_file_uri(): + html_path = Path("./tests/data/html/sources/example_01.html") + in_doc = InputDocument( + path_or_stream=html_path, + format=InputFormat.HTML, + backend=HTMLDocumentBackend, + filename="test", + ) + html_doc = HTMLDocumentBackend(path_or_stream=html_path, in_doc=in_doc) + html_doc.base_path = html_path.resolve().as_uri() + + resolved = html_doc._resolve_relative_path("subdir/another.html") + expected = str((html_path.resolve().parent / "subdir/another.html").resolve()) + assert resolved == expected + + # file:// URI base_path blocks directory traversal + with pytest.raises(ValueError, match="Path traversal blocked"): + html_doc._resolve_relative_path("../../../../../../../etc/something") + + # file:// URI base_path blocks absolute paths + with pytest.raises( + ValueError, match="Absolute paths are not allowed with local base_path" + ): + html_doc._resolve_relative_path("file:///etc/something") + + # URL-encoded spaces in file URI base path + html_doc.base_path = "file:///path%20with%20spaces/doc.html" + resolved_spaces = html_doc._resolve_relative_path("image.png") + assert "path with spaces" in resolved_spaces + assert "image.png" in resolved_spaces + + def test_heading_levels(): in_path = Path("tests/data/html/sources/wiki_duck.html") in_doc = InputDocument( diff --git a/tests/test_backend_markdown.py b/tests/test_backend_markdown.py index 95bbaecc55..f330b89763 100644 --- a/tests/test_backend_markdown.py +++ b/tests/test_backend_markdown.py @@ -300,6 +300,38 @@ def test_local_relative_image_resolves_from_document_dir(tmp_path: Path, as_path assert image.getpixel((0, 0)) == (0, 255, 0) +def test_local_relative_image_resolved_with_file_uri_source(tmp_path): + """Relative image resolution must accept a file:// URI as source_uri.""" + image_path = tmp_path / "figure.png" + Image.new("RGB", (7, 5), color=(0, 255, 0)).save(image_path) + md_path = tmp_path / "doc.md" + md_path.write_text("# Title\n\n![alt](figure.png)\n", encoding="utf-8") + + converter = DocumentConverter( + allowed_formats=[InputFormat.MD], + format_options={ + InputFormat.MD: MarkdownFormatOption( + backend_options=MarkdownBackendOptions( + fetch_images=True, + enable_local_fetch=True, + source_uri=md_path.as_uri(), + ) + ) + }, + ) + + doc = converter.convert(md_path).document + + pictures = [ + item for item, _ in doc.iterate_items() if isinstance(item, PictureItem) + ] + assert len(pictures) == 1 + image = pictures[0].get_image(doc) + assert image is not None + assert image.size == (7, 5) + assert image.getpixel((0, 0)) == (0, 255, 0) + + def test_code_block_language_detection(): markdown = ( "```python\n"