Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 9 additions & 10 deletions docling/backend/html_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
44 changes: 36 additions & 8 deletions docling/backend/utils/image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:", "#")):
Expand Down
5 changes: 4 additions & 1 deletion docling/backend/xml/jats_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
32 changes: 32 additions & 0 deletions tests/test_backend_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
32 changes: 32 additions & 0 deletions tests/test_backend_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading