diff --git a/docling/backend/json/docling_json_backend.py b/docling/backend/json/docling_json_backend.py index fd2a495eb5..e60192015d 100644 --- a/docling/backend/json/docling_json_backend.py +++ b/docling/backend/json/docling_json_backend.py @@ -95,7 +95,10 @@ def _get_doc_or_err(self) -> Union[DoclingDocument, Exception]: else: raise RuntimeError(f"Unexpected: {type(self.path_or_stream)=}") doc = DoclingDocument.model_validate_json(json_data=json_data) - if not self.options.enable_local_fetch: + if self.options.enable_local_fetch: + # docling-core reads local image paths only after this opt-in. + doc.load_local_images() + else: self._drop_local_image_refs(doc) return doc except Exception as e: diff --git a/docling/pipeline/vlm_pipeline.py b/docling/pipeline/vlm_pipeline.py index 88de8a19aa..0bf6cc0bba 100644 --- a/docling/pipeline/vlm_pipeline.py +++ b/docling/pipeline/vlm_pipeline.py @@ -69,9 +69,21 @@ from docling.pipeline.base_pipeline import PaginatedPipeline from docling.utils.deepseekocr_utils import parse_deepseekocr_markdown from docling.utils.profiling import TimeRecorder +from docling.utils.vlm_utils import normalize_section_header_levels _log = logging.getLogger(__name__) _DOCLANG_OPEN_RE = re.compile(r"]*)?>") +# Formats whose mappers keep raw heading depths per page; the joined document is normalized. +_RAW_HEADING_DEPTH_FORMATS = frozenset( + { + ResponseFormat.CHANDRA_HTML, + ResponseFormat.DEEPSEEKOCR_MARKDOWN, + ResponseFormat.DOTS_JSON, + ResponseFormat.NEMOTRON_PARSE_V2, + ResponseFormat.NEMOTRON_PARSE_V3_5, + ResponseFormat.UNLIMITED_OCR_MARKDOWN, + } +) class VlmPipeline(PaginatedPipeline): @@ -353,7 +365,7 @@ def _build_document(self, conv_res: ConversionResult) -> ConversionResult: for page_no in expected_page_nos if page_no in processed_page_nos ] - conv_res.document = self._concatenate_page_documents( + conv_res.document = self._join_page_documents( [ (page_no, page_documents[page_no]) for page_no in expected_page_nos @@ -362,6 +374,15 @@ def _build_document(self, conv_res: ConversionResult) -> ConversionResult: ) return conv_res + def _join_page_documents( + self, page_documents: list[tuple[int, DoclingDocument]] + ) -> DoclingDocument: + document = self._concatenate_page_documents(page_documents) + if self._response_format() in _RAW_HEADING_DEPTH_FORMATS: + # Once per document, so a heading depth has one level on all pages. + normalize_section_header_levels(document) + return document + def _process_page_batch( self, *, @@ -525,6 +546,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) elif response_format == ResponseFormat.UNLIMITED_OCR_MARKDOWN: from docling.utils.deepseekocr_utils import parse_unlimited_ocr_markdown @@ -535,6 +557,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) elif response_format == ResponseFormat.MARKDOWN: document = self._convert_text_page( @@ -554,6 +577,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) except ValueError as exc: conv_res.errors.append( @@ -585,6 +609,7 @@ def _finalize_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page.image, + normalize_heading_levels=False, ) elif response_format == ResponseFormat.MINERU2: from docling.utils.mineru_utils import parse_mineru2 @@ -703,6 +728,7 @@ def _dots_page_document( filename=conv_res.input.file.name or "file", page_image=page_image, model_image_size=model_image_size, + normalize_heading_levels=False, ) def _nemotron_parse_v2_page_document( @@ -740,6 +766,7 @@ def _nemotron_parse_v2_page_document( page_no=page.page_no, filename=conv_res.input.file.name or "file", page_image=page_image, + normalize_heading_levels=False, ) def _extract_code_block(self, text: str) -> str: diff --git a/docling/utils/chandra_utils.py b/docling/utils/chandra_utils.py index d841630c88..ece606d134 100644 --- a/docling/utils/chandra_utils.py +++ b/docling/utils/chandra_utils.py @@ -41,6 +41,7 @@ from pydantic import AnyUrl, ValidationError from docling.utils.code_language import detect_code_language +from docling.utils.vlm_utils import normalize_section_header_levels _log = logging.getLogger(__name__) @@ -435,6 +436,7 @@ def _emit_runs( run.text = " ".join(run.text.split()) if not merged: return + before = len(self.doc.texts) layer = ( ContentLayer.FURNITURE if label in {DocItemLabel.PAGE_HEADER, DocItemLabel.PAGE_FOOTER} @@ -501,6 +503,10 @@ def _emit_runs( ) if run_label == DocItemLabel.FIELD_VALUE: item.kind = "fillable" + for item in self.doc.texts[before:]: + if isinstance(item, SectionHeaderItem): + # Without , a section header has raw depth 2, like "##". + item.level = 2 def walk( self, @@ -620,6 +626,7 @@ def _block( if re.fullmatch(r"h[1-6]", node.tag): for item in self.doc.texts[before:]: if isinstance(item, SectionHeaderItem): + # Raw markup depth, normalized per document later. item.level = int(node.tag[1]) def _list(self, node: _Element, parent: NodeItem | None) -> None: @@ -796,6 +803,8 @@ def parse_chandra_html( page_no: int, filename: str = "file", page_image: PILImage.Image | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Map Chandra layout blocks and their HTML contents to document primitives. @@ -803,7 +812,9 @@ def parse_chandra_html( block; derived children remain unlocated unless they declare their own bbox. Bare HTML is recovered without provenance; non-HTML responses raise ValueError. Caption/footnote links require explicit nesting. Separate layout blocks are - neither associated nor merged by proximity. + neither associated nor merged by proximity. Set ``normalize_heading_levels`` + to False to keep raw heading depths for a later document-wide + :func:`normalize_section_header_levels`. """ doc = DoclingDocument( name=Path(filename).stem, @@ -855,4 +866,6 @@ def parse_chandra_html( block.attrs.get("data-label") == "Blank-Page" for block in blocks ): raise ValueError("Chandra HTML contains no document content") + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/deepseekocr_utils.py b/docling/utils/deepseekocr_utils.py index 76a39aaca5..1634b2b492 100644 --- a/docling/utils/deepseekocr_utils.py +++ b/docling/utils/deepseekocr_utils.py @@ -24,6 +24,8 @@ from lxml import etree from PIL import Image as PILImage +from docling.utils.vlm_utils import normalize_section_header_levels + _log = logging.getLogger(__name__) @@ -158,23 +160,16 @@ def _collect_annotation_content( break ii += 1 else: - # Original logic for other labels + # Every non-table block keeps its lines up to the next annotation. while i < len(lines): - content_line = lines[i].strip() - if content_line: - if re.match(annotation_pattern, content_line): - break - visited_lines.add(i) + if re.match(annotation_pattern, lines[i].strip()): + break + if i not in visited_lines: content_lines.append(lines[i].rstrip()) - i += 1 - if label_str not in ["figure", "image"]: - break - else: - i += 1 - if content_lines: - break + visited_lines.add(i) + i += 1 - return "\n".join(content_lines), i + return "\n".join(content_lines).strip("\n"), i def _process_annotation_item( @@ -214,7 +209,8 @@ def _process_annotation_item( clean_content = content[hash_count:].strip() page_doc.add_title(text=clean_content, prov=prov) elif label_str == "sub_title": - heading_level = 1 + # Raw markup depth, normalized per document later. + heading_level = 2 clean_content = content if content.startswith("#"): hash_count = 0 @@ -223,8 +219,7 @@ def _process_annotation_item( hash_count += 1 else: break - if hash_count > 1: - heading_level = hash_count - 1 + heading_level = hash_count clean_content = content[hash_count:].strip() page_doc.add_heading(text=clean_content, level=heading_level, prov=prov) else: @@ -237,6 +232,8 @@ def parse_deepseekocr_markdown( page_no: int, filename: str = "file", page_image: Optional[PILImage.Image] = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Parse DeepSeek OCR markdown with label[[x1, y1, x2, y2]] format. @@ -259,6 +256,8 @@ def parse_deepseekocr_markdown( page_no: Page number (1-based). filename: Source filename. page_image: Optional PIL image of the page. + normalize_heading_levels: If False, keep raw heading depths for a later + document-wide :func:`normalize_section_header_levels`. Returns: DoclingDocument with parsed content @@ -389,6 +388,8 @@ def parse_deepseekocr_markdown( label_str, content_text, prov, caption_item, page_doc, label_map ) + if normalize_heading_levels: + normalize_section_header_levels(page_doc) return page_doc @@ -419,6 +420,8 @@ def parse_unlimited_ocr_markdown( page_no: int, filename: str = "file", page_image: Optional[PILImage.Image] = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Parse Unlimited-OCR grounding output into a :class:`DoclingDocument`. @@ -431,4 +434,5 @@ def parse_unlimited_ocr_markdown( page_no=page_no, filename=filename, page_image=page_image, + normalize_heading_levels=normalize_heading_levels, ) diff --git a/docling/utils/dots_utils.py b/docling/utils/dots_utils.py index 0002182379..d944eb9ad7 100644 --- a/docling/utils/dots_utils.py +++ b/docling/utils/dots_utils.py @@ -43,7 +43,11 @@ from docling.utils.chandra_utils import _add_html_fragment, _add_html_table from docling.utils.code_language import detect_code_language -from docling.utils.vlm_utils import link_adjacent_captions, parse_markdown_heading +from docling.utils.vlm_utils import ( + link_adjacent_captions, + normalize_section_header_levels, + parse_markdown_heading, +) _log = logging.getLogger(__name__) @@ -213,6 +217,8 @@ def parse_dots_json( filename: str = "file", page_image: PILImage.Image | None = None, model_image_size: Size | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: """Parse dots.ocr / dots.mocr JSON output into a DoclingDocument. @@ -224,6 +230,8 @@ def parse_dots_json( page_image: Optional PIL image of the page. model_image_size: If provided, bbox pixel coords are rescaled from this resolution to *original_page_size*. + normalize_heading_levels: If False, keep raw heading depths for a later + document-wide :func:`normalize_section_header_levels`. Returns: DoclingDocument populated with parsed elements. @@ -332,7 +340,8 @@ def parse_dots_json( item = doc.add_heading( text="", orig=text, - level=max(1, markdown_level - 1) if markdown_level is not None else 1, + # Raw markup depth, normalized per document later. + level=markdown_level if markdown_level is not None else 2, prov=prov, ) if not _add_inline_text( @@ -371,4 +380,6 @@ def parse_dots_json( ) link_adjacent_captions(doc, block_starts, caption_owners) + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/nemotron_parse_utils.py b/docling/utils/nemotron_parse_utils.py index 134f794201..5079b26b92 100644 --- a/docling/utils/nemotron_parse_utils.py +++ b/docling/utils/nemotron_parse_utils.py @@ -31,6 +31,10 @@ from docling.backend.md_backend import MarkdownDocumentBackend from docling.datamodel.base_models import InputFormat from docling.datamodel.document import InputDocument +from docling.utils.vlm_utils import ( + normalize_section_header_levels, + strip_math_delimiters, +) _log = logging.getLogger(__name__) @@ -255,8 +259,14 @@ def parse_nemotron_parse_v2( page_no: int, filename: str = "file", page_image: PILImage.Image | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: - """Parse one page of Nemotron Parse 2.0 native output.""" + """Parse one page of Nemotron Parse 2.0 native output. + + Set ``normalize_heading_levels`` to False to keep raw heading depths for a + later document-wide :func:`normalize_section_header_levels`. + """ origin = DocumentOrigin(filename=filename, mimetype="text/plain", binary_hash=0) document = DoclingDocument(name=filename.rsplit(".", 1)[0], origin=origin) image_dpi = 72 @@ -311,17 +321,17 @@ def parse_nemotron_parse_v2( ) elif region.label == "Section-header": heading_match = _HEADING_PATTERN.match(raw_text) - level = len(heading_match.group(1)) if heading_match is not None else 1 document.add_heading( text=_markdown_to_text(raw_text), orig=raw_text, - level=level, + # Raw markup depth, normalized per document later. + level=len(heading_match.group(1)) if heading_match is not None else 2, prov=provenance, ) else: label = _TEXT_LABELS.get(region.label, DocItemLabel.TEXT) text = ( - raw_text + strip_math_delimiters(raw_text) if label == DocItemLabel.FORMULA else _markdown_to_text(raw_text) ) @@ -332,4 +342,6 @@ def parse_nemotron_parse_v2( prov=provenance, ) + if normalize_heading_levels: + normalize_section_header_levels(document) return document diff --git a/docling/utils/nemotron_parse_v3_5_utils.py b/docling/utils/nemotron_parse_v3_5_utils.py index 7e34af4c1e..832e5e0e33 100644 --- a/docling/utils/nemotron_parse_v3_5_utils.py +++ b/docling/utils/nemotron_parse_v3_5_utils.py @@ -49,7 +49,12 @@ _markdown_inline_html, _unescape_markdown_literals, ) -from docling.utils.vlm_utils import link_adjacent_captions, parse_markdown_heading +from docling.utils.vlm_utils import ( + link_adjacent_captions, + normalize_section_header_levels, + parse_markdown_heading, + strip_math_delimiters, +) _log = logging.getLogger(__name__) @@ -83,11 +88,6 @@ } _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. -_INLINE_MATH_DELIMITER = re.compile(r"\\\\|\\[()]") -_OUTER_MATH = re.compile( - r"\$\$((?:(?!\$\$).)*)\$\$|\\\[((?:(?!\\[\[\]]).)*)\\\]|\$([^$]*)\$", re.DOTALL -) @dataclass(frozen=True) @@ -150,20 +150,6 @@ def _page_bbox(bbox: tuple[float, float, float, float], size: Size) -> BoundingB ) -def _formula_latex(text: str) -> str: - """Formula LaTeX without math delimiters; the serializer adds its own. - - The model wraps each segment in ``\\(...\\)``, often with text between them - (``(6) \\(x_1=a\\), \\(x_2=b\\)``), and leaves single ones in truncated output. - """ - text = _INLINE_MATH_DELIMITER.sub( - lambda match: match.group() if match.group() == "\\\\" else "", text - ).strip() - while (outer := _OUTER_MATH.fullmatch(text)) is not None: - text = next(group for group in outer.groups() if group is not None).strip() - return text - - def _add_rich_text( doc: DoclingDocument, text: str, @@ -470,8 +456,14 @@ def parse_nemotron_parse_v3_5( page_no: int, filename: str = "file", page_image: PILImage.Image | None = None, + *, + normalize_heading_levels: bool = True, ) -> DoclingDocument: - """Parse one page of Nemotron 3.5 Parse native output.""" + """Parse one page of Nemotron 3.5 Parse native output. + + Set ``normalize_heading_levels`` to False to keep raw heading depths for a + later document-wide :func:`normalize_section_header_levels`. + """ doc = DoclingDocument( name=filename.rsplit(".", 1)[0], origin=DocumentOrigin(filename=filename, mimetype="text/plain", binary_hash=0), @@ -544,14 +536,14 @@ def parse_nemotron_parse_v3_5( ) elif block.label in {"Title", "Section-header"}: clean_text, level = parse_markdown_heading(text) - # Markdown "##" is a level-1 section below the "#" title, as in dots. heading = ( doc.add_title(text="", orig=text, prov=prov) if block.label == "Title" else doc.add_heading( text="", orig=text, - level=max(1, level - 1) if level is not None else 1, + # Raw markup depth, normalized per document later. + level=level if level is not None else 2, prov=prov, ) ) @@ -560,7 +552,7 @@ def parse_nemotron_parse_v3_5( ): heading.text = _unescape_markdown_literals(clean_text) elif block.label == "Formula": - doc.add_formula(text=_formula_latex(text), orig=text, prov=prov) + doc.add_formula(text=strip_math_delimiters(text), orig=text, prov=prov) elif block.label == "Code": doc.add_code( text=text, @@ -579,4 +571,6 @@ def parse_nemotron_parse_v3_5( ) link_adjacent_captions(doc, block_starts, caption_owners) + if normalize_heading_levels: + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/paddleocr_vl_utils.py b/docling/utils/paddleocr_vl_utils.py index 6b3d05241e..8164dd1528 100644 --- a/docling/utils/paddleocr_vl_utils.py +++ b/docling/utils/paddleocr_vl_utils.py @@ -39,7 +39,10 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator from docling.utils.chandra_utils import _add_html_table -from docling.utils.vlm_utils import parse_markdown_heading +from docling.utils.vlm_utils import ( + normalize_section_header_levels, + parse_markdown_heading, +) _log = logging.getLogger(__name__) @@ -253,13 +256,12 @@ def _add_block( text, _ = parse_markdown_heading(text) doc.add_title(text=text, prov=prov) elif label == "paragraph_title": - heading_level = 1 + # Raw markup depth, normalized per document later. + heading_level = 2 if formatted_content: text, markdown_level = parse_markdown_heading(text) if markdown_level is not None: - # Paddle reserves Markdown H1 for ``doc_title``. Docling's - # section level 1 is exported as H2, so remove that offset. - heading_level = max(1, markdown_level - 1) + heading_level = markdown_level doc.add_heading(text=text, level=heading_level, prov=prov) elif label == "table": if _add_html_table(doc, text, prov) is None: @@ -336,4 +338,6 @@ def parse_paddleocr_vl_result( for block in page.parsing_res_list: _add_block(doc, block, page=page, page_no=resolved_page_no) + # One page is one document here, so its heading levels are normalized now. + normalize_section_header_levels(doc) return doc diff --git a/docling/utils/vlm_utils.py b/docling/utils/vlm_utils.py index d3ed9c5d11..016674911d 100644 --- a/docling/utils/vlm_utils.py +++ b/docling/utils/vlm_utils.py @@ -12,6 +12,7 @@ DocItemLabel, DoclingDocument, FloatingItem, + SectionHeaderItem, Size, TextItem, ) @@ -71,6 +72,45 @@ def parse_markdown_heading(text: str) -> tuple[str, int | None]: return match.group(2), len(match.group(1)) +def normalize_section_header_levels(doc: DoclingDocument) -> None: + """Turn the raw heading depths of a whole document into section levels. + + VLM mappers store a section header with N Markdown ``#`` or HTML ```` + at level N, and one without markup at level 2 (like ``##``). Docling exports + section level N one step deeper, below the title. If no section header has + level 1, all levels go up one step, so exports keep the source depth. + Otherwise nothing changes, so two source depths never share one level. + The layout label alone decides title versus section header. Call this once + per document, after pages are joined, so a depth maps to one level on all + pages. ``doc.texts`` holds every text item, in every layer and container. + """ + headers = [item for item in doc.texts if isinstance(item, SectionHeaderItem)] + if all(item.level > 1 for item in headers): + for item in headers: + item.level -= 1 + + +# `\(` and `\)` are not math symbols, so in a formula they are delimiters; `\\` stays. +_INLINE_MATH_DELIMITER = re.compile(r"\\\\|\\[()]") +_OUTER_MATH = re.compile( + r"\$\$((?:(?!\$\$).)*)\$\$|\\\[((?:(?!\\[\[\]]).)*)\\\]|\$([^$]*)\$", re.DOTALL +) + + +def strip_math_delimiters(text: str) -> str: + """Formula LaTeX without math delimiters; the serializer adds its own. + + VLMs can wrap each segment in ``\\(...\\)``, often with text between them + (``(6) \\(x_1=a\\), \\(x_2=b\\)``), and leave single ones in truncated output. + """ + text = _INLINE_MATH_DELIMITER.sub( + lambda match: match.group() if match.group() == "\\\\" else "", text + ).strip() + while (outer := _OUTER_MATH.fullmatch(text)) is not None: + text = next(group for group in outer.groups() if group is not None).strip() + return text + + def strip_stop_strings(texts: list[str], stop_strings: list[str]) -> list[str]: """Strip stop strings from decoded texts. diff --git a/docs/usage/vision_models.md b/docs/usage/vision_models.md index 338058c083..f021fb9fb6 100644 --- a/docs/usage/vision_models.md +++ b/docs/usage/vision_models.md @@ -223,3 +223,20 @@ invented coordinates. Invalid bounding boxes also produce a warning while their content is retained. Nonempty prose or JSON responses without HTML transcription produce a page-specific inference error and `PARTIAL_SUCCESS`, rather than an unreported empty result. An explicitly labeled `Blank-Page` may be empty. + +## Heading depths in native layout output + +For Chandra, dots, Nemotron Parse, DeepSeek-OCR, Unlimited-OCR, and PaddleOCR-VL +output, the layout label alone decides title versus section header. Docling +exports a title as `#` and a section header one step below it. If no section +header in the document uses depth 1 (`#` or `

