diff --git a/deepdoc/parser/opendataloader_parser.py b/deepdoc/parser/opendataloader_parser.py index 33d54463aa..d591ad737f 100644 --- a/deepdoc/parser/opendataloader_parser.py +++ b/deepdoc/parser/opendataloader_parser.py @@ -1,5 +1,7 @@ from __future__ import annotations +import html +import json import logging import os import re @@ -83,7 +85,7 @@ def _bbox_from_element(el: dict) -> Optional[_BBox]: def _iter_elements(node: Any) -> Iterable[dict]: if isinstance(node, dict): - if "type" in node and ("content" in node or "text" in node or "cells" in node): + if "type" in node and ("content" in node or "text" in node or "cells" in node or "table_cells" in node): yield node for v in node.values(): yield from _iter_elements(v) @@ -117,9 +119,198 @@ def _element_html(el: dict) -> str: v = el.get(key) if isinstance(v, str) and v.strip(): return v + # Docling tables: rebuild HTML from table_cells + cells = el.get("table_cells") + if isinstance(cells, list) and cells: + num_rows = el.get("num_rows") or 0 + num_cols = el.get("num_cols") or 0 + if num_rows > 0 and num_cols > 0: + grid: list[list[dict]] = [[{} for _ in range(num_cols)] for _ in range(num_rows)] + for c in cells: + if not isinstance(c, dict): + continue + r = c.get("start_row_offset_idx", 0) + col = c.get("start_col_offset_idx", 0) + row_span = max(1, c.get("row_span", 1) or 1) + col_span = max(1, c.get("col_span", 1) or 1) + text = html.escape(c.get("text", "")) + if 0 <= r < num_rows and 0 <= col < num_cols: + grid[r][col] = {"text": text, "row_span": row_span, "col_span": col_span} + # Mark spanned positions as occupied so they are skipped + for dr in range(row_span): + for dc in range(col_span): + if dr == 0 and dc == 0: + continue + rr, cc = r + dr, col + dc + if 0 <= rr < num_rows and 0 <= cc < num_cols: + grid[rr][cc] = {"_skip": True} + rows_html = [] + for row in grid: + cols = [] + for cell_data in row: + if cell_data.get("_skip"): + continue + if not cell_data: + continue + attrs = "" + if cell_data.get("row_span", 1) > 1: + attrs += f' rowspan="{cell_data["row_span"]}"' + if cell_data.get("col_span", 1) > 1: + attrs += f' colspan="{cell_data["col_span"]}"' + cols.append(f"{cell_data['text']}") + rows_html.append("" + "".join(cols) + "") + return "" + "".join(rows_html) + "
" return "" +# --------------------------------------------------------------------------- +# DoclingDocument → intermediate format adapter +# --------------------------------------------------------------------------- +# The opendataloader-pdf-hybrid service (/v1/convert/file) returns a +# DoclingDocument JSON whose structure differs from the original OpenDataLoader +# format. This adapter normalises it so _transfer_from_json can consume it. +# --------------------------------------------------------------------------- + +_DOCLING_TEXT_LABELS = { + "paragraph", + "text", + "section_header", + "title", + "heading", + "list_item", + "code", + "caption", + "footnote", + "page_header", + "page_footer", +} +_DOCLING_TABLE_LABELS = {"table"} +_DOCLING_PICTURE_LABELS = {"picture", "figure"} +_DOCLING_FORMULA_LABELS = {"formula"} + + +def _normalize_docling_response(doc: dict, page_heights: dict[int, float] | None = None) -> list[dict]: + """Convert a DoclingDocument JSON into the intermediate element format + that _iter_elements / _transfer_from_json / _bbox_from_element expect. + + Each returned element dict has: + - "type": element type string (table, picture, paragraph, section_header, …) + - "text": or "content" for text content + - "page_number": page number + - "bounding_box": [left, bottom, right, top] flat array in PDF points + - "html": rebuilt HTML for tables (optional) + - "cells": / "table_cells": for tables (optional) + + Parameters: + doc: the DoclingDocument JSON dict. + page_heights: optional mapping from page_no (1-based) to page height + in PDF points. When provided, Y coordinates from + Docling's TOPLEFT origin are flipped to bottom-left + origin. When absent the raw values are used (may + produce incorrect positions). + """ + if page_heights is None: + page_heights = {} + elements: list[dict] = [] + + # --- texts --- + for item in doc.get("texts") or []: + if not isinstance(item, dict): + continue + lbl = (item.get("label") or "").lower() + if lbl in {"page_header", "page_footer"}: + continue + el_type = lbl # preserve original label (section_header, paragraph, etc.) + text = item.get("text", "") + page_no, bbox_arr = _extract_docling_prov(item, page_heights) + el: dict[str, Any] = {"type": el_type, "text": text} + if page_no is not None: + el["page_number"] = page_no + if bbox_arr is not None: + el["bounding_box"] = bbox_arr + elements.append(el) + + # --- tables --- + for item in doc.get("tables") or []: + if not isinstance(item, dict): + continue + el_type = "table" + page_no, bbox_arr = _extract_docling_prov(item, page_heights) + el: dict[str, Any] = {"type": el_type, "table_cells": item.get("table_cells", []), "num_rows": item.get("num_rows", 0), "num_cols": item.get("num_cols", 0)} + if page_no is not None: + el["page_number"] = page_no + if bbox_arr is not None: + el["bounding_box"] = bbox_arr + elements.append(el) + + # --- pictures --- + for item in doc.get("pictures") or []: + if not isinstance(item, dict): + continue + el_type = "picture" + # Try to get caption from embedded data or referenced captions + caption = "" + # Some picture items may have a "data" field with description + data = item.get("data") + if isinstance(data, dict): + caption = data.get("description", "") + page_no, bbox_arr = _extract_docling_prov(item, page_heights) + el: dict[str, Any] = {"type": el_type, "content": caption} + if page_no is not None: + el["page_number"] = page_no + if bbox_arr is not None: + el["bounding_box"] = bbox_arr + elements.append(el) + + return elements + + +def _extract_docling_prov(item: dict, page_heights: dict[int, float] | None = None) -> tuple[Optional[int], Optional[list[float]]]: + """Extract page number and bounding box from a Docling item's prov list. + + Docling prov format: [{"page_no": int, "bbox": {"l": float, "t": float, + "r": float, "b": float, "coord_origin": "TOPLEFT"}}] + + Returns (page_no, [left, bottom, right, top]) in PDF points with + bottom-left origin (matching OpenDataLoader convention). + + When a page_heights mapping is provided, Docling's TOPLEFT Y coordinates + are flipped using the page height. Without it the raw values are + returned as-is (which places the bounding box at the wrong vertical + position). + """ + if page_heights is None: + page_heights = {} + prov_list = item.get("prov") + if not isinstance(prov_list, list) or not prov_list: + return None, None + # Use the first provenance entry + prov = prov_list[0] + page_no = prov.get("page_no") + if page_no is None: + return None, None + bbox = prov.get("bbox") + if not isinstance(bbox, dict): + return None, None + left = _as_float(bbox.get("l")) + top_from_top = _as_float(bbox.get("t")) + right = _as_float(bbox.get("r")) + bottom_from_top = _as_float(bbox.get("b")) + if any(v is None for v in (left, top_from_top, right, bottom_from_top)): + return None, None + # Docling uses top-left origin (t = distance from top of page). + # Convert to bottom-left origin using page height when available. + page_height = page_heights.get(int(page_no)) + if page_height is not None: + bottom_to_bottom = page_height - bottom_from_top + top_to_bottom = page_height - top_from_top + else: + # Fallback: raw values (incorrect positions but better than nothing) + bottom_to_bottom = bottom_from_top + top_to_bottom = top_from_top + return int(page_no), [left, bottom_to_bottom, right, top_to_bottom] + + class OpenDataLoaderParser(RAGFlowPdfParser): def __init__(self): self.logger = logging.getLogger(self.__class__.__name__) @@ -353,6 +544,13 @@ class OpenDataLoaderParser(RAGFlowPdfParser): except Exception as e: self.logger.warning(f"[OpenDataLoader] render pages failed: {e}") + # Build page-height lookup for Docling TOPLEFT → bottom-left + # coordinate conversion. Pages are 1-based in Docling. + page_heights: dict[int, float] = {} + for i, img in enumerate(self.page_images): + if img is not None: + page_heights[i + 1] = float(img.size[1]) + # Read PDF bytes for the multipart upload if binary is not None: pdf_bytes = binary if isinstance(binary, (bytes, bytearray)) else binary.getvalue() @@ -365,7 +563,10 @@ class OpenDataLoaderParser(RAGFlowPdfParser): if callback: callback(0.1, f"[OpenDataLoader] Sending '{filename}' to service") - form_data: dict[str, str] = {} + # Docling Fast Server endpoint: POST /v1/convert/file + # - files: PDF file (multipart/form-data) + # - page_ranges: optional, e.g. "1-5" + form_data: dict[str, str] = {"to_formats": '["json", "md"]'} if hybrid: form_data["hybrid"] = hybrid if image_output: @@ -377,10 +578,10 @@ class OpenDataLoaderParser(RAGFlowPdfParser): last_exc: Exception | None = None for attempt in range(1, 4): try: - self.logger.info(f"[OpenDataLoader] POST {self.api_url}/file_parse for '{filename}' (attempt {attempt})") + self.logger.info(f"[OpenDataLoader] POST {self.api_url}/v1/convert/file for '{filename}' (attempt {attempt})") resp = requests.post( - url=f"{self.api_url}/file_parse", - files={"file": (filename, pdf_bytes, "application/pdf")}, + url=f"{self.api_url}/v1/convert/file", + files={"files": (filename, pdf_bytes, "application/pdf")}, data=form_data, headers=headers, timeout=self.timeout, @@ -397,18 +598,41 @@ class OpenDataLoaderParser(RAGFlowPdfParser): if callback: callback(0.7, "[OpenDataLoader] Processing response") - # Service response structure: - # { - # "json_doc": {...} | null, # structured parse tree (preferred) - # "md_text": "..." | null # markdown fallback when json_doc is absent - # } - json_doc = result.get("json_doc") - md_text = result.get("md_text") + # /v1/convert/file response structure (nested): + # {"status": ..., "document": {"json_content": {DoclingDocument}}, "processing_time": ...} + json_doc = None + md_text = None + if isinstance(result, dict): + doc = result.get("document") + inner = None + if isinstance(doc, dict): + inner = doc.get("json_content") + if isinstance(inner, str): + try: + inner = json.loads(inner) + except (json.JSONDecodeError, TypeError): + self.logger.warning("[OpenDataLoader] json_content is a string but not valid JSON") + inner = None + + # Use DoclingDocument if found at inner, otherwise fall back + if isinstance(inner, dict) and ("texts" in inner or "tables" in inner or "pictures" in inner): + json_doc = _normalize_docling_response(inner, page_heights) + elif isinstance(doc, dict) and ("texts" in doc or "tables" in doc or "pictures" in doc): + json_doc = _normalize_docling_response(doc, page_heights) + elif isinstance(result, dict) and ("texts" in result or "tables" in result or "pictures" in result): + json_doc = _normalize_docling_response(result, page_heights) + else: + # Legacy / flat format + json_doc = result.get("json_doc") + md_text = result.get("md_text") + elif isinstance(result, str): + md_text = result sections: list[tuple[str, ...]] = [] tables: list = [] if json_doc is not None: sections, tables = self._transfer_from_json(json_doc, parse_method=parse_method) + self.logger.info(f"[OpenDataLoader] Extracted {len(sections)} sections, {len(tables)} tables") if not sections and md_text: sections = self._sections_from_markdown(md_text, parse_method=parse_method) diff --git a/test/unit_test/deepdoc/parser/test_opendataloader_parser.py b/test/unit_test/deepdoc/parser/test_opendataloader_parser.py index 68c63a256e..48628a53da 100644 --- a/test/unit_test/deepdoc/parser/test_opendataloader_parser.py +++ b/test/unit_test/deepdoc/parser/test_opendataloader_parser.py @@ -114,10 +114,22 @@ class TestCheckInstallation: class TestParsePdf: - def _mock_response(self, json_doc=None, md_text=None) -> mock.MagicMock: + def _mock_response(self, json_doc=None, md_text=None, docling=None) -> mock.MagicMock: resp = mock.MagicMock() resp.raise_for_status = mock.MagicMock() - resp.json.return_value = {"json_doc": json_doc, "md_text": md_text} + if docling is not None: + # Docling nested format: {"status": ..., "document": {"json_content": {DoclingDocument}}} + resp.json.return_value = { + "status": "success", + "document": {"json_content": docling}, + "processing_time": 1.0, + "errors": [], + "failed_pages": [], + "timings": {}, + } + else: + # Legacy flat format + resp.json.return_value = {"json_doc": json_doc, "md_text": md_text} return resp def test_raises_when_api_url_not_set(self, tmp_path): @@ -128,7 +140,7 @@ class TestParsePdf: with pytest.raises(RuntimeError, match="OPENDATALOADER_APISERVER"): p.parse_pdf(filepath=str(pdf)) - def test_posts_to_file_parse_endpoint(self, tmp_path): + def test_posts_to_v1_convert_file_endpoint(self, tmp_path): p = _make_parser() pdf = tmp_path / "doc.pdf" pdf.write_bytes(b"%PDF-dummy") @@ -139,7 +151,7 @@ class TestParsePdf: mock_post.assert_called_once() call_kwargs = mock_post.call_args - assert "/file_parse" in call_kwargs.kwargs.get("url", call_kwargs.args[0] if call_kwargs.args else "") + assert "/v1/convert/file" in call_kwargs.kwargs.get("url", call_kwargs.args[0] if call_kwargs.args else "") def test_binary_bytes_sent_as_multipart(self, tmp_path): p = _make_parser() @@ -150,8 +162,8 @@ class TestParsePdf: p.parse_pdf(filepath="file.pdf", binary=pdf_bytes) files_arg = mock_post.call_args.kwargs.get("files", {}) - assert "file" in files_arg - _, sent_bytes, mime = files_arg["file"] + assert "files" in files_arg + _, sent_bytes, mime = files_arg["files"] assert sent_bytes == pdf_bytes assert mime == "application/pdf" @@ -164,7 +176,7 @@ class TestParsePdf: p.parse_pdf(filepath="file.pdf", binary=io.BytesIO(pdf_bytes)) files_arg = mock_post.call_args.kwargs.get("files", {}) - _, sent_bytes, _ = files_arg["file"] + _, sent_bytes, _ = files_arg["files"] assert sent_bytes == pdf_bytes def test_json_doc_response_returns_sections(self, tmp_path): @@ -182,6 +194,25 @@ class TestParsePdf: assert any("Hello from JSON" in s[0] for s in sections) + def test_docling_nested_response_returns_sections(self, tmp_path): + p = _make_parser() + docling = { + "texts": [ + {"label": "paragraph", "text": "Hello from Docling", "prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 100, "b": 20, "coord_origin": "TOPLEFT"}}]}, + {"label": "section_header", "text": "A Title", "prov": [{"page_no": 1, "bbox": {"l": 0, "t": 20, "r": 200, "b": 40, "coord_origin": "TOPLEFT"}}]}, + ], + "tables": [], + "pictures": [], + } + resp = self._mock_response(docling=docling) + + with mock.patch.object(p, "__images__"), mock.patch("requests.post", return_value=resp): + sections, tables = p.parse_pdf(filepath="doc.pdf", binary=b"%PDF", parse_method="pipeline") + + assert len(sections) >= 2 + assert any("Hello from Docling" in s[0] for s in sections) + assert any("A Title" in s[0] for s in sections) + def test_md_text_fallback_when_no_json(self, tmp_path): p = _make_parser() resp = self._mock_response(json_doc=None, md_text="# Markdown heading\n\nBody text.") @@ -231,6 +262,8 @@ class TestParsePdf: p.parse_pdf(filepath="doc.pdf", binary=b"%PDF") data_arg = mock_post.call_args.kwargs.get("data", {}) + # to_formats is always sent + assert "to_formats" in data_arg assert "hybrid" not in data_arg assert "image_output" not in data_arg assert "sanitize" not in data_arg @@ -318,3 +351,112 @@ class TestCrop: p.page_images = [] result = p.crop("@@1\t10.0\t100.0\t20.0\t80.0##", need_position=True) assert result == (None, None) + + +# --------------------------------------------------------------------------- +# _extract_docling_prov — coordinate conversion +# --------------------------------------------------------------------------- + + +class TestExtractDoclingProv: + """Unit tests for _extract_docling_prov's TOPLEFT → bottom-left conversion.""" + + def test_flips_y_with_page_height(self): + """When page_height is provided, Docling TOPLEFT coords are flipped.""" + item = { + "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 100, "r": 200, "b": 150, "coord_origin": "TOPLEFT"}}], + } + page_heights = {1: 842.0} # A4 portrait in PDF points + page_no, bbox = _mod._extract_docling_prov(item, page_heights) + assert page_no == 1 + # bottom = 842 - 150 = 692, top = 842 - 100 = 742 + assert bbox == [10.0, 692.0, 200.0, 742.0] + + def test_falls_back_to_raw_when_no_page_height(self): + """Without page_height, raw TOPLEFT values are returned as-is.""" + item = { + "prov": [{"page_no": 1, "bbox": {"l": 10, "t": 100, "r": 200, "b": 150, "coord_origin": "TOPLEFT"}}], + } + page_no, bbox = _mod._extract_docling_prov(item, {}) + assert page_no == 1 + # no flip → raw t=100 as top, b=150 as bottom + assert bbox == [10.0, 150.0, 200.0, 100.0] + + def test_returns_none_when_no_prov(self): + assert _mod._extract_docling_prov({}, {}) == (None, None) + + def test_returns_none_when_missing_coords(self): + item = {"prov": [{"page_no": 1, "bbox": {"l": 10}}]} + assert _mod._extract_docling_prov(item, {1: 842.0}) == (None, None) + + +# --------------------------------------------------------------------------- +# _element_html — table HTML rebuild with row_span/col_span and escaping +# --------------------------------------------------------------------------- + + +class TestElementHtml: + """Unit tests for _element_html's Docling table HTML generation.""" + + def test_basic_table_without_spans(self): + el = { + "table_cells": [ + {"start_row_offset_idx": 0, "start_col_offset_idx": 0, "text": "A", "row_span": 1, "col_span": 1}, + {"start_row_offset_idx": 0, "start_col_offset_idx": 1, "text": "B", "row_span": 1, "col_span": 1}, + {"start_row_offset_idx": 1, "start_col_offset_idx": 0, "text": "C", "row_span": 1, "col_span": 1}, + {"start_row_offset_idx": 1, "start_col_offset_idx": 1, "text": "D", "row_span": 1, "col_span": 1}, + ], + "num_rows": 2, + "num_cols": 2, + } + html = _mod._element_html(el) + assert "A" in html + assert "D" in html + assert "" in html + + def test_table_with_row_span(self): + el = { + "table_cells": [ + {"start_row_offset_idx": 0, "start_col_offset_idx": 0, "text": "Merged", "row_span": 2, "col_span": 1}, + {"start_row_offset_idx": 0, "start_col_offset_idx": 1, "text": "B", "row_span": 1, "col_span": 1}, + {"start_row_offset_idx": 1, "start_col_offset_idx": 1, "text": "D", "row_span": 1, "col_span": 1}, + ], + "num_rows": 2, + "num_cols": 2, + } + html = _mod._element_html(el) + assert 'rowspan="2"' in html + # Row 1 col 0 is spanned, should not have its own
+ assert html.count("alert(1)", "row_span": 1, "col_span": 1}, + ], + "num_rows": 1, + "num_cols": 1, + } + html = _mod._element_html(el) + assert "<script>" in html + assert "