]*>[\s\S]*?",
re.IGNORECASE,
)
# Literal spans and code spans pass through; `\\` keeps `\\*` from unescaping.
-# A code span opens only at a full backtick run, read atomically with `(?=(`+))\1`:
+# A code span opens only at a full backtick run, read atomically in a lookahead:
# a backtracking `(`+)` takes cubic time on a long unclosed run.
_MARKDOWN_ESCAPE_PATTERN = re.compile(
rf"{_LITERAL_INLINE_PATTERN.pattern}"
- r"|(?`+))(?P=ticks)[\s\S]*?(?[*_])|\\\\",
re.IGNORECASE,
)
@@ -78,77 +78,16 @@
def _unescape_markdown_literals(text: str) -> str:
r"""Turn the Markdown escapes `\*` and `\_` into literal characters."""
return _MARKDOWN_ESCAPE_PATTERN.sub(
- lambda match: match.group(2) or match.group(0), text
+ lambda match: match.group("escape") or match.group(0), text
)
def _markdown_inline_html(text: str) -> str:
"""Render Markdown emphasis as inline HTML; unescape other text."""
html_text = text
- if "*" in text or "_" in text:
- # Markdown is an optional format dependency, also used for DOTS emphasis.
- try:
- import marko
- except ImportError as exc:
- raise ImportError(
- "DOTS Markdown emphasis requires "
- "`pip install 'docling-slim[format-markdown]'`."
- ) from exc
- from marko.helpers import MarkoExtension
-
- class Paragraph(marko.block.Paragraph):
- override = True
-
- @classmethod
- def break_paragraph(
- cls, source: marko.source.Source, lazy: bool = False
- ) -> bool:
- return False
-
- @staticmethod
- def is_setext_heading(line: str) -> bool:
- # SetextHeading is not a registered block element below.
- return False
-
- class InlineParser(marko.Parser):
- def __init__(self) -> None:
- super().__init__()
- # Native DOTS categories already define the block structure.
- self.block_elements = {
- name: element
- for name, element in self.block_elements.items()
- if name in {"Document", "Paragraph", "BlankLine"}
- }
-
- class LiteralInline(marko.inline.InlineElement):
- children: str
- pattern = _LITERAL_INLINE_PATTERN
- priority = 8
- parse_group = 0
-
- class CodeSpan(marko.inline.CodeSpan):
- override = True
- priority = 9
-
- class LiteralRenderer:
- def render_literal_inline(self, element: LiteralInline) -> str:
- return (
- element.children
- if element.children.lower().startswith("" in rendered or "" in rendered:
+ if any(token in text for token in ("*", "_", "$", r"\(", r"\[", "`")):
+ rendered = create_markdown_parser(inline_only=True).convert(text)
+ if re.search(r"<(?:strong|em|math)\b", rendered):
# Keep soft and hard line breaks as
.
html_text = re.sub(r"(?:
)?\n", "
", rendered.strip())
else:
diff --git a/docling/utils/markdown_math.py b/docling/utils/markdown_math.py
new file mode 100644
index 0000000000..a729119413
--- /dev/null
+++ b/docling/utils/markdown_math.py
@@ -0,0 +1,121 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+"""Math-aware Markdown rendering for native VLM response adapters."""
+
+from __future__ import annotations
+
+import html
+import re
+from threading import local
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ import marko
+
+_MATH = re.compile(
+ r"(? None:
+ self.by_mode: dict[bool, marko.Markdown] = {}
+
+
+_parsers = _MarkdownParsers()
+
+
+def create_markdown_parser(*, inline_only: bool = False) -> marko.Markdown:
+ """Render LaTeX as literal math nodes and keep code and currency literal.
+
+ ``inline_only`` leaves the enclosing native block type in control instead
+ of interpreting its text as new Markdown lists, headings, or tables.
+
+ Reuse one parser per mode and thread: Marko instances are not thread-safe.
+ """
+ # Markdown is an optional dependency for slim installations.
+ try:
+ import marko
+ except ImportError as exc:
+ raise ImportError(
+ "Markdown parsing requires `pip install 'docling-slim[format-markdown]'`."
+ ) from exc
+ from marko.helpers import MarkoExtension
+
+ if inline_only in _parsers.by_mode:
+ return _parsers.by_mode[inline_only]
+
+ class Math(marko.inline.InlineElement):
+ children: str
+ pattern = _MATH
+ priority = 8
+ parse_group = 0
+
+ class CodeSpan(marko.inline.CodeSpan):
+ override = True
+ priority = 9
+
+ class LiteralCode(marko.inline.InlineElement):
+ children: str
+ pattern = re.compile(r"]*>[\s\S]*?", re.IGNORECASE)
+ priority = 10
+ parse_group = 0
+
+ class MathRenderer:
+ def render_math(self, element: Math) -> str:
+ match = _MATH.fullmatch(element.children)
+ if match is None:
+ return html.escape(element.children)
+ latex = next(group for group in match.groups() if group is not None)
+ display = match.group(1) is not None or match.group(4) is not None
+ attrs = ' display="block"' if display else ""
+ return f""
+
+ def render_literal_code(self, element: LiteralCode) -> str:
+ return element.children
+
+ class Paragraph(marko.block.Paragraph):
+ override = True
+
+ @classmethod
+ def break_paragraph(
+ cls, source: marko.source.Source, lazy: bool = False
+ ) -> bool:
+ return False
+
+ @staticmethod
+ def is_setext_heading(line: str) -> bool:
+ return False
+
+ class InlineParser(marko.Parser):
+ def __init__(self) -> None:
+ super().__init__()
+ self.block_elements = {
+ name: element
+ for name, element in self.block_elements.items()
+ if name in {"Document", "Paragraph", "BlankLine"}
+ }
+
+ markdown = marko.Markdown(
+ parser=InlineParser if inline_only else marko.Parser,
+ extensions=[
+ # GFM adds block elements after InlineParser filters them, and its
+ # strikethrough and autolinks change literal native block content.
+ *([] if inline_only else ["gfm"]),
+ MarkoExtension(
+ elements=[
+ Math,
+ CodeSpan,
+ LiteralCode,
+ *([Paragraph] if inline_only else []),
+ ],
+ renderer_mixins=[MathRenderer],
+ ),
+ ],
+ )
+ _parsers.by_mode[inline_only] = markdown
+ return markdown
diff --git a/docling/utils/nemotron_parse_utils.py b/docling/utils/nemotron_parse_utils.py
index 5079b26b92..2ced887551 100644
--- a/docling/utils/nemotron_parse_utils.py
+++ b/docling/utils/nemotron_parse_utils.py
@@ -24,6 +24,7 @@
Size,
TableData,
TabularChartMetaField,
+ TextItem,
)
from PIL import Image as PILImage
@@ -31,6 +32,8 @@
from docling.backend.md_backend import MarkdownDocumentBackend
from docling.datamodel.base_models import InputFormat
from docling.datamodel.document import InputDocument
+from docling.utils.chandra_utils import _add_html_fragment
+from docling.utils.markdown_math import create_markdown_parser
from docling.utils.vlm_utils import (
normalize_section_header_levels,
strip_math_delimiters,
@@ -198,6 +201,17 @@ def _markdown_to_text(markdown: str) -> str:
return markdown.strip()
+def _add_inline_math(document: DoclingDocument, item: TextItem, text: str) -> None:
+ if not any(token in text for token in ("$", r"\(", r"\[")):
+ return
+ rendered = create_markdown_parser(inline_only=True).convert(text)
+ if "; the answer is or .",
+ ),
+ (
+ "about ~5 m and ~10 m, where $x$ is the length",
+ "about ~5 m and ~10 m, where is the length",
+ ),
+ (
+ "| A | B |\n| --- | --- |\n| $x$ | y |",
+ "| A | B |\n| --- | --- |\n| | y |",
+ ),
+ ("> [!NOTE]\n> Value $x$.", "> [!NOTE]\n> Value ."),
+ (
+ "See www.example.com and $x$.",
+ "See www.example.com and .",
+ ),
+ ],
+)
+def test_inline_math_preserves_native_text_syntax(source: str, expected: str) -> None:
+ assert create_markdown_parser(inline_only=True).convert(source) == (
+ f"{expected}
\n"
+ )
+
+
+def test_full_markdown_still_parses_tables_with_math() -> None:
+ rendered = create_markdown_parser().convert("| A | B |\n| --- | --- |\n| $x$ | y |")
+ assert "" in rendered
+ assert " " in rendered
+
+
+def test_reused_parsers_do_not_leak_document_state() -> None:
+ full = create_markdown_parser()
+ inline = create_markdown_parser(inline_only=True)
+ assert 'name' in full.convert(
+ "[name][ref] and $x$\n\n[ref]: https://example.com"
+ )
+ assert inline.convert("# Next $y$") == "# Next
\n"
+ assert full.convert("[name][ref] and $z$") == (
+ "[name][ref] and
\n"
+ )
+
+
+def test_concurrent_conversions_keep_references_and_math_in_their_document() -> None:
+ def convert(index: int) -> str:
+ parser = create_markdown_parser()
+ return parser.convert(
+ f"[name][ref] and $x_{index}$\n\n[ref]: https://example.com/{index}"
+ )
+
+ with ThreadPoolExecutor(max_workers=4) as executor:
+ rendered = list(executor.map(convert, range(32)))
+ assert rendered == [
+ f'name and
\n'
+ for index in range(32)
+ ]
diff --git a/tests/test_vlm_inline_math_exports.py b/tests/test_vlm_inline_math_exports.py
new file mode 100644
index 0000000000..af3c9c9b17
--- /dev/null
+++ b/tests/test_vlm_inline_math_exports.py
@@ -0,0 +1,83 @@
+# SPDX-FileCopyrightText: The Docling Contributors
+# SPDX-License-Identifier: MIT
+
+import json
+
+import pytest
+from docling_core.types.doc import CodeItem, FormulaItem, Size
+
+from docling.utils.dots_utils import parse_dots_json
+from docling.utils.nemotron_parse_utils import parse_nemotron_parse_v2
+
+
+@pytest.mark.parametrize("parser", ["dots", "nemotron"])
+@pytest.mark.parametrize(
+ "category", ["Text", "Title", "Section-header", "List-item", "Caption"]
+)
+def test_inline_math_survives_export_without_changing_block_ownership(
+ parser: str, category: str
+) -> None:
+ latex = r"x_1 * y_2 * z_3 < 2"
+ text = f"Value ${latex}$ and `literal $x_1$`; cost: \\$5."
+ if category == "Section-header":
+ text = "### " + text
+ size = Size(width=600, height=800)
+ if parser == "dots":
+ source = json.dumps(
+ [{"category": category, "bbox": [100, 100, 500, 200], "text": text}]
+ )
+ doc = parse_dots_json(source, size, page_no=2)
+ else:
+ source = f"{text}"
+ doc = parse_nemotron_parse_v2(source, size, size, page_no=2)
+ formulas = [item for item in doc.texts if isinstance(item, FormulaItem)]
+ assert [formula.text for formula in formulas] == [latex]
+ assert all(not formula.prov for formula in formulas)
+ assert len([item for item in doc.texts if item.prov]) == 1
+ assert next(item for item in doc.texts if item.prov).prov[0].page_no == 2
+ assert [item.text for item in doc.texts if isinstance(item, CodeItem)] == [
+ "literal $x_1$"
+ ]
+ exported = doc.export_to_markdown()
+ assert f"${latex}$" in exported
+ assert "`literal $x_1$`" in exported
+ assert "$5" in exported
+
+
+@pytest.mark.parametrize("parser", ["dots", "nemotron"])
+def test_inline_math_preserves_approximation_marks(parser: str) -> None:
+ text = "about ~5 m and ~10 m, where $x$ is the length"
+ size = Size(width=600, height=800)
+ if parser == "dots":
+ source = json.dumps(
+ [{"category": "Text", "bbox": [100, 100, 500, 200], "text": text}]
+ )
+ doc = parse_dots_json(source, size, page_no=2)
+ else:
+ source = f"{text}"
+ doc = parse_nemotron_parse_v2(source, size, size, page_no=2)
+ assert "about ~5 m and ~10 m" in doc.export_to_markdown()
+ assert not any(
+ item.formatting.strikethrough for item in doc.texts if item.formatting
+ )
+ assert [item.text for item in doc.texts if isinstance(item, FormulaItem)] == ["x"]
+
+
+@pytest.mark.parametrize("parser", ["dots", "nemotron"])
+@pytest.mark.parametrize(
+ "price", ["$5-$10", "$5\u2013$10", "$5.00 to $10.00", "$1,000-$2,000"]
+)
+def test_currency_range_remains_text_next_to_math(parser: str, price: str) -> None:
+ text = f"Price {price} and $x$."
+ size = Size(width=600, height=800)
+ if parser == "dots":
+ source = json.dumps(
+ [{"category": "Text", "bbox": [100, 100, 500, 200], "text": text}]
+ )
+ doc = parse_dots_json(source, size, page_no=2)
+ else:
+ source = f"{text}"
+ doc = parse_nemotron_parse_v2(source, size, size, page_no=2)
+ assert [item.text for item in doc.texts if isinstance(item, FormulaItem)] == ["x"]
+ assert f"Price {price}" in doc.export_to_markdown()
+ assert len([item for item in doc.texts if item.prov]) == 1