Document Merging and Concatenation#
DoclingDocument supports merging multiple document objects into one via DoclingDocument.concatenate(docs) — a classmethod that returns a new DoclingDocument with sequentially renumbered pages and fully remapped internal references. It is the standard way to assemble per-page documents (e.g., from VLM pipelines) into a single result.
A companion method, filter(page_nrs=...), uses the same internal machinery to produce a new document restricted to a subset of pages. _normalize_references() uses it to repair a single document's own cross-references in place.
_DocIndex: The Merge Buffer#
DoclingDocument._DocIndex is a private BaseModel nested inside DoclingDocument. It acts as an accumulator — each call to its index(doc, page_nrs=None) method ingests one source document, remaps all its references, and appends items into the buffer's typed lists.
Fields:
- Per-type item lists:
groups,texts,pictures,tables,key_value_items,form_items,field_regions,field_items pages: dict[int, PageItem]— renumbered page map_body: Optional[GroupItem]— merged document body_max_page: int— high-water mark for page offset calculation_names: list[str]— accumulates source document names
Once all documents have been indexed, _update_from_index(doc_index) writes the buffer's contents back into the result DoclingDocument.
The get_name() helper deduplicates and joins source document names with " + " .
Reference Remapping Inside index()#
The core logic lives in _DocIndex.index(). It runs in two passes:
Pass 1 — item traversal iterates all items in document order via _iterate_items_with_stack :
self_refassignment — computesnew_cref = "#/<type_key>/<new_index>"and recordsorig_ref_to_new_ref[old_cref] = new_cref.- Parent remapping — looks up the parent's old cref in
orig_ref_to_new_ref. If the direct parent was excluded (e.g., filtered out), it walks up ancestors until it finds one in the map; falls back to#/bodywith a warning . RichTableCellrefs — when aTableItemis the parent, anyRichTableCell.ref.crefmatching the item's old ref is updated inline .
Pass 2 — FloatingItem explicit refs iterates only the newly appended items (using start_indices to avoid re-processing items from prior index() calls) and rewrites captions, references, and footnotes lists by mapping old crefs through orig_ref_to_new_ref — missing entries are silently dropped .
Why a second pass? Captions and footnotes are forward references — the caption text item is usually indexed after the table/picture that references it. A second pass ensures all items are in the map before cross-links are resolved.
Page Number Adjustment#
For each source document, a page_delta is computed as :
page_delta = _max_page - min(doc.pages.keys()) + 1
This shifts incoming page numbers so they continue sequentially after the highest page already in the buffer. The delta is applied to:
- Provenance
page_noon everyDocItem, including graph cells insideKeyValueItemandFormItem. pagesdictionary keys — eachPageItemis deep-copied, itspage_noupdated, and stored under the new key ._max_pageis updated after each document is indexed, so subsequent calls accumulate correctly.
Known Bug Fixes and Edge Cases#
Two PRs address caption/reference remapping bugs that affect item-level copying (via add_node_items / insert_node_items) — a related but distinct code path from concatenate():
- PR #514 —
TableItemcaption refs were carried over verbatim during cross-document node copies, producing stale or out-of-boundscaptionspointers. Fix:_append_item_copies()now recursively resolves, deep-copies, and re-indexes caption targets. - PR #553 — A follow-up refinement tracking old→new index offsets via a lookup table and applying
_update_refitems_with_lookup()tocaptions,references, andfootnotesafter each copy.
Both bugs stem from the same root cause: FloatingItem containers (TableItem, PictureItem, etc.) hold explicit pointer lists (captions, references, footnotes) separate from the tree structure, so a simple structural copy misses them. The _DocIndex.index() design avoids this by doing a post-pass over all FloatingItem instances after the traversal is complete .
Usage and Callers#
# Merge a list of per-page documents into one
merged = DoclingDocument.concatenate([doc_page1, doc_page2, doc_page3])
# Filter to specific pages (same internal machinery)
subset = doc.filter(page_nrs={1, 2, 5})
In the Docling main library, the VLM pipeline assembles per-page DoclingDocument objects and merges them via DoclingDocument.concatenate(docs=page_docs) inside _add_page_metadata_and_concatenate .
Key source locations: