Skip to content
Open
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
81 changes: 10 additions & 71 deletions docling/utils/dots_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -56,99 +57,37 @@
re.DOTALL,
)
_HTML_TAG_PATTERN = re.compile(
r"</?(?:a|b|br|code|del|div|em|i|p|s|span|strong|sub|sup|table|tbody|td|tfoot|th|thead|tr|u)(?=[\s/>])",
r"</?(?:a|b|br|code|del|div|em|i|math|p|s|span|strong|sub|sup|table|tbody|td|tfoot|th|thead|tr|u)(?=[\s/>])",
re.IGNORECASE,
)
_LITERAL_INLINE_PATTERN = re.compile(
r"(?<!\\)(?:\$\$[\s\S]*?(?<!\\)\$\$|\$[^\n$]+?(?<!\\)\$"
r"|\\\([\s\S]*?\\\)|\\\[[\s\S]*?\\\])"
r"|<code\b[^>]*>[\s\S]*?</code>",
rf"{_MATH.pattern}|<code\b[^>]*>[\s\S]*?</code>",
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"|(?<!`)(?=(`+))\1[\s\S]*?(?<!`)\1(?!`)|\\([*_])|\\\\",
r"|(?<!`)(?=(?P<ticks>`+))(?P=ticks)[\s\S]*?(?<!`)(?P=ticks)(?!`)"
r"|\\(?P<escape>[*_])|\\\\",
re.IGNORECASE,
)


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("<code")
else marko.HTMLRenderer.escape_html(element.children)
)

markdown = marko.Markdown(
parser=InlineParser,
extensions=[
MarkoExtension(
elements=[Paragraph, LiteralInline, CodeSpan],
renderer_mixins=[LiteralRenderer],
)
],
)
rendered = markdown.convert(text)
if "<strong>" in rendered or "<em>" 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 <br>.
html_text = re.sub(r"(?:<br />)?\n", "<br>", rendered.strip())
else:
Expand Down
121 changes: 121 additions & 0 deletions docling/utils/markdown_math.py
Original file line number Diff line number Diff line change
@@ -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"(?<!\\)(?:\$\$([\s\S]*?)(?<!\\)\$\$"
# A dollar before a digit starts an amount, not a closing math delimiter.
r"|(?<!\$)\$(?![\s$])([^\n$]*?)(?<![\\\s])\$(?![\d$])"
r"|\\\(([\s\S]*?)\\\)|\\\[([\s\S]*?)\\\])"
)


class _MarkdownParsers(local):
def __init__(self) -> 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"<code\b[^>]*>[\s\S]*?</code>", 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"<math{attrs}>{html.escape(latex.strip())}</math>"

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
31 changes: 27 additions & 4 deletions docling/utils/nemotron_parse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,13 +24,16 @@
Size,
TableData,
TabularChartMetaField,
TextItem,
)
from PIL import Image as PILImage

from docling.backend.latex_backend import LatexDocumentBackend
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,
Expand Down Expand Up @@ -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 "<math" not in rendered:
return
item.text = ""
Comment thread
PierreMesure marked this conversation as resolved.
# The native region owns the coordinates; derived formula runs do not.
_add_html_fragment(document, rendered, label=DocItemLabel.TEXT, parent=item)


def _markdown_cell_to_text(markdown: str) -> str:
"""Parse Markdown cell content using Docling's Markdown backend."""
try:
Expand Down Expand Up @@ -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
Expand All @@ -316,31 +331,39 @@ 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 = (
strip_math_delimiters(raw_text)
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)
Expand Down
Loading
Loading