`), each section header exports +with its source depth: `##` stays `##`. Otherwise each one exports one step +deeper, so different source depths never share one section level. A section +header without heading markup counts as `##`. Markdown has no heading deeper than +`######`, so in a document with depth 1, source depths 5 and 6 both export as +`######`, but their section levels stay different. + +The VLM pipeline applies this rule once to the whole document, so one depth gets +the same level on all pages. Each mapper function applies it to its own page when +you call it directly. `parse_paddleocr_vl_result` reads one saved PaddleOCR-VL +page, so it always applies the rule to that page. diff --git a/pyproject.toml b/pyproject.toml index 25365130fc..692dfb169c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -48,7 +48,7 @@ requires-python = '>=3.10,<4.0' # MINIMAL BASE (8 packages) - ~50MB dependencies = [ 'pydantic>=2.0.0,<3.0.0', - 'docling-core>=2.102.0,<3.0.0', + 'docling-core>=2.103.1,<3.0.0', 'pydantic-settings>=2.3.0,<3.0.0', 'filetype>=1.2.0,<2.0.0', 'requests>=2.32.2,<3.0.0', @@ -282,7 +282,7 @@ models-vlm-inline = [ feat-chunking = [ # >=2.94.1 relaxed its transformers pin; older releases cap transformers<5.9.0 # on macOS, which conflicts with the mlx-vlm floor in models-vlm-inline. - 'docling-core[chunking]>=2.102.0,<3.0.0', + 'docling-core[chunking]>=2.103.1,<3.0.0', ] service-client = [ @@ -334,7 +334,7 @@ typecheck = [ ] pr-fast-checks = [ { include-group = "typecheck" }, - "docling-core[chunking] (>=2.102.0,<3.0.0)", + "docling-core[chunking] (>=2.103.1,<3.0.0)", "pydantic (>=2.0.0,<3.0.0)", "pydantic-settings (>=2.3.0,<3.0.0)", "ruff==0.15.12", diff --git a/tach.toml b/tach.toml index b8635b2581..8e2d8f2c2a 100644 --- a/tach.toml +++ b/tach.toml @@ -185,7 +185,7 @@ depends_on = [] [[modules]] path = "docling.utils.nemotron_parse_utils" layer = "core" -depends_on = ["docling.backend", "docling.datamodel"] +depends_on = ["docling.backend", "docling.datamodel", "docling.utils"] [[modules]] path = "docling.utils" diff --git a/tests/data/docx/groundtruth/docx_rich_cells.docx.html b/tests/data/docx/groundtruth/docx_rich_cells.docx.html index be69ab91e7..e715dd7c90 100644 --- a/tests/data/docx/groundtruth/docx_rich_cells.docx.html +++ b/tests/data/docx/groundtruth/docx_rich_cells.docx.html @@ -197,27 +197,27 @@

Table with rich cells

  • Number two
  • Number three
  • This is simple text with

    -

    bold

    +

    bold

    ,

    -

    strikethrough

    +

    strikethrough

    and

    -

    italic

    +

    italic

    formatting with x

    -

    2

    +

    2

    and H

    -

    2

    +

    2

    O

    This is a paragraph

    This is another paragraph

    Table with nested table

    Before table

    Column AColumn B
    Simple cell upper left

    Simple cell with

    -

    bold

    +

    bold

    and

    -

    italic

    -

    text

    ABC

    Cell 1

    Cell 2

    Cell 3

    +

    italic

    +

    text

    ABC

    Cell 1

    Cell 2

    Cell 3

    Rich cell
    A nested table

    -
    ABC

    Cell 1

    Cell 2

    Cell 3

    +
    ABC

    Cell 1

    Cell 2

    Cell 3

    After table with bold , underline , strikethrough , and italic formatting

    diff --git a/tests/test_chandra_vlm.py b/tests/test_chandra_vlm.py index 0cb8725204..b54f79a1b5 100644 --- a/tests/test_chandra_vlm.py +++ b/tests/test_chandra_vlm.py @@ -405,7 +405,7 @@ def test_chandra_inline_math_formatting_and_code_whitespace(): str(next(t.hyperlink for t in doc.texts if t.text == "link")) == "https://example.org/" ) - assert doc.texts[-1].level == 3 + assert doc.texts[-1].level == 2 def test_chandra_nested_math_does_not_inherit_table_bbox(): diff --git a/tests/test_nemotron_formula_exports.py b/tests/test_nemotron_formula_exports.py new file mode 100644 index 0000000000..63da5c3761 --- /dev/null +++ b/tests/test_nemotron_formula_exports.py @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import pytest +from docling_core.types.doc import FormulaItem, Size + +from docling.utils.nemotron_parse_utils import parse_nemotron_parse_v2 + + +@pytest.mark.parametrize( + "opening, closing", + [("$", "$"), ("$$", "$$"), (r"\(", r"\)"), (r"\[", r"\]"), ("", "")], +) +def test_formula_export_has_one_delimiter_pair(opening: str, closing: str) -> None: + latex = r"\left\{x_1 * y_2 : x > 0\right\}" + raw = f"{opening}{latex}{closing}" + source = f"{raw}" + size = Size(width=600, height=800) + doc = parse_nemotron_parse_v2(source, size, size, page_no=1) + formula = doc.texts[0] + assert isinstance(formula, FormulaItem) + assert formula.text == latex + assert formula.orig == raw + assert formula.prov + assert doc.export_to_markdown() == f"$${latex}$$" + + +def test_multiline_formula_keeps_latex_and_line_breaks() -> None: + latex = "\\begin{aligned}\nx &= y \\\\\nz &= w\n\\end{aligned}" + source = f"$$\n{latex}\n$$" + size = Size(width=600, height=800) + doc = parse_nemotron_parse_v2(source, size, size, page_no=1) + assert doc.texts[0].text == latex + assert doc.export_to_markdown() == f"$${latex}$$" + + +def test_formula_segments_lose_every_inline_delimiter() -> None: + raw = r"(6) \(x_1=a\), \(x_2=b\)" + source = f"{raw}" + size = Size(width=600, height=800) + doc = parse_nemotron_parse_v2(source, size, size, page_no=1) + assert doc.texts[0].text == "(6) x_1=a, x_2=b" + assert doc.texts[0].orig == raw diff --git a/tests/test_nemotron_parse_v2.py b/tests/test_nemotron_parse_v2.py index 3ff1941cbd..44654724c1 100644 --- a/tests/test_nemotron_parse_v2.py +++ b/tests/test_nemotron_parse_v2.py @@ -175,7 +175,7 @@ def test_parse_nemotron_markdown_and_semantic_classes() -> None: assert document.texts[0].label == DocItemLabel.TITLE assert document.texts[0].text == "Main title" assert document.texts[1].label == DocItemLabel.SECTION_HEADER - assert document.texts[1].level == 3 + assert document.texts[1].level == 2 assert document.texts[1].text == "1.2 Method" assert document.texts[2].text == "Article history:\nReceived 12 June" assert any(item.label == DocItemLabel.REFERENCE for item in document.texts) @@ -183,7 +183,7 @@ def test_parse_nemotron_markdown_and_semantic_classes() -> None: item.text for item in document.texts if item.label == DocItemLabel.LIST_ITEM ] == ["First item", "Second item"] assert any( - item.label == DocItemLabel.FORMULA and item.text == r"\(x^2\)" + item.label == DocItemLabel.FORMULA and item.text == r"x^2" for item in document.texts ) assert len(document.pictures) == 2 diff --git a/tests/test_ocr_multiline_blocks.py b/tests/test_ocr_multiline_blocks.py new file mode 100644 index 0000000000..2cfb589c16 --- /dev/null +++ b/tests/test_ocr_multiline_blocks.py @@ -0,0 +1,60 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +import pytest +from docling_core.types.doc import DocItemLabel, Size + +from docling.utils.deepseekocr_utils import ( + parse_deepseekocr_markdown, + parse_unlimited_ocr_markdown, +) + + +@pytest.mark.parametrize("parser", ["deepseek", "unlimited"]) +def test_text_block_keeps_continuation_lines_and_following_table(parser: str) -> None: + def marker(label: str, top: int) -> str: + if parser == "deepseek": + return f"{label}[[100,{top},900,{top + 100}]]\n" + return f"<|det|>{label} [100,{top},900,{top + 100}]<|/det|>" + + source = ( + marker("text", 100) + + "First line\nSecond line\n\nThird paragraph\n" + + marker("table", 400) + + "
    Total
    \n" + + marker("table_caption", 600) + + "Caption first line\nCaption second line\n" + + marker("text", 800) + + "Final block" + ) + parse = ( + parse_deepseekocr_markdown + if parser == "deepseek" + else parse_unlimited_ocr_markdown + ) + doc = parse(source, Size(width=600, height=800), page_no=2) + text = next(item for item in doc.texts if item.label == DocItemLabel.TEXT) + assert text.text == "First line\nSecond line\n\nThird paragraph" + assert text.prov[0].page_no == 2 + assert len(doc.tables) == 1 + assert doc.tables[0].data.grid[0][0].text == "Total" + caption = doc.tables[0].captions[0].resolve(doc) + assert caption.text == "Caption first line\nCaption second line" + assert doc.texts[-1].text == "Final block" + assert "Second line" in doc.export_to_markdown() + + +def test_caption_does_not_claim_table_markup_already_collected() -> None: + source = ( + "table[[100,100,900,300]]\n" + "table_caption[[100,400,900,500]]\nCaption first\nCaption second\n\n" + "
    Total
    \n\n" + "text[[100,600,900,700]]\nFinal block" + ) + doc = parse_deepseekocr_markdown(source, Size(width=600, height=800), page_no=1) + assert doc.tables[0].data.grid[0][0].text == "Total" + assert ( + doc.tables[0].captions[0].resolve(doc).text == "Caption first\nCaption second" + ) + assert all("" not in item.text for item in doc.texts) + assert doc.texts[-1].text == "Final block" diff --git a/tests/test_vlm_heading_exports.py b/tests/test_vlm_heading_exports.py new file mode 100644 index 0000000000..b75d5b0420 --- /dev/null +++ b/tests/test_vlm_heading_exports.py @@ -0,0 +1,235 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +"""Heading depth must survive VLM response parsing and Markdown export.""" + +import json +from pathlib import PurePath +from types import SimpleNamespace + +import pytest +from docling_core.types.doc import ( + DocItemLabel, + DoclingDocument, + SectionHeaderItem, + Size, + TitleItem, +) + +from docling.datamodel.base_models import Page, PagePredictions, VlmPrediction +from docling.datamodel.pipeline_options_vlm_model import ( + InferenceFramework, + InlineVlmOptions, + ResponseFormat, +) +from docling.pipeline.vlm_pipeline import VlmPipeline +from docling.utils.chandra_utils import _add_html_fragment, parse_chandra_html +from docling.utils.deepseekocr_utils import ( + parse_deepseekocr_markdown, + parse_unlimited_ocr_markdown, +) +from docling.utils.dots_utils import parse_dots_json +from docling.utils.nemotron_parse_utils import parse_nemotron_parse_v2 +from docling.utils.nemotron_parse_v3_5_utils import parse_nemotron_parse_v3_5 +from docling.utils.paddleocr_vl_utils import parse_paddleocr_vl_result + +_SIZE = Size(width=600, height=800) +# A block is (is_title, markup depth or None for no heading markup). +_Block = tuple[bool, int | None] +_FORMATS = { + "chandra": ResponseFormat.CHANDRA_HTML, + "dots": ResponseFormat.DOTS_JSON, + "nemotron_v2": ResponseFormat.NEMOTRON_PARSE_V2, + "nemotron_v3_5": ResponseFormat.NEMOTRON_PARSE_V3_5, + "deepseek": ResponseFormat.DEEPSEEKOCR_MARKDOWN, + "unlimited": ResponseFormat.UNLIMITED_OCR_MARKDOWN, +} +_PARSERS = [*_FORMATS, "paddle"] + + +def _markdown(index: int, depth: int | None) -> str: + return f"{'#' * depth} H{index}" if depth is not None else f"H{index}" + + +def _response(parser: str, blocks: list[_Block]) -> str: + """Render one page of model output with one block per heading.""" + parts = [] + for index, (is_title, depth) in enumerate(blocks): + y = 100 + 50 * index + text = _markdown(index, depth) + if parser == "chandra": + label = "Title" if is_title else "Section-Header" + body = f"H{index}" if depth is not None else text + parts.append( + f'
    ' + f"{body}
    " + ) + elif parser == "dots": + category = "Title" if is_title else "Section-header" + parts.append( + json.dumps( + {"category": category, "bbox": [60, y, 540, y + 40], "text": text} + ) + ) + elif parser in {"nemotron_v2", "nemotron_v3_5"}: + label = "Title" if is_title else "Section-header" + parts.append( + f"{text}" + f"\n\n" + ) + elif parser == "deepseek": + label = "title" if is_title else "sub_title" + parts.append( + f"<|ref|>{label}<|/ref|><|det|>[[100, {y}, 900, {y + 40}]]<|/det|>\n" + f"{text}\n\n" + ) + elif parser == "unlimited": + label = "title" if is_title else "sub_title" + parts.append(f"<|det|>{label} [100, {y}, 900, {y + 40}]<|/det|>{text}\n") + else: + raise AssertionError(parser) + if parser == "dots": + return f"[{', '.join(parts)}]" + return "".join(parts) + + +def _parse(parser: str, blocks: list[_Block], page_no: int = 1) -> DoclingDocument: + """Map one page with the standalone mapper and its default normalization.""" + if parser == "paddle": + payload = { + "width": _SIZE.width, + "height": _SIZE.height, + "model_settings": {"format_block_content": True}, + "parsing_res_list": [ + { + "block_label": "doc_title" if is_title else "paragraph_title", + "block_content": _markdown(index, depth), + "block_bbox": [60, 100 + 50 * index, 540, 140 + 50 * index], + } + for index, (is_title, depth) in enumerate(blocks) + ], + } + return parse_paddleocr_vl_result(payload, filename="page.pdf", page_no=page_no) + content = _response(parser, blocks) + if parser == "chandra": + return parse_chandra_html(content, _SIZE, page_no=page_no) + if parser == "dots": + return parse_dots_json(content, _SIZE, page_no=page_no) + if parser == "nemotron_v2": + return parse_nemotron_parse_v2(content, _SIZE, _SIZE, page_no=page_no) + if parser == "nemotron_v3_5": + return parse_nemotron_parse_v3_5(content, _SIZE, page_no=page_no) + if parser == "deepseek": + return parse_deepseekocr_markdown(content, _SIZE, page_no=page_no) + return parse_unlimited_ocr_markdown(content, _SIZE, page_no=page_no) + + +def _levels(doc: DoclingDocument) -> list[int]: + return [item.level for item in doc.texts if isinstance(item, SectionHeaderItem)] + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_depth_one_section_keeps_every_depth_distinct(parser: str) -> None: + doc = _parse(parser, [(False, 1), (False, 2), (False, 3)]) + + assert all(isinstance(item, SectionHeaderItem) for item in doc.texts) + assert _levels(doc) == [1, 2, 3] + assert doc.export_to_markdown() == "## H0\n\n### H1\n\n#### H2" + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_source_depth_survives_export_without_depth_one(parser: str) -> None: + doc = _parse(parser, [(False, 2), (False, 3), (False, 6)], page_no=2) + + assert _levels(doc) == [1, 2, 5] + assert all(item.prov[0].page_no == 2 for item in doc.texts) + assert doc.export_to_markdown() == "## H0\n\n### H1\n\n###### H2" + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_section_without_markup_is_depth_two(parser: str) -> None: + assert _levels(_parse(parser, [(False, None), (False, 3)])) == [1, 2] + assert _levels(_parse(parser, [(False, 1), (False, None)])) == [1, 2] + + +@pytest.mark.parametrize("parser", _PARSERS) +def test_layout_label_alone_decides_title(parser: str) -> None: + doc = _parse(parser, [(True, 2), (False, 1), (False, 2)]) + + assert isinstance(doc.texts[0], TitleItem) + assert doc.texts[0].text == "H0" + assert _levels(doc) == [1, 2] + assert doc.export_to_markdown() == "# H0\n\n## H1\n\n### H2" + + +def test_html_fragment_records_raw_depth() -> None: + doc = DoclingDocument(name="fragment") + doc.add_page(page_no=2, size=_SIZE) + for depth in (2, 3): + _add_html_fragment(doc, f"Method", label=DocItemLabel.TEXT) + + # The calling mapper normalizes; the shared fragment helper must not. + assert _levels(doc) == [2, 3] + + +@pytest.mark.parametrize("parser", list(_FORMATS)) +def test_pipeline_normalizes_joined_pages_once(parser: str) -> None: + pipeline = VlmPipeline.__new__(VlmPipeline) + pipeline.pipeline_options = SimpleNamespace( + generate_page_images=False, + generate_picture_images=False, + images_scale=1.0, + vlm_options=InlineVlmOptions( + prompt="", + repo_id="test", + response_format=_FORMATS[parser], + inference_framework=InferenceFramework.TRANSFORMERS, + scale=1.0, + ), + ) + pipeline.force_backend_text = False + conv_res = SimpleNamespace( + input=SimpleNamespace(file=PurePath("test.pdf")), errors=[] + ) + + def join(pages: dict[int, list[_Block]]) -> DoclingDocument: + page_documents = [] + for page_no, blocks in pages.items(): + page = Page( + page_no=page_no, + size=_SIZE, + predictions=PagePredictions( + vlm_response=VlmPrediction(text=_response(parser, blocks)) + ), + ) + page_documents.append( + (page_no, pipeline._finalize_page_document(conv_res, page)) + ) + return pipeline._join_page_documents(page_documents) + + # Only page 1 has a depth-1 section, yet "##" has one level on both pages. + doc = join({1: [(False, 1), (False, 2)], 2: [(False, 2)]}) + assert [(item.prov[0].page_no, item.level) for item in doc.texts] == [ + (1, 1), + (1, 2), + (2, 2), + ] + # A second normalization would turn "###" into "##". + doc = join({1: [(False, 3)], 2: [(False, 3)]}) + assert _levels(doc) == [2, 2] + assert doc.export_to_markdown() == "### H0\n\n### H0" + assert not conv_res.errors + + +def test_formatted_html_heading_keeps_one_coordinate_owner() -> None: + doc = parse_chandra_html( + '
    ' + "

    Method and results

    ", + Size(width=600, height=800), + page_no=1, + ) + assert isinstance(doc.texts[0], SectionHeaderItem) + assert doc.texts[0].level == 2 + assert doc.texts[0].prov + assert all(not item.prov for item in doc.texts[1:]) + assert doc.export_to_markdown() == "### **Method** and results" diff --git a/uv.lock b/uv.lock index 3ecf2aea0c..d12dc1fac6 100644 --- a/uv.lock +++ b/uv.lock @@ -1943,7 +1943,7 @@ requires-dist = [{ name = "docling-slim", extras = ["service-client"], editable [[package]] name = "docling-core" -version = "2.102.0" +version = "2.103.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "defusedxml" }, @@ -1962,9 +1962,9 @@ dependencies = [ { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c8/cc/af034afb72ddc4bfcb24f6cace3ffcf800ca4226080497c989a6a4124a4b/docling_core-2.102.0.tar.gz", hash = "sha256:f951eef5ba69391d8fc0b39e54726475d6135b49d4080f886ad98945ea397872", size = 425379, upload-time = "2026-10-18T05:31:45.103Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/74/70107b222626fa5167a4de9dd24148b2ff657667d9ad57d17d3fba3906ef/docling_core-2.103.1.tar.gz", hash = "sha256:52398d2369fe32fccb7c06527d888b4cfa50226b25f55bbdc5d6e6d6863ee4f4", size = 440557, upload-time = "2026-10-20T14:45:14.447Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/83/ee/5e8ddf1cdccf623aa2f131cddbd5e50fc0647f933481ebe347185a05a259/docling_core-2.102.0-py3-none-any.whl", hash = "sha256:dc2d1d6e6ec5e96c762e7b71df0d017dfd94224a06c92eeccced1625a7e921c2", size = 321488, upload-time = "2026-10-18T05:31:43.292Z" }, + { url = "https://files.pythonhosted.org/packages/8a/6b/047a02de5828d54462f48f7d12da50861cc36505a600460ae08eeeeca2f5/docling_core-2.103.1-py3-none-any.whl", hash = "sha256:2407bb42bb95468192b69b772ab72477e2a636bd33c5c6a401047b190fc81b96", size = 328165, upload-time = "2026-10-20T14:45:12.455Z" }, ] [package.optional-dependencies] @@ -2422,8 +2422,8 @@ requires-dist = [ { name = "defusedxml", marker = "extra == 'format-iwork'", specifier = ">=0.7.1,<0.8.0" }, { name = "defusedxml", marker = "extra == 'format-xml-uspto'", specifier = ">=0.7.1,<0.8.0" }, { name = "defusedxml", marker = "extra == 'models-local'", specifier = ">=0.7.1,<0.8.0" }, - { name = "docling-core", specifier = ">=2.102.0,<3.0.0" }, - { name = "docling-core", extras = ["chunking"], marker = "extra == 'feat-chunking'", specifier = ">=2.102.0,<3.0.0" }, + { name = "docling-core", specifier = ">=2.103.1,<3.0.0" }, + { name = "docling-core", extras = ["chunking"], marker = "extra == 'feat-chunking'", specifier = ">=2.103.1,<3.0.0" }, { name = "docling-ibm-models", marker = "extra == 'models-local'", specifier = ">=4.0.2,<5" }, { name = "docling-parse", marker = "extra == 'format-pdf-docling'", specifier = ">=7.22.2,<8.0.0" }, { name = "docling-slim", extras = ["convert-core"], marker = "extra == 'extract-core'" }, @@ -2562,7 +2562,7 @@ examples = [ ] pr-fast-checks = [ { name = "boto3-stubs", specifier = "~=1.37" }, - { name = "docling-core", extras = ["chunking"], specifier = ">=2.102.0,<3.0.0" }, + { name = "docling-core", extras = ["chunking"], specifier = ">=2.103.1,<3.0.0" }, { name = "pandas-stubs", specifier = "~=2.1" }, { name = "pydantic", specifier = ">=2.0.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.3.0,<3.0.0" },