From 9591f6fef36fec49fc8f04ab6c1de50a6fca499f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8D=E7=A9=B9=E5=A4=96=E5=8D=96-01?= <2046083414@qq.com> Date: Tue, 20 Oct 2026 11:03:10 +0800 Subject: [PATCH 1/2] fix: accept file URI bases for image resources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 苍穹外卖-01 <2046083414@qq.com> --- docling/backend/md_backend.py | 9 +- .../backend/utils/image_resource_loader.py | 16 +++ tests/test_backend_html.py | 29 ++++++ tests/test_backend_markdown.py | 98 +++++++++++++++++++ tests/test_image_resource_loader.py | 46 +++++++++ 5 files changed, 197 insertions(+), 1 deletion(-) diff --git a/docling/backend/md_backend.py b/docling/backend/md_backend.py index 99cbcaf23a..b70f80179a 100644 --- a/docling/backend/md_backend.py +++ b/docling/backend/md_backend.py @@ -1127,7 +1127,14 @@ def _load_image_ref(self, dest: str) -> ImageRef | None: base_path = ( str(md_options.source_uri) if md_options.source_uri is not None else None ) - return self._get_image_loader().load_image_ref(dest, base_path) + try: + return self._get_image_loader().load_image_ref(dest, base_path) + except ValueError as exc: + # Keep the document usable when a relative image escapes its local + # source directory. The image is omitted, while the loader still + # enforces the confinement check. + warnings.warn(f"Could not process an image from {dest}: {exc}") + return None def is_valid(self) -> bool: return self.valid diff --git a/docling/backend/utils/image_resource_loader.py b/docling/backend/utils/image_resource_loader.py index 5d498761a9..dfa67d796b 100644 --- a/docling/backend/utils/image_resource_loader.py +++ b/docling/backend/utils/image_resource_loader.py @@ -23,6 +23,7 @@ from pathlib import Path from typing import Optional, Union from urllib.parse import urljoin, urlparse, urlsplit +from urllib.request import url2pathname import certifi import requests @@ -306,6 +307,20 @@ def is_absolute_path(loc: str) -> bool: and not parsed_loc.netloc ) + @staticmethod + def _local_base_path(value: str) -> str: + """Return a filesystem path for a local path or ``file://`` URI.""" + parsed = urlparse(value) + if parsed.scheme.lower() != "file": + return value + + path = parsed.path + if parsed.netloc and parsed.netloc.lower() != "localhost": + path = f"//{parsed.netloc}{path}" + if not path: + raise ValueError("Invalid file URI: empty path") + return url2pathname(path) + def resolve_relative_path(self, loc: str, base_path: Optional[str]) -> str: loc = loc.strip() @@ -316,6 +331,7 @@ def resolve_relative_path(self, loc: str, base_path: Optional[str]) -> str: abs_loc = loc if base_path: + base_path = self._local_base_path(base_path) if loc.startswith("//"): abs_loc = "https:" + loc elif not loc.startswith(("http://", "https://", "data:", "#")): diff --git a/tests/test_backend_html.py b/tests/test_backend_html.py index a1818b2cb5..319349f71b 100644 --- a/tests/test_backend_html.py +++ b/tests/test_backend_html.py @@ -1491,6 +1491,35 @@ def test_e2e_html_conversion_with_images(mock_local, monkeypatch): assert verify_document(res_local.document, gt_path + ".json", GENERATE) +def test_local_file_uri_source_resolves_images(tmp_path: Path): + """A file URI source must resolve relative HTML images locally.""" + image_path = tmp_path / "figure.png" + image_path.write_bytes(PNG_1X1) + html_path = tmp_path / "doc.html" + html_path.write_text('', encoding="utf-8") + + converter = DocumentConverter( + allowed_formats=[InputFormat.HTML], + format_options={ + InputFormat.HTML: HTMLFormatOption( + backend_options=HTMLBackendOptions( + fetch_images=True, + enable_local_fetch=True, + source_uri=html_path.as_uri(), + ) + ) + }, + ) + + doc = converter.convert(html_path).document + + pictures = [ + item for item, _ in doc.iterate_items() if isinstance(item, PictureItem) + ] + assert len(pictures) == 1 + assert pictures[0].get_image(doc) is not None + + def test_html_furniture(): raw_html = ( b"

Initial content with some bold text

