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\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"