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
7 changes: 7 additions & 0 deletions docling/backend/latex/backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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] = {}
Expand Down
6 changes: 2 additions & 4 deletions docling/backend/latex/handlers/environments.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]]
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 7 additions & 8 deletions docling/backend/latex/handlers/macros.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@

import logging
import re
from io import BytesIO
from pathlib import Path
from typing import TYPE_CHECKING, Callable

Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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")

Expand Down
8 changes: 8 additions & 0 deletions docling/datamodel/backend_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=(
Expand Down
35 changes: 34 additions & 1 deletion docling/document_converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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
Expand Down
71 changes: 70 additions & 1 deletion tests/test_latex/test_basic.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"""
Expand Down
Loading