" diff --git a/tests/test_backend_markdown.py b/tests/test_backend_markdown.py index 95bbaecc55..f766e58833 100644 --- a/tests/test_backend_markdown.py +++ b/tests/test_backend_markdown.py @@ -300,6 +300,104 @@ 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_accepts_file_uri_source(tmp_path: Path): + """A file URI source must resolve relative images like a local path.""" + source_dir = tmp_path / "with space" + source_dir.mkdir() + image_path = source_dir / "figure.png" + Image.new("RGB", (7, 5), color=(0, 255, 0)).save(image_path) + md_path = source_dir / "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_local_file_uri_source_respects_local_fetch(tmp_path: Path): + """A file URI base does not enable local image fetching by itself.""" + 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=False, + source_uri=md_path.as_uri(), + ) + ) + }, + ) + + with pytest.warns(UserWarning, match="Fetching local resources"): + doc = converter.convert(md_path).document + + pictures = [ + item for item, _ in doc.iterate_items() if isinstance(item, PictureItem) + ] + assert len(pictures) == 1 + assert pictures[0].get_image(doc) is None + + +def test_local_file_uri_source_keeps_document_on_path_traversal( + tmp_path: Path, +): + """A path-traversal image must not abort Markdown conversion.""" + source_dir = tmp_path / "docs" + source_dir.mkdir() + outside_image = tmp_path / "outside.png" + Image.new("RGB", (7, 5), color=(0, 255, 0)).save(outside_image) + md_path = source_dir / "doc.md" + md_path.write_text("# Title\n\n![alt](../outside.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(), + ) + ) + }, + ) + + with pytest.warns(UserWarning, match="Path traversal blocked"): + doc = converter.convert(md_path).document + + pictures = [ + item for item, _ in doc.iterate_items() if isinstance(item, PictureItem) + ] + assert len(pictures) == 1 + assert pictures[0].get_image(doc) is None + + def test_code_block_language_detection(): markdown = ( "```python\n" diff --git a/tests/test_image_resource_loader.py b/tests/test_image_resource_loader.py index 37ba0d6859..30727bd086 100644 --- a/tests/test_image_resource_loader.py +++ b/tests/test_image_resource_loader.py @@ -5,6 +5,7 @@ import socket import threading +from pathlib import Path from urllib.parse import quote import pytest @@ -242,3 +243,48 @@ def test_load_image_data_local_requires_base_path(): loader = ImageResourceLoader(enable_local_fetch=True) with pytest.raises(OperationNotAllowed, match="requires base_path"): loader.load_image_data("/some/where/image.png", None) + + +def test_resolve_relative_path_accepts_file_uri_base(tmp_path): + loader = ImageResourceLoader() + base_path = (tmp_path / "doc.md").as_uri() + + resolved = loader.resolve_relative_path("figure.png", base_path) + + assert Path(resolved) == (tmp_path / "figure.png").resolve() + + +def test_resolve_relative_path_accepts_localhost_file_uri(tmp_path): + loader = ImageResourceLoader() + base_path = ( + (tmp_path / "doc.md").as_uri().replace("file:///", "file://localhost/", 1) + ) + + resolved = loader.resolve_relative_path("figure.png", base_path) + + assert Path(resolved) == (tmp_path / "figure.png").resolve() + + +def test_resolve_relative_path_accepts_unc_file_uri(): + loader = ImageResourceLoader() + + resolved = loader.resolve_relative_path( + "figure.png", "file://server/share/docs/doc.md" + ) + + assert Path(resolved) == Path("//server/share/docs/figure.png") + + +def test_resolve_relative_path_keeps_file_uri_confinement(tmp_path): + loader = ImageResourceLoader() + base_path = (tmp_path / "doc.md").as_uri() + + with pytest.raises(ValueError, match="Path traversal blocked"): + loader.resolve_relative_path("../outside.png", base_path) + + +def test_resolve_relative_path_rejects_empty_file_uri(): + loader = ImageResourceLoader() + + with pytest.raises(ValueError, match="empty path"): + loader.resolve_relative_path("figure.png", "file://") From cc1f6fa6828118856950d7d30d1fd2f7bdd398af Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=8B=8D=E7=A9=B9=E5=A4=96=E5=8D=96-01?= <2046083414@qq.com> Date: Wed, 21 Oct 2026 19:04:42 +0800 Subject: [PATCH 2/2] test: make UNC file URI checks platform aware MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: 苍穹外卖-01 <2046083414@qq.com> --- tests/test_image_resource_loader.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_image_resource_loader.py b/tests/test_image_resource_loader.py index 30727bd086..5296bf5c6c 100644 --- a/tests/test_image_resource_loader.py +++ b/tests/test_image_resource_loader.py @@ -4,12 +4,14 @@ """Unit tests for the shared image-resource loader and its safety limits.""" import socket +import sys import threading from pathlib import Path from urllib.parse import quote import pytest +from docling.backend.utils import image_resource_loader from docling.backend.utils.image_resource_loader import ( ImageResourceLoader, validate_url_safety, @@ -265,6 +267,24 @@ def test_resolve_relative_path_accepts_localhost_file_uri(tmp_path): assert Path(resolved) == (tmp_path / "figure.png").resolve() +def test_local_base_path_normalizes_unc_authority(monkeypatch): + converted_paths: list[str] = [] + + def convert_path(path: str) -> str: + converted_paths.append(path) + return "converted-unc-path" + + monkeypatch.setattr(image_resource_loader, "url2pathname", convert_path) + + result = ImageResourceLoader._local_base_path("file://server/share/docs/doc.md") + + assert converted_paths == ["//server/share/docs/doc.md"] + assert result == "converted-unc-path" + + +@pytest.mark.skipif( + sys.platform != "win32", reason="UNC filesystem paths require Windows" +) def test_resolve_relative_path_accepts_unc_file_uri(): loader = ImageResourceLoader()