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""" +
+Figure test + +

Before figure.

+

Green figure.

{graphic}
+

After figure.

""" + converter = DocumentConverter( + allowed_formats=[InputFormat.XML_JATS], + format_options={ + InputFormat.XML_JATS: XMLJatsFormatOption(backend_options=options) + }, + ) + return converter.convert( + DocumentStream(name="article.nxml", stream=BytesIO(xml.encode())) + ).document + + +def assert_structure(doc: DoclingDocument) -> PictureItem: + title = doc.body.children[0].resolve(doc) + assert isinstance(title, TextItem) and title.text == "Figure test" + before, picture, after = [ref.resolve(doc) for ref in title.children] + assert isinstance(before, TextItem) and before.text == "Before figure." + assert isinstance(picture, PictureItem) + assert isinstance(after, TextItem) and after.text == "After figure." + assert before.parent == picture.parent == after.parent == title.get_ref() + assert len(picture.captions) == 1 + caption = picture.captions[0].resolve(doc) + assert isinstance(caption, TextItem) and caption.text == "Fig. 1 Green figure." + markdown = doc.export_to_markdown(image_mode=ImageRefMode.EMBEDDED) + assert markdown.index(before.text) < markdown.index(caption.text) + assert markdown.index(caption.text) < markdown.index(after.text) + return picture + + +def source_path(tmp_path: Path, kind: str) -> Path | str: + path = tmp_path / "article.nxml" + if kind == "path": + return path + uri = path.as_uri() + return ( + uri.replace("file:///", "file://LoCaLhOsT/", 1) if kind == "localhost" else uri + ) + + +@pytest.mark.parametrize("source_kind", ["path", "file", "localhost"]) +@pytest.mark.parametrize( + "graphic", + [ + '', + '', + '' + '', + ], +) +def test_stream_loads_local_figure(tmp_path: Path, source_kind: str, graphic: str): + directory = tmp_path / "space 中文" + directory.mkdir() + Image.new("RGB", (7, 5), (0, 255, 0)).save(directory / "figure.png") + doc = convert_figure( + graphic, + JatsBackendOptions( + fetch_images=True, + enable_local_fetch=True, + source_uri=source_path(directory, source_kind), + ), + ) + picture = assert_structure(doc) + image = picture.get_image(doc) + assert image is not None and image.size == (7, 5) + assert image.getpixel((0, 0)) == (0, 255, 0) + assert "data:image/png;base64," in doc.export_to_markdown( + image_mode=ImageRefMode.EMBEDDED + ) + + +@pytest.mark.parametrize("source_kind", ["path", "file"]) +@pytest.mark.parametrize("damage", ["unrecognized", "truncated"]) +@pytest.mark.parametrize("fallback", [False, True]) +def test_damaged_figure_keeps_content( + tmp_path: Path, source_kind: str, damage: str, fallback: bool +): + buffer = BytesIO() + Image.new("RGB", (32, 32), (0, 255, 0)).save(buffer, format="BMP") + (tmp_path / "broken.bmp").write_bytes( + buffer.getvalue()[:100] if damage == "truncated" else b"not an image" + ) + Image.new("RGB", (7, 5), (0, 255, 0)).save(tmp_path / "figure.png") + graphic = '' + if fallback: + graphic = ( + f'{graphic}' + ) + with pytest.warns(UserWarning, match="Could not process an image"): + doc = convert_figure( + graphic, + JatsBackendOptions( + fetch_images=True, + enable_local_fetch=True, + source_uri=source_path(tmp_path, source_kind), + ), + ) + picture = assert_structure(doc) + if fallback: + image = picture.get_image(doc) + assert image is not None and image.getpixel((0, 0)) == (0, 255, 0) + else: + assert picture.image is None + + +@pytest.mark.parametrize("source_kind", ["path", "file"]) +@pytest.mark.parametrize("failure", ["missing", "absolute", "traversal", "svg"]) +def test_local_figure_guards(tmp_path: Path, source_kind: str, failure: str, caplog): + directory = tmp_path / "article" + directory.mkdir() + Image.new("RGB", (7, 5)).save(tmp_path / "outside.png") + Image.new("RGB", (7, 5)).save(directory / "fallback.png") + href = { + "missing": "missing.png", + "absolute": (tmp_path / "outside.png").as_posix(), + "traversal": "../outside.png", + "svg": "figure.svg", + }[failure] + graphic = f'' + if failure in {"absolute", "traversal"}: + graphic = f'{graphic}' + options = JatsBackendOptions( + fetch_images=True, + enable_local_fetch=True, + source_uri=source_path(directory, source_kind), + ) + if failure == "svg": + doc = convert_figure(graphic, options) + assert "Skipping unsupported JATS SVG" in caplog.text + else: + message = { + "missing": "no matching local file", + "absolute": "Absolute paths are not allowed", + "traversal": "Path traversal blocked", + }[failure] + with pytest.warns(UserWarning, match=message): + doc = convert_figure(graphic, options) + picture = assert_structure(doc) + assert (picture.image is not None) == (failure == "absolute") + + +@pytest.mark.parametrize( + "source", + [ + "file://", + "file://localhost", + "file://LOCALHOST", + "file:///", + pytest.param( + "file:///C:/", + marks=pytest.mark.skipif( + sys.platform != "win32", reason="Windows drive root" + ), + ), + "file://server/share/", + ], +) +def test_root_file_uri_is_not_a_document(source: str, monkeypatch): + reads = [] + + def record_read(*args, **kwargs): + reads.append(args) + raise AssertionError("Invalid base must not reach image reading") + + monkeypatch.setattr(ImageResourceLoader, "load_image_data", record_read) + with pytest.warns(UserWarning, match="source document|Windows UNC"): + doc = convert_figure( + '', + 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![Caption](figure.png)\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."""