diff --git a/docling/backend/latex/backend.py b/docling/backend/latex/backend.py index 2fedeaaee6..5c5e8c1d59 100644 --- a/docling/backend/latex/backend.py +++ b/docling/backend/latex/backend.py @@ -72,6 +72,13 @@ def __init__( options = LatexBackendOptions() super().__init__(in_doc, path_or_stream, options) self.options = LatexBackendOptions.model_validate(self.options) + # Directory that relative \input, \include and \includegraphics paths + # resolve against. A stream has none unless source_uri gives it. + self._source_dir: Path | None = None + if isinstance(self.path_or_stream, Path): + self._source_dir = self.path_or_stream.parent + elif self.options.source_uri is not None: + self._source_dir = Path(self.options.source_uri).parent self.labels: dict[str, bool] = {} self._custom_macros: dict[str, str] = {} self._custom_macro_num_args: dict[str, int] = {} diff --git a/docling/backend/latex/handlers/environments.py b/docling/backend/latex/handlers/environments.py index 057215e2db..efe68a9ef0 100644 --- a/docling/backend/latex/handlers/environments.py +++ b/docling/backend/latex/handlers/environments.py @@ -45,7 +45,7 @@ class EnvironmentHandlerMixin: if TYPE_CHECKING: options: BaseBackendOptions latex_preamble: str - path_or_stream: Any + _source_dir: Path | None _tectonic_engine: TectonicEngine | None _tikz_executor: ThreadPoolExecutor | None _tikz_futures: list[Future[Any]] @@ -213,9 +213,7 @@ def set_code_fallback(): set_code_fallback() preamble = self.latex_preamble - source_root = None - if isinstance(self.path_or_stream, Path): - source_root = self.path_or_stream.parent + source_root = self._source_dir if self._tikz_executor: future = self._tikz_executor.submit( render_task, diff --git a/docling/backend/latex/handlers/macros.py b/docling/backend/latex/handlers/macros.py index 26896e177a..768fb0406c 100644 --- a/docling/backend/latex/handlers/macros.py +++ b/docling/backend/latex/handlers/macros.py @@ -5,7 +5,6 @@ import logging import re -from io import BytesIO from pathlib import Path from typing import TYPE_CHECKING, Callable @@ -77,7 +76,7 @@ class MacroHandlerMixin: if TYPE_CHECKING: - path_or_stream: BytesIO | Path + _source_dir: Path | None _input_stack: set[str] _custom_macros: dict[str, str] _custom_macro_num_args: dict[str, int] @@ -336,9 +335,9 @@ def _process_macro( # noqa: C901 if img_path: image = None try: - if isinstance(self.path_or_stream, Path): - base_dir = self.path_or_stream.parent.resolve() - img_full_path = self.path_or_stream.parent / img_path + if self._source_dir is not None: + base_dir = self._source_dir.resolve() + img_full_path = self._source_dir / img_path try: if not img_full_path.resolve().is_relative_to(base_dir): _log.warning( @@ -398,9 +397,9 @@ def _process_macro( # noqa: C901 from pylatexenc.latexwalker import LatexWalker filepath = self._extract_macro_arg(node) - if filepath and isinstance(self.path_or_stream, Path): - base_dir = self.path_or_stream.parent.resolve() - input_path = self.path_or_stream.parent / filepath + if filepath and self._source_dir is not None: + base_dir = self._source_dir.resolve() + input_path = self._source_dir / filepath if not input_path.suffix: input_path = input_path.with_suffix(".tex") diff --git a/docling/datamodel/backend_options.py b/docling/datamodel/backend_options.py index 61c386b554..4e15f0e31f 100644 --- a/docling/datamodel/backend_options.py +++ b/docling/datamodel/backend_options.py @@ -553,6 +553,14 @@ class LatexBackendOptions(BaseBackendOptions): """Options specific to the LaTeX backend.""" kind: Literal["latex"] = Field("latex", exclude=True, repr=False) + source_uri: Optional[PurePath] = Field( + None, + description=( + "The local path of the LaTeX document. If provided and the document " + "is read from a stream, the backend resolves relative \\input, " + "\\include and \\includegraphics paths against its directory." + ), + ) parse_timeout: Optional[float] = Field( 30.0, description=( diff --git a/docling/document_converter.py b/docling/document_converter.py index 9f8bb6c6f6..8a78340e07 100644 --- a/docling/document_converter.py +++ b/docling/document_converter.py @@ -15,7 +15,15 @@ from typing import Optional, Type, Union from docling_core.types.doc.page import TextCellUnit -from pydantic import ConfigDict, Field, model_validator, validate_call +from pydantic import ( + AnyHttpUrl, + ConfigDict, + Field, + TypeAdapter, + ValidationError, + model_validator, + validate_call, +) from typing_extensions import Self from docling.backend.abstract_backend import ( @@ -358,6 +366,31 @@ class LatexFormatOption(FormatOption): backend: Type[AbstractDocumentBackend] = LatexDocumentBackend backend_options: Optional[LatexBackendOptions] = None + def backend_options_for_input( + self, source: Path | str | DocumentStream + ) -> LatexBackendOptions | None: + """Keep the location of a local ``str`` source. + + A ``str`` source is read into a stream before it reaches the backend, + which then cannot resolve ``\\input``, ``\\include`` or + ``\\includegraphics`` relative to the document. ``Path`` sources and + streams are passed on unchanged, and so are http(s) URLs, the same way + ``resolve_source_to_stream`` tells them apart from local paths. + """ + options = self.backend_options + if not isinstance(source, str) or ( + options is not None and options.source_uri is not None + ): + return options + try: + TypeAdapter(AnyHttpUrl).validate_python(source) + except ValidationError: + base = options.model_dump() if options is not None else {} + return LatexBackendOptions.model_validate( + {**base, "source_uri": Path(source)} + ) + return options + class EmailFormatOption(FormatOption): pipeline_cls: Type = SimplePipeline diff --git a/tests/test_latex/test_basic.py b/tests/test_latex/test_basic.py index 18221ead0c..89b74ec406 100644 --- a/tests/test_latex/test_basic.py +++ b/tests/test_latex/test_basic.py @@ -11,7 +11,7 @@ from docling.datamodel.backend_options import LatexBackendOptions from docling.datamodel.base_models import ConversionStatus, InputFormat from docling.datamodel.document import ConversionResult, DoclingDocument, InputDocument -from docling.document_converter import DocumentConverter +from docling.document_converter import DocumentConverter, LatexFormatOption from ..test_data_gen_flag import GEN_TEST_DATA from ..verify_utils import verify_document, verify_export @@ -356,6 +356,75 @@ def test_latex_input_file_in_main_file_encoding(tmp_path, encoding, inputenc): assert "The chapter body." in md +def _write_paper_with_includes(directory: Path) -> Path: + """Write a main.tex that pulls in a sibling .tex file and a figure.""" + from PIL import Image as PILImage + + (directory / "intro.tex").write_text("\\section{Introduction}\nIncluded text.\n") + PILImage.new("RGB", (20, 10), "blue").save(directory / "fig.png") + main_file = directory / "main.tex" + main_file.write_text( + "\\documentclass{article}\n\\begin{document}\nMain text.\n" + "\\input{intro}\n\\includegraphics{fig.png}\n\\end{document}\n" + ) + return main_file + + +@pytest.mark.parametrize("relative", [False, True]) +def test_latex_str_source_resolves_included_files(tmp_path, monkeypatch, relative): + """A str source resolves \\input and \\includegraphics like a Path source.""" + main_file = _write_paper_with_includes(tmp_path) + if relative: + monkeypatch.chdir(tmp_path) + source = "main.tex" + else: + source = str(main_file) + + doc = get_latex_converter().convert(source).document + + assert "Included text." in doc.export_to_markdown() + assert len(doc.pictures) == 1 + assert doc.pictures[0].image is not None + + +def test_latex_stream_resolves_included_files_with_source_uri(tmp_path): + """A stream resolves relative files against source_uri when it is given.""" + main_file = _write_paper_with_includes(tmp_path) + stream = BytesIO(main_file.read_bytes()) + in_doc = InputDocument( + path_or_stream=stream, + format=InputFormat.LATEX, + backend=LatexDocumentBackend, + filename="main.tex", + ) + + doc = LatexDocumentBackend( + in_doc=in_doc, + path_or_stream=stream, + options=LatexBackendOptions(source_uri=main_file), + ).convert() + + assert "Included text." in doc.export_to_markdown() + assert doc.pictures[0].image is not None + + +def test_latex_format_option_source_uri_only_for_local_str(): + """Only a local str source gets a source_uri; a URL has no local directory.""" + option = LatexFormatOption() + + local = option.backend_options_for_input("paper/main.tex") + assert local is not None + assert local.source_uri == Path("paper/main.tex") + assert option.backend_options_for_input("https://example.com/main.tex") is None + assert option.backend_options_for_input(Path("paper/main.tex")) is None + + custom = LatexFormatOption(backend_options=LatexBackendOptions(parse_timeout=5)) + options = custom.backend_options_for_input("paper/main.tex") + assert options is not None + assert options.parse_timeout == 5 + assert options.source_uri == Path("paper/main.tex") + + def test_latex_author_date(): """Test \\author and \\date text is preserved""" latex_content = b"""