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\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\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\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()