Before figure.
Green figure.
After figure.
From 232f7f42f205308119b8af304dae6e1ecec3abcb 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: Mon, 26 Oct 2026 12:33:54 +0800
Subject: [PATCH 1/2] build: align local line checks with CI
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: 苍穹外卖-01 <2046083414@qq.com>
---
Makefile | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/Makefile b/Makefile
index 39f160be4f..5bb9ad42ee 100644
--- a/Makefile
+++ b/Makefile
@@ -17,7 +17,7 @@ check-all: ## Run all read-only local checks.
uv run --no-sync ty check
uv run --no-sync tach check
python3 scripts/check_tach_module_coverage.py
- python3 scripts/check_max_lines.py
+ python3 scripts/check_max_lines.py --max-lines=1500
python3 .github/scripts/check_skill_routes.py
uv run --no-sync dprint check --config .github/dprint.json --config-discovery=false
uv lock --locked
From db3bbb88137f62205a161d4f5b2287a42a8cc151 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: Mon, 26 Oct 2026 12:33:54 +0800
Subject: [PATCH 2/2] fix(images): complete JATS file URI figure loading
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Signed-off-by: 苍穹外卖-01 <2046083414@qq.com>
---
.../backend/utils/image_resource_loader.py | 18 +-
docling/backend/xml/jats_backend.py | 31 +-
docs/usage/advanced_options.md | 18 ++
tests/test_backend_jats_images.py | 299 ++++++++++++++++++
tests/test_image_resource_file_uri.py | 141 +++++++++
tests/test_image_resource_loader.py | 66 ----
6 files changed, 494 insertions(+), 79 deletions(-)
create mode 100644 tests/test_backend_jats_images.py
create mode 100644 tests/test_image_resource_file_uri.py
diff --git a/docling/backend/utils/image_resource_loader.py b/docling/backend/utils/image_resource_loader.py
index df8b739b71..2a68bcfb2b 100644
--- a/docling/backend/utils/image_resource_loader.py
+++ b/docling/backend/utils/image_resource_loader.py
@@ -312,17 +312,25 @@ def is_absolute_path(loc: str) -> bool:
)
@staticmethod
- def _local_base_path(value: str) -> str:
- """Return a filesystem path for a local path or ``file://`` URI."""
+ def normalize_local_base_path(value: str) -> str:
+ """Convert a file URI to a native path; leave other bases unchanged.
+
+ Raise ValueError for an empty path or an unsupported authority.
+ This operation does not grant permission to read local files.
+ """
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")
+ if parsed.netloc and parsed.netloc.lower() != "localhost":
+ if os.name != "nt":
+ raise ValueError(
+ "Non-localhost file URI authorities require Windows UNC paths"
+ )
+ path = f"//{parsed.netloc}{path}"
return url2pathname(path)
def resolve_relative_path(self, loc: str, base_path: Optional[str]) -> str:
@@ -335,7 +343,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)
+ base_path = self.normalize_local_base_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..766acb8f08 100644
--- a/docling/backend/xml/jats_backend.py
+++ b/docling/backend/xml/jats_backend.py
@@ -29,6 +29,7 @@
from io import BytesIO
from pathlib import Path
from typing import Final, cast
+from urllib.parse import urlsplit
from docling_core.types.doc import (
DocItemLabel,
@@ -962,11 +963,21 @@ def _add_figure_captions(
def _load_figure_image(self, node: etree._Element) -> ImageRef | None:
"""Load an explicitly enabled local JATS figure image."""
- if (
- not self.options.fetch_images
- or self.base_path is None
- or not ImageResourceLoader.is_local_path(self.base_path)
- ):
+ if not self.options.fetch_images or self.base_path is None:
+ return None
+
+ is_file_uri = urlsplit(self.base_path).scheme.lower() == "file"
+ if not is_file_uri and not ImageResourceLoader.is_local_path(self.base_path):
+ return None
+ try:
+ local_base = Path(
+ self._image_loader.normalize_local_base_path(self.base_path)
+ )
+ # AnyUrl normalizes empty file URIs to a root directory URI.
+ if is_file_uri and local_base.parent == local_base:
+ raise ValueError("JATS file URI must identify a source document")
+ except ValueError as e:
+ warnings.warn(f"Could not process JATS figure images: {e}")
return None
graphic_nodes = node.xpath("graphic | alternatives/graphic")
@@ -1018,9 +1029,13 @@ def _load_figure_image(self, node: etree._Element) -> ImageRef | None:
# loader.
if Path(resolved_href).is_file():
found_candidate = True
- image_ref = self._image_loader.create_image_ref(
- resolved_href, self.base_path
- )
+ try:
+ image_ref = self._image_loader.create_image_ref(
+ resolved_href, self.base_path
+ )
+ except OSError as e:
+ warnings.warn(f"Could not process an image from {href}: {e}")
+ continue
if image_ref is not None:
return image_ref
diff --git a/docs/usage/advanced_options.md b/docs/usage/advanced_options.md
index b0e061c482..073c17f41b 100644
--- a/docs/usage/advanced_options.md
+++ b/docs/usage/advanced_options.md
@@ -572,6 +572,24 @@ Relative image paths are resolved against the current working directory. The
in JSON input; save the document with `ImageRefMode.EMBEDDED` if it has to go
through the CLI again with its images.
+### Load local JATS figure images
+
+JATS figure images require both `fetch_images=True` and
+`enable_local_fetch=True` on `JatsBackendOptions`. For a `DocumentStream`, set
+`source_uri` to the source document path or its `file://` URI. The source
+document need not exist, but its directory must contain the referenced images.
+Relative figure paths stay within that directory. Remote image references and
+remote source URIs do not enable downloads, even with `enable_remote_fetch=True`.
+
+File URIs with no authority or with `localhost` work on all supported platforms.
+Other authorities, such as `file://server/share/article.nxml`, require Windows
+UNC paths. JATS skips file URIs that identify only a filesystem root or a UNC
+share root, including empty file URIs normalized to `file:///`. Use a document
+location such as `file:///path/to/article.nxml` instead.
+
+Missing or damaged images produce a warning. The figure, its caption, and the
+rest of the article remain in the converted document. SVG figures are skipped.
+
### Fetch HTML images from remote hosts
The HTML backend only downloads images referenced by a page when you opt in with
diff --git a/tests/test_backend_jats_images.py b/tests/test_backend_jats_images.py
new file mode 100644
index 0000000000..3bfda3b655
--- /dev/null
+++ b/tests/test_backend_jats_images.py
@@ -0,0 +1,299 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Exercise local JATS figures through the stream conversion boundary."""
+
+import builtins
+import sys
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+from docling_core.types.doc import DoclingDocument, ImageRefMode, PictureItem, TextItem
+from PIL import Image
+
+from docling.backend.utils.image_resource_loader import ImageResourceLoader
+from docling.datamodel.backend_options import JatsBackendOptions
+from docling.datamodel.base_models import DocumentStream, InputFormat
+from docling.document_converter import DocumentConverter, XMLJatsFormatOption
+
+pytestmark = pytest.mark.cross_platform
+
+
+def convert_figure(graphic: str, options: JatsBackendOptions) -> DoclingDocument:
+ xml = f"""
+ Before figure. Green figure. After figure.',
+ '
',
+ '
'
+ '
'
+ if fallback:
+ graphic = (
+ f'
'
+ if failure in {"absolute", "traversal"}:
+ graphic = f'
',
+ JatsBackendOptions(
+ fetch_images=True, enable_local_fetch=True, source_uri=source
+ ),
+ )
+ assert assert_structure(doc).image is None
+ assert reads == []
+
+
+def test_disabled_images_do_not_resolve_or_probe(monkeypatch):
+ calls = []
+
+ def record_call(*args, **kwargs):
+ calls.append(args)
+ raise AssertionError("Disabled image loading must not resolve or probe")
+
+ monkeypatch.setattr(ImageResourceLoader, "normalize_local_base_path", record_call)
+ monkeypatch.setattr(ImageResourceLoader, "load_image_data", record_call)
+ monkeypatch.setattr(Path, "is_file", record_call)
+ doc = convert_figure(
+ '
', JatsBackendOptions(source_uri="file://")
+ )
+ assert assert_structure(doc).image is None
+ assert calls == []
+
+
+def test_local_permission_prevents_image_bytes(tmp_path: Path, monkeypatch):
+ image_path = tmp_path / "figure.png"
+ Image.new("RGB", (7, 5)).save(image_path)
+ reads = []
+ original_open = builtins.open
+
+ def guarded_open(file, *args, **kwargs):
+ if isinstance(file, (str, Path)) and Path(file) == image_path:
+ reads.append(file)
+ raise AssertionError("Local permission must prevent image byte reads")
+ return original_open(file, *args, **kwargs)
+
+ monkeypatch.setattr(builtins, "open", guarded_open)
+ with pytest.warns(UserWarning, match="enable_local_fetch"):
+ doc = convert_figure(
+ '
',
+ JatsBackendOptions(
+ fetch_images=True, source_uri=(tmp_path / "article.nxml").as_uri()
+ ),
+ )
+ assert assert_structure(doc).image is None
+ assert reads == []
+
+
+@pytest.mark.parametrize(
+ "source_kind", ["path", "file", "remote", "ftp", "other", "none"]
+)
+def test_jats_never_fetches_remote_images(
+ tmp_path: Path, source_kind: str, monkeypatch
+):
+ calls = []
+
+ def record_fetch(*args, **kwargs):
+ calls.append(args)
+ raise AssertionError("JATS must not fetch remote images")
+
+ monkeypatch.setattr(ImageResourceLoader, "fetch_remote", record_fetch)
+ sources = {
+ "path": tmp_path / "article.nxml",
+ "file": (tmp_path / "article.nxml").as_uri(),
+ "remote": "https://example.com/article.nxml",
+ "ftp": "ftp://example.com/article.nxml",
+ "other": "custom://example.com/article.nxml",
+ "none": None,
+ }
+ doc = convert_figure(
+ '
'
+ '
'
+ if source_kind not in {"path", "file"}
+ else '
',
+ JatsBackendOptions(
+ fetch_images=True, enable_remote_fetch=True, source_uri=sources[source_kind]
+ ),
+ )
+ assert assert_structure(doc).image is None
+ assert calls == []
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows supports UNC authorities")
+def test_jats_rejects_non_local_authority(monkeypatch):
+ calls = []
+
+ def record_read(*args, **kwargs):
+ calls.append(args)
+ raise AssertionError("Unsupported authority must not read images")
+
+ monkeypatch.setattr(ImageResourceLoader, "load_image_data", record_read)
+ with pytest.warns(UserWarning, match="Windows UNC"):
+ doc = convert_figure(
+ '
',
+ JatsBackendOptions(
+ fetch_images=True,
+ enable_local_fetch=True,
+ source_uri="file://server/share/article.nxml",
+ ),
+ )
+ assert assert_structure(doc).image is None
+ assert calls == []
diff --git a/tests/test_image_resource_file_uri.py b/tests/test_image_resource_file_uri.py
new file mode 100644
index 0000000000..4376059138
--- /dev/null
+++ b/tests/test_image_resource_file_uri.py
@@ -0,0 +1,141 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Check file URI resolution on native filesystems."""
+
+import sys
+from io import BytesIO
+from pathlib import Path
+
+import pytest
+
+from docling.backend.utils.image_resource_loader import ImageResourceLoader
+from docling.datamodel.backend_options import HTMLBackendOptions, MarkdownBackendOptions
+from docling.datamodel.base_models import DocumentStream, InputFormat
+from docling.document_converter import (
+ DocumentConverter,
+ HTMLFormatOption,
+ MarkdownFormatOption,
+)
+
+pytestmark = pytest.mark.cross_platform
+
+
+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()
+
+
+@pytest.mark.parametrize("authority", ["localhost", "LOCALHOST", "LoCaLhOsT"])
+def test_resolve_relative_path_accepts_localhost_file_uri(tmp_path, authority):
+ loader = ImageResourceLoader()
+ base_path = (
+ (tmp_path / "doc.md").as_uri().replace("file:///", f"file://{authority}/", 1)
+ )
+
+ resolved = loader.resolve_relative_path("figure.png", base_path)
+
+ assert Path(resolved) == (tmp_path / "figure.png").resolve()
+
+
+@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)
+
+
+@pytest.mark.parametrize(
+ "base", ["file://", "file://localhost", "file://LOCALHOST", "file://server"]
+)
+def test_resolve_relative_path_rejects_empty_file_uri(base):
+ loader = ImageResourceLoader()
+
+ with pytest.raises(ValueError, match="empty path"):
+ loader.resolve_relative_path("figure.png", base)
+
+
+def test_file_uri_decodes_real_directory(tmp_path):
+ directory = tmp_path / "space 中文 %23"
+ directory.mkdir()
+ image_path = directory / "figure.png"
+ image_path.write_bytes(b"target")
+ resolved = ImageResourceLoader().resolve_relative_path(
+ "figure.png", (directory / "doc.md").as_uri()
+ )
+ assert Path(resolved).read_bytes() == b"target"
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows supports UNC authorities")
+def test_non_local_authority_is_rejected():
+ with pytest.raises(ValueError, match="Windows UNC"):
+ ImageResourceLoader().resolve_relative_path(
+ "figure.png", "file://server/share/docs/doc.md"
+ )
+
+
+def test_remote_base_keeps_url_resolution():
+ assert (
+ ImageResourceLoader().resolve_relative_path(
+ "figure.png", "https://example.com/docs/article.html"
+ )
+ == "https://example.com/docs/figure.png"
+ )
+
+
+@pytest.mark.skipif(sys.platform == "win32", reason="Windows supports UNC authorities")
+@pytest.mark.parametrize("format", [InputFormat.HTML, InputFormat.MD])
+def test_backend_keeps_content_after_rejected_authority(format, monkeypatch):
+ calls = []
+
+ def record_read(*args, **kwargs):
+ calls.append(args)
+ raise AssertionError("Rejected authority must not read an image")
+
+ monkeypatch.setattr(ImageResourceLoader, "load_image_data", record_read)
+ source = "file://server/share/article.html"
+ if format == InputFormat.HTML:
+ option = HTMLFormatOption(
+ backend_options=HTMLBackendOptions(
+ source_uri=source, fetch_images=True, enable_local_fetch=True
+ )
+ )
+ content = '
Before

After
' + name = "article.html" + else: + option = MarkdownFormatOption( + backend_options=MarkdownBackendOptions( + source_uri=source, fetch_images=True, enable_local_fetch=True + ) + ) + content = "Before\n\n\n\nAfter\n" + name = "article.md" + converter = DocumentConverter( + allowed_formats=[format], format_options={format: option} + ) + with pytest.warns(UserWarning, match="Windows UNC"): + doc = converter.convert( + DocumentStream(name=name, stream=BytesIO(content.encode())) + ).document + markdown = doc.export_to_markdown() + assert markdown.index("Before") < markdown.index("After") + assert len(doc.pictures) == 1 and doc.pictures[0].image is None + assert calls == [] diff --git a/tests/test_image_resource_loader.py b/tests/test_image_resource_loader.py index 53f338fa4a..04dd602b82 100644 --- a/tests/test_image_resource_loader.py +++ b/tests/test_image_resource_loader.py @@ -6,17 +6,14 @@ 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, @@ -252,69 +249,6 @@ 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."""