Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docling/backend/json/docling_json_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
29 changes: 28 additions & 1 deletion docling/pipeline/vlm_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<doclang(?:\s[^>]*)?>")
# 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):
Expand Down Expand Up @@ -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
Expand All @@ -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,
*,
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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(
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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:
Expand Down
15 changes: 14 additions & 1 deletion docling/utils/chandra_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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}
Expand Down Expand Up @@ -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 <hN>, a section header has raw depth 2, like "##".
item.level = 2

def walk(
self,
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -796,14 +803,18 @@ 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.

A source bbox is assigned to at most one document item representing that layout
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,
Expand Down Expand Up @@ -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
38 changes: 21 additions & 17 deletions docling/utils/deepseekocr_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)


Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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:
Expand All @@ -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.

Expand All @@ -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
Expand Down Expand Up @@ -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


Expand Down Expand Up @@ -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`.

Expand All @@ -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,
)
15 changes: 13 additions & 2 deletions docling/utils/dots_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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.

Expand All @@ -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.
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
20 changes: 16 additions & 4 deletions docling/utils/nemotron_parse_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
)
Expand All @@ -332,4 +342,6 @@ def parse_nemotron_parse_v2(
prov=provenance,
)

if normalize_heading_levels:
normalize_section_header_levels(document)
return document
Loading
Loading