diff --git a/docling/utils/dots_utils.py b/docling/utils/dots_utils.py index a165c28d12..7147fd0e21 100644 --- a/docling/utils/dots_utils.py +++ b/docling/utils/dots_utils.py @@ -43,6 +43,7 @@ from docling.utils.chandra_utils import _add_html_fragment, _add_html_table from docling.utils.code_language import detect_code_language +from docling.utils.markdown_math import _MATH, create_markdown_parser from docling.utils.vlm_utils import ( link_adjacent_captions, normalize_section_header_levels, @@ -56,21 +57,20 @@ re.DOTALL, ) _HTML_TAG_PATTERN = re.compile( - r"])", + r"])", re.IGNORECASE, ) _LITERAL_INLINE_PATTERN = re.compile( - r"(?]*>[\s\S]*?", + rf"{_MATH.pattern}|]*>[\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"{html.escape(latex.strip())}" + + 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 " str: """Parse Markdown cell content using Docling's Markdown backend.""" try: @@ -295,12 +309,13 @@ def parse_nemotron_parse_v2( if region.label == "List-item": if current_list_group is None: current_list_group = document.add_list_group() - document.add_list_item( + item = document.add_list_item( text=_markdown_to_text(raw_text), orig=raw_text, parent=current_list_group, prov=provenance, ) + _add_inline_math(document, item, raw_text) continue current_list_group = None @@ -316,18 +331,24 @@ def parse_nemotron_parse_v2( tabular_chart=TabularChartMetaField(chart_data=table_data) ) elif region.label == "Title": - document.add_title( + item = document.add_title( text=_markdown_to_text(raw_text), orig=raw_text, prov=provenance ) + _add_inline_math( + document, item, _HEADING_PATTERN.sub("", raw_text, count=1) + ) elif region.label == "Section-header": heading_match = _HEADING_PATTERN.match(raw_text) - document.add_heading( + item = document.add_heading( text=_markdown_to_text(raw_text), orig=raw_text, # Raw markup depth, normalized per document later. level=len(heading_match.group(1)) if heading_match is not None else 2, prov=provenance, ) + _add_inline_math( + document, item, _HEADING_PATTERN.sub("", raw_text, count=1) + ) else: label = _TEXT_LABELS.get(region.label, DocItemLabel.TEXT) text = ( @@ -335,12 +356,14 @@ def parse_nemotron_parse_v2( if label == DocItemLabel.FORMULA else _markdown_to_text(raw_text) ) - document.add_text( + item = document.add_text( label=label, text=text, orig=raw_text, prov=provenance, ) + if label != DocItemLabel.FORMULA: + _add_inline_math(document, item, raw_text) if normalize_heading_levels: normalize_section_header_levels(document) diff --git a/tests/test_dots_parser.py b/tests/test_dots_parser.py index f33aaed42b..a50d7308b6 100644 --- a/tests/test_dots_parser.py +++ b/tests/test_dots_parser.py @@ -12,6 +12,7 @@ CodeItem, DocItemLabel, DoclingDocument, + FormulaItem, InlineGroup, RichTableCell, Script, @@ -93,10 +94,7 @@ def test_markdown_emphasis_preserves_math_code_and_html(page_size: Size): }, ] doc = parse_dots_json(json.dumps(data), page_size, page_no=1) - assert any( - item.text == "Bold $x_1 * y_2 < 2$" and item.formatting.bold - for item in doc.texts - ) + assert any(item.text == "Bold" and item.formatting.bold for item in doc.texts) assert any( item.text == "2" and item.formatting.script == Script.SUB for item in doc.texts ) @@ -106,20 +104,21 @@ def test_markdown_emphasis_preserves_math_code_and_html(page_size: Size): "_literal_", "'**literal**'", ] - assert ( - next(item.text for item in doc.texts if item.label == DocItemLabel.FORMULA) - == r"x_1 * y_2 * z_3" - ) + assert [item.text for item in doc.texts if isinstance(item, FormulaItem)] == [ + "x_1 * y_2 < 2", + "x_1 * y_2 * z_3", + ] + assert "$x_1 * y_2 < 2$" in doc.export_to_markdown() @pytest.mark.parametrize( ("text", "expected"), [ ("snake_case and a_b_c", "snake_case and a_b_c"), + (r"Price $5\_x-$10", "Price $5_x-$10"), (r"\*literal\*", "*literal*"), - (r"a\_b, $x\_1 \* y$, `c\_d` and \\*", r"a_b, $x\_1 \* y$, `c\_d` and \\*"), + (r"a\_b, `c\_d` and \\*", r"a_b, `c\_d` and \\*"), ("unmatched **bold", "unmatched **bold"), - ("$x_1 * y_2 * z_3$", "$x_1 * y_2 * z_3$"), ("______", "______"), ("a *\n=\nb", "a *\n=\nb"), (r"``a\_b` c`` and d\_e", r"``a\_b` c`` and d_e"), @@ -360,9 +359,10 @@ def test_normalizes_headings_code_and_inline_html(self, page_size: Size): item.text == "2" and item.formatting.script == Script.SUB for item in doc.texts ) - assert any( - item.text == "Keep the inequality $0x; the answer is 5 or 2+2.", + ), + ( + "about ~5 m and ~10 m, where $x$ is the length", + "about ~5 m and ~10 m, where x is the length", + ), + ( + "| A | B |\n| --- | --- |\n| $x$ | y |", + "| A | B |\n| --- | --- |\n| x | y |", + ), + ("> [!NOTE]\n> Value $x$.", "> [!NOTE]\n> Value x."), + ( + "See www.example.com and $x$.", + "See www.example.com and x.", + ), + ], +) +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 y

\n" + assert full.convert("[name][ref] and $z$") == ( + "

[name][ref] and z

\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 x_{index}

\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
x