diff --git a/docling/utils/dots_utils.py b/docling/utils/dots_utils.py index d944eb9ad7..a165c28d12 100644 --- a/docling/utils/dots_utils.py +++ b/docling/utils/dots_utils.py @@ -66,8 +66,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]*?(?)" +) _CHART_OPEN = re.compile(r'') _CHART_TABLE_KEY = re.compile(r"table\[(\w+)\]") @@ -244,7 +250,30 @@ 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_MARKUP.finditer(text): + parts.append(html.escape(text[pos : match.start()], quote=False)) + 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) def _parse_latex_cell(text: str) -> _LatexCell: @@ -259,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 ) @@ -304,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) 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..fbdb3b29ea 100644 --- a/tests/test_nemotron_parse_v3_5.py +++ b/tests/test_nemotron_parse_v3_5.py @@ -187,6 +187,41 @@ 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_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}"