From 47d058e974f5fe84e62b8fa9f72375bfddf5fac5 Mon Sep 17 00:00:00 2001 From: Michele Dolfi Date: Tue, 20 Oct 2026 23:19:55 +0200 Subject: [PATCH 1/2] fix(nemotron-parse): bound work on untrusted model output - Cap \multicolumn and \multirow spans before the span grid is built. A span of 10^7 took 13 s and 1.2 GB; larger values exhaust memory. - Read a code span's opening backtick run atomically in _MARKDOWN_ESCAPE_PATTERN. An unclosed run took cubic time (2000 backticks: 15 s) in the Nemotron 3.5 and dots mappers. - Escape LaTeX table cell text before the HTML table is built, and keep only inline formatting tags. Cell text could add rows, cells and spans to the table. Signed-off-by: Michele Dolfi --- docling/utils/dots_utils.py | 5 +++- docling/utils/nemotron_parse_v3_5_utils.py | 32 ++++++++++++++++++---- tests/test_dots_parser.py | 3 ++ tests/test_nemotron_parse_v3_5.py | 23 ++++++++++++++++ 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/docling/utils/dots_utils.py b/docling/utils/dots_utils.py index 0002182379..637b5d1441 100644 --- a/docling/utils/dots_utils.py +++ b/docling/utils/dots_utils.py @@ -62,8 +62,11 @@ 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 backtracking `(`+)` takes cubic time on a long unclosed run. _MARKDOWN_ESCAPE_PATTERN = re.compile( - rf"{_LITERAL_INLINE_PATTERN.pattern}|(`+)[\s\S]*?(?", "textasciitilde": "~", "linebreak": "
", "mbox": "", - "&": "&", } +# Inline tags that a table cell keeps as markup; all other text is escaped. +_CELL_INLINE_TAG = re.compile(r"", re.IGNORECASE) _CHART_OPEN = re.compile(r'') _CHART_TABLE_KEY = re.compile(r"table\[(\w+)\]") # `\(` and `\)` are not math symbols, so in a formula they are delimiters; `\\` stays. @@ -258,7 +260,25 @@ def _unwrap_command(text: str, command: str, arg_count: int) -> list[str] | None def _span(value: str) -> int: - return int(value) if value.strip().isdigit() and int(value) > 0 else 1 + digits = value.strip().lstrip("0") + if not digits.isdecimal(): + return 1 + # Above the limit _add_html_table rejects the table; do not build a huge grid first. + if len(digits) > len(str(_MAX_TABLE_GRID_CELLS)): + return _MAX_TABLE_GRID_CELLS + 1 + return min(int(digits), _MAX_TABLE_GRID_CELLS + 1) + + +def _escape_cell_markup(text: str) -> str: + """Escape cell text so that it cannot change the table; keep inline tags.""" + parts: list[str] = [] + pos = 0 + for match in _CELL_INLINE_TAG.finditer(text): + parts.append(html.escape(text[pos : match.start()], quote=False)) + parts.append(match.group()) + pos = match.end() + parts.append(html.escape(text[pos:], quote=False)) + return "".join(parts) def _parse_latex_cell(text: str) -> _LatexCell: @@ -332,7 +352,7 @@ def latex_tabular_to_html(latex: str) -> str: scope = ' scope="col"' if cell.header else "" html_cells.append( f'<{tag}{scope} rowspan="{cell.row_span}" colspan="{cell.col_span}">' - f"{_markdown_inline_html(cell.text)}" + f"{_markdown_inline_html(_escape_cell_markup(cell.text))}" ) html_rows.append(f"{''.join(html_cells)}") return f"{''.join(html_rows)}
" diff --git a/tests/test_dots_parser.py b/tests/test_dots_parser.py index 36bd62127c..f33aaed42b 100644 --- a/tests/test_dots_parser.py +++ b/tests/test_dots_parser.py @@ -122,6 +122,9 @@ def test_markdown_emphasis_preserves_math_code_and_html(page_size: Size): ("$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"), + # An unclosed run took cubic time before the code-span match was atomic. + ("`" * 5000 + r" a\_b", "`" * 5000 + " a_b"), ], ) def test_literal_markers_stay_literal(text: str, expected: str, page_size: Size): diff --git a/tests/test_nemotron_parse_v3_5.py b/tests/test_nemotron_parse_v3_5.py index 9eac871506..1884165605 100644 --- a/tests/test_nemotron_parse_v3_5.py +++ b/tests/test_nemotron_parse_v3_5.py @@ -187,6 +187,29 @@ def test_table_maps_multirow_multicolumn_and_thead() -> None: assert [cells[2, col].text for col in range(4)] == ["Kin $", "145", "", "5 % < 6"] +def test_table_with_oversized_span_is_rejected_without_building_it() -> None: + # Building the span grid used memory linear in the predicted span value. + table = r"\begin{tabular}{c}\multicolumn{1000000000}{c}{x}\\\end{tabular}" + + data = _parse(_block("Table", table)).tables[0].data + + assert (data.num_rows, data.num_cols) == (0, 0) + + +def test_table_cell_markup_stays_cell_text() -> None: + table = ( + r"\begin{tabular}{cc}" + r'ab & x2 \& y\\' + r"\end{tabular}" + ) + + data = _parse(_block("Table", table)).tables[0].data + + assert (data.num_rows, data.num_cols) == (1, 2) + assert data.grid[0][0].text == 'ab' + assert data.grid[0][1].text == "x2 & y" + + def test_table_cell_under_multirow_with_text_ends_the_span() -> None: table = r"\begin{tabular}{cc}\multirow{3}{*}{A} & 1\\ \mbox{} & 2\\ B & 3\\\end{tabular}" From ab7c550e735b5ceb9c8055495197122c17fefdca Mon Sep 17 00:00:00 2001 From: Christoph Auer Date: Thu, 22 Oct 2026 14:38:30 +0200 Subject: [PATCH 2/2] fix(nemotron-parse): keep escaped LaTeX brackets as cell text - Map LaTeX text commands and escape cell text in one pass. \textless{}b\textgreater{} became a tag, because the commands became raw < and > before the escape. - Keep as an inline cell tag. - Stop a row above _MAX_TABLE_GRID_CELLS columns before the span grid is built. 2000 spans of 1000 columns used 511 MB. Signed-off-by: Christoph Auer --- docling/utils/nemotron_parse_v3_5_utils.py | 32 +++++++++++++--------- tests/test_nemotron_parse_v3_5.py | 12 ++++++++ 2 files changed, 31 insertions(+), 13 deletions(-) diff --git a/docling/utils/nemotron_parse_v3_5_utils.py b/docling/utils/nemotron_parse_v3_5_utils.py index f4b673c904..9d87142f3c 100644 --- a/docling/utils/nemotron_parse_v3_5_utils.py +++ b/docling/utils/nemotron_parse_v3_5_utils.py @@ -80,14 +80,18 @@ r"|\\([%&#$])" ) _LATEX_TEXT_REPLACEMENTS = { - "textless": "<", - "textgreater": ">", + "textless": "<", + "textgreater": ">", "textasciitilde": "~", "linebreak": "
", "mbox": "", + "&": "&", } -# Inline tags that a table cell keeps as markup; all other text is escaped. -_CELL_INLINE_TAG = re.compile(r"", re.IGNORECASE) +# Inline tags that a table cell keeps as markup, and LaTeX text commands that it maps; +# all other text is escaped. +_CELL_MARKUP = re.compile( + rf"{_LATEX_TEXT.pattern}|(?i:)" +) _CHART_OPEN = re.compile(r'') _CHART_TABLE_KEY = re.compile(r"table\[(\w+)\]") @@ -259,9 +263,14 @@ def _escape_cell_markup(text: str) -> str: """Escape cell text so that it cannot change the table; keep inline tags.""" parts: list[str] = [] pos = 0 - for match in _CELL_INLINE_TAG.finditer(text): + for match in _CELL_MARKUP.finditer(text): parts.append(html.escape(text[pos : match.start()], quote=False)) - parts.append(match.group()) + command = match.group(1) or match.group(2) + parts.append( + match.group() + if command is None + else _LATEX_TEXT_REPLACEMENTS.get(command, command) + ) pos = match.end() parts.append(html.escape(text[pos:], quote=False)) return "".join(parts) @@ -279,12 +288,7 @@ def _parse_latex_cell(text: str) -> _LatexCell: header, text = True, args[0] else: break - text = _LATEX_TEXT.sub( - lambda match: _LATEX_TEXT_REPLACEMENTS.get( - match.group(1) or match.group(2), match.group(2) or "" - ), - text, - ) + text = _escape_cell_markup(text) return _LatexCell( text=text.strip(), row_span=row_span, col_span=col_span, header=header ) @@ -324,6 +328,8 @@ def latex_tabular_to_html(latex: str) -> str: continue # Text under a multirow: the predicted row span was too long. owner[0].row_span = row_index - owner[1] + if col + cell.col_span > _MAX_TABLE_GRID_CELLS: + raise ValueError(f"LaTeX table exceeds {_MAX_TABLE_GRID_CELLS} columns") for owned_col in range(col, col + cell.col_span): owners[owned_col] = (cell, row_index) cells.append(cell) @@ -338,7 +344,7 @@ def latex_tabular_to_html(latex: str) -> str: scope = ' scope="col"' if cell.header else "" html_cells.append( f'<{tag}{scope} rowspan="{cell.row_span}" colspan="{cell.col_span}">' - f"{_markdown_inline_html(_escape_cell_markup(cell.text))}" + f"{_markdown_inline_html(cell.text)}" ) html_rows.append(f"{''.join(html_cells)}") return f"{''.join(html_rows)}
" diff --git a/tests/test_nemotron_parse_v3_5.py b/tests/test_nemotron_parse_v3_5.py index 1884165605..fbdb3b29ea 100644 --- a/tests/test_nemotron_parse_v3_5.py +++ b/tests/test_nemotron_parse_v3_5.py @@ -210,6 +210,18 @@ def test_table_cell_markup_stays_cell_text() -> None: assert data.grid[0][1].text == "x2 & y" +def test_table_cell_latex_angle_brackets_stay_cell_text() -> None: + table = ( + r"\begin{tabular}{cc}" + r"\textless{}b\textgreater{}x\textless{}/b\textgreater{} & a\\" + r"\end{tabular}" + ) + + data = _parse(_block("Table", table)).tables[0].data + + assert [cell.text for cell in data.grid[0]] == ["x", "a None: table = r"\begin{tabular}{cc}\multirow{3}{*}{A} & 1\\ \mbox{} & 2\\ B & 3\\\end{tabular}"