From 5a863f3b4fba9c42c5012e0a9e5048da5160a702 Mon Sep 17 00:00:00 2001 From: Ankit Kumar Date: Sun, 4 Oct 2026 01:20:13 +0530 Subject: [PATCH] fix(latex): skip minted's language and bracketed options before code Signed-off-by: Ankit Kumar --- docling/backend/latex/utils/text.py | 15 ++++++++++++- tests/test_latex/test_environments.py | 32 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/docling/backend/latex/utils/text.py b/docling/backend/latex/utils/text.py index 113ad75671..b4fb19ed2a 100644 --- a/docling/backend/latex/utils/text.py +++ b/docling/backend/latex/utils/text.py @@ -39,6 +39,13 @@ pass # guarded by LatexDocumentBackend.__init__ +# A {...} group, allowing one level of nested braces. +_BRACED_GROUP = r"\{(?:[^{}]|\{[^{}]*\})*\}" +# A [...] optional argument whose braced values may contain brackets, +# as in [caption={A [b] c}]. +_OPTIONAL_ARGUMENT = rf"\[(?:[^\[\]{{}}]|{_BRACED_GROUP})*\]" + + class TextHelperMixin: if TYPE_CHECKING: _custom_macros: dict[str, str] @@ -111,7 +118,13 @@ def _process_group_node( self._process_nodes(node.nodelist, doc, parent, formatting, text_label) def _extract_verbatim_content(self, latex_str: str, env_name: str) -> str: - pattern = rf"\\begin\{{{re.escape(env_name)}\}}(?:\[.*?\])?(.*?)\\end\{{{re.escape(env_name)}\}}" + name = re.escape(env_name) + # minted takes the code's language as a mandatory {argument}. + language = _BRACED_GROUP if env_name == "minted" else "" + pattern = ( + rf"\\begin\{{{name}\}}(?:{_OPTIONAL_ARGUMENT})?{language}" + rf"(.*?)\\end\{{{name}\}}" + ) match = re.search(pattern, latex_str, re.DOTALL) if match: return re.sub(r"\A(?:[ \t]*\r?\n)+", "", match.group(1)).rstrip() diff --git a/tests/test_latex/test_environments.py b/tests/test_latex/test_environments.py index 59a3fe0627..ce93d025b9 100644 --- a/tests/test_latex/test_environments.py +++ b/tests/test_latex/test_environments.py @@ -126,6 +126,38 @@ def test_latex_code_environment_keeps_first_line_indentation(env_name, lead): assert code_texts == [" key: 1\n sub:\n x: 2"] +@pytest.mark.parametrize( + "opening", + [ + "\\begin{minted}{python}", + "\\begin{minted}[linenos, frame=lines]{python}", + "\\begin{lstlisting}[language=Python, caption={A [nested] cap}]", + ], +) +def test_latex_code_environment_skips_its_arguments(opening): + """Environment arguments are markup, not the first line of the code.""" + env_name = opening[len("\\begin{") : opening.index("}")] + latex_content = ( + "\\documentclass{article}\n" + "\\begin{document}\n" + f"{opening}\n" + " x = 1\n" + f"\\end{{{env_name}}}\n" + "\\end{document}\n" + ).encode() + in_doc = InputDocument( + path_or_stream=BytesIO(latex_content), + format=InputFormat.LATEX, + backend=LatexDocumentBackend, + filename="test.tex", + ) + backend = LatexDocumentBackend(in_doc=in_doc, path_or_stream=BytesIO(latex_content)) + doc = backend.convert() + + code_texts = [t.text for t in doc.texts if t.label == DocItemLabel.CODE] + assert code_texts == [" x = 1"] + + def test_latex_subequations_environment(): """Test subequations wrapper environment passes through inner equations.""" latex_content = rb"""