Skip to content
Merged
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
9 changes: 8 additions & 1 deletion docling/backend/md_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -1128,7 +1128,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
Expand Down
16 changes: 16 additions & 0 deletions docling/backend/utils/image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -310,6 +311,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()

Expand All @@ -320,6 +335,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:", "#")):
Expand Down
29 changes: 29 additions & 0 deletions tests/test_backend_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -1492,6 +1492,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('<img src="figure.png">', 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"<html><body><p>Initial content with some <strong>bold text</strong></p>"
Expand Down
98 changes: 98 additions & 0 deletions tests/test_backend_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
66 changes: 66 additions & 0 deletions tests/test_image_resource_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,17 @@
import base64
import socket
import struct
import sys
import threading
import zlib
from io import BytesIO
from pathlib import Path
from urllib.parse import quote

import pytest
from PIL import Image

from docling.backend.utils import image_resource_loader
from docling.backend.utils.image_resource_loader import (
ImageResourceLoader,
validate_url_safety,
Expand Down Expand Up @@ -249,6 +252,69 @@ def test_load_image_data_local_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_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()

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://")


def _png_header_data_uri(width: int, height: int) -> str:
"""A PNG that declares ``width`` x ``height`` pixels but has no pixel data."""

Expand Down
Loading