Repository: docling-project/docling-core

Issue:
Add manipulation operations on items of a DoclingDocument.
# Support merge, update, insert, and delete operations for DocItem using self-references

For agentic frameworks that need to generate documents, we can not just rely on the simple `add_***` functionality, but we need to be able to  merge, update, and delete Docitems/GroupItems.

## Description:

We need to extend the DocItem framework to support merge, update, and delete operations using the concept of self-references — that is, paths pointing to the current item’s location within the larger JSON document structure.

This functionality is important for enabling fine-grained manipulation of document elements, especially in interactive workflows (e.g., LLM-based editing or user-driven document transformations).

Hints from issue comments:
@vagenas: A main implication is that (some of) these operations would affect the document structure, thus invalidating the previous refs.

More specifically, in increasing complexity:
- **Updating** `Doctem`s is probably easy, since the structure remains unaffected.
- **Deleting** `DocItem`s or `GroupItem`s could also be addressed by "marking" a node or group (recursively) as deleted — and potentially getting rid of the "deleted" nodes/groups in a second step, i.e. at the completion of the "transaction", ⚠ after which the doc would have new refs ⚠
- **Inserting** `DocItem`s or `GroupItem`s likely has two intricacies: (1) it could invalidate old refs (like deletions above), and (2) may be ambiguous if done naïvely, so must be defined unambiguously, e.g. `doc.append_child(parent_node)`, `doc.add_sibling_before(next_sibling)`, etc..

(Not sure exactly how "merges" are meant, but they likely can be implemented based on the other operations above.)

More context on the envisioned use case, ideally with some example flows & details, could help shape this further.@cau-git: @vagenas this is a good start. One use case for which this is requested frequently is to merge multiple DoclingDocument instances into one, and in extension, splitting one DoclingDocument up into several (e.g. by page ranges). The former case brings the complexities of remapping references that could overlap.

For the per-element ops:
- Inserting an element between others should be as simple as adding it into the top-level `texts`, `tables`, `pictures` etc. and add its self-reference somewhere in a node's ordered list of `children` (most commonly the `body` node). Not sure if this needs extra considerations for transactionality.
- Deleting an element has to remove it from the respective field it lives (`texts`, `tables`, ...) and also the reference from the `children` list of its parent node. 

Both cases raise the question if we want to rewrite all other references such that the actual order and the references remain in sync and without gaps.
 @vagenas: The complexity arises from having the insert / delete operations applied on our current, **static**-reference representation.

💡 If we decouple these operations from reference materialization by introducing a **dynamic**-reference representation, i.e. based on memory pointers instead of static strings, things look more streamlined.

The figure below shows how it could look like on a high level:

<img width="1688" alt="Image" src="https://github.com/user-attachments/assets/372eac9d-e77b-4210-bd6c-171eb6e3ad16" />@PeterStaar-IBM: Merged!

Solution patch:
diff --git a/docling_core/types/doc/document.py b/docling_core/types/doc/document.py
index f0f288c..88c256a 100644
--- a/docling_core/types/doc/document.py
+++ b/docling_core/types/doc/document.py
@@ -458,8 +458,12 @@ class RefItem(BaseModel):
         populate_by_name=True,
     )
 
+    def _split_ref_to_path(self):
+        """Get the path of the reference."""
+        return self.cref.split("/")
+
     def resolve(self, doc: "DoclingDocument"):
-        """resolve."""
+        """Resolve the path in the document."""
         path_components = self.cref.split("/")
         if (num_comps := len(path_components)) == 3:
             _, path, index_str = path_components
@@ -624,10 +628,98 @@ class NodeItem(BaseModel):
 
     model_config = ConfigDict(extra="forbid")
 
-    def get_ref(self):
+    def get_ref(self) -> RefItem:
         """get_ref."""
         return RefItem(cref=self.self_ref)
 
+    def _get_parent_ref(
+        self, doc: "DoclingDocument", stack: list[int]
+    ) -> Optional[RefItem]:
+        """get_parent_ref."""
+        if len(stack) == 0:
+            return self.parent
+        elif len(stack) > 0 and stack[0] < len(self.children):
+            item = self.children[stack[0]].resolve(doc)
+            return item._get_parent_ref(doc=doc, stack=stack[1:])
+
+        return None
+
+    def _delete_child(self, doc: "DoclingDocument", stack: list[int]) -> bool:
+        """Delete child node in tree."""
+        if len(stack) == 1 and stack[0] < len(self.children):
+            del self.children[stack[0]]
+            return True
+        elif len(stack) > 1 and stack[0] < len(self.children):
+            item = self.children[stack[0]].resolve(doc)
+            return item._delete_child(doc=doc, stack=stack[1:])
+
+        return False
+
+    def _update_child(
+        self, doc: "DoclingDocument", stack: list[int], new_ref: RefItem
+    ) -> bool:
+        """Update child node in tree."""
+        if len(stack) == 1 and stack[0] < len(self.children):
+            # ensure the parent is correct
+            new_item = new_ref.resolve(doc=doc)
+            new_item.parent = self.get_ref()
+
+            self.children[stack[0]] = new_ref
+            return True
+        elif len(stack) > 1 and stack[0] < len(self.children):
+            item = self.children[stack[0]].resolve(doc)
+            return item._update_child(doc=doc, stack=stack[1:], new_ref=new_ref)
+
+        return False
+
+    def _add_child(
+        self, doc: "DoclingDocument", stack: list[int], new_ref: RefItem
+    ) -> bool:
+        """Append child to node identified by stack."""
+        if len(stack) == 0:
+
+            # ensure the parent is correct
+            new_item = new_ref.resolve(doc=doc)
+            new_item.parent = self.get_ref()
+
+            self.children.append(new_ref)
+            return True
+        elif len(stack) > 0 and stack[0] < len(self.children):
+            item = self.children[stack[0]].resolve(doc)
+            return item._add_child(doc=doc, stack=stack[1:], new_ref=new_ref)
+
+        return False
+
+    def _add_sibling(
+        self,
+        doc: "DoclingDocument",
+        stack: list[int],
+        new_ref: RefItem,
+        after: bool = True,
+    ) -> bool:
+        """Add sibling node in tree."""
+        if len(stack) == 1 and stack[0] < len(self.children) and (not after):
+            # ensure the parent is correct
+            new_item = new_ref.resolve(doc=doc)
+            new_item.parent = self.get_ref()
+
+            self.children.insert(stack[0], new_ref)
+            return True
+        elif len(stack) == 1 and stack[0] < len(self.children) and (after):
+            # ensure the parent is correct
+            new_item = new_ref.resolve(doc=doc)
+            new_item.parent = self.get_ref()
+
+            self.children.insert(stack[0] + 1, new_ref)
+            return True
+        elif len(stack) > 1 and stack[0] < len(self.children):
+            item = self.children[stack[0]].resolve(doc)
+            return item._add_sibling(
+                doc=doc, stack=stack[1:], new_ref=new_ref, after=after
+            )
+
+        return False
+
 
 class GroupItem(NodeItem):  # Container type, can't be a leaf node
     """GroupItem."""
@@ -1683,6 +1775,364 @@ class DoclingDocument(BaseModel):
                     item["content_layer"] = "furniture"
         return data
 
+    # ---------------------------
+    # Public Manipulation methods
+    # ---------------------------
+
+    def append_child_item(
+        self, *, child: NodeItem, parent: Optional[NodeItem] = None
+    ) -> None:
+        """Adds an item."""
+        if len(child.children) > 0:
+            raise ValueError("Can not append a child with children")
+
+        parent = parent if parent is not None else self.body
+
+        success, stack = self._get_stack_of_item(item=parent)
+
+        if not success:
+            raise ValueError(
+                f"Could not resolve the parent node in the document tree: {parent}"
+            )
+
+        # Append the item to the attributes of the doc
+        self._append_item(item=child, parent_ref=parent.get_ref())
+
+        # Update the tree of the doc
+        success = self.body._add_child(doc=self, new_ref=child.get_ref(), stack=stack)
+
+        # Clean the attribute (orphan) if not successful
+        if not success:
+            self._pop_item(item=child)
+            raise ValueError(f"Could not append child: {child} to parent: {parent}")
+
+    def insert_item_after_sibling(
+        self, *, new_item: NodeItem, sibling: NodeItem
+    ) -> None:
+        """Inserts an item, given its node_item instance, after other as a sibling."""
+        self._insert_item_at_refitem(item=new_item, ref=sibling.get_ref(), after=True)
+
+    def insert_item_before_sibling(
+        self, *, new_item: NodeItem, sibling: NodeItem
+    ) -> None:
+        """Inserts an item, given its node_item instance, before other as a sibling."""
+        self._insert_item_at_refitem(item=new_item, ref=sibling.get_ref(), after=False)
+
+    def delete_items(self, *, node_items: List[NodeItem]) -> None:
+        """Deletes an item, given its instance or ref, and any children it has."""
+        refs = []
+        for _ in node_items:
+            refs.append(_.get_ref())
+
+        self._delete_items(refs=refs)
+
+    def replace_item(self, *, new_item: NodeItem, old_item: NodeItem) -> None:
+        """Replace item with new item."""
+        self.insert_item_after_sibling(new_item=new_item, sibling=old_item)
+        self.delete_items(node_items=[old_item])
+
+    # ----------------------------
+    # Private Manipulation methods
+    # ----------------------------
+
+    def _get_stack_of_item(self, item: NodeItem) -> tuple[bool, list[int]]:
+        """Find the stack indices of the item."""
+        return self._get_stack_of_refitem(ref=item.get_ref())
+
+    def _get_stack_of_refitem(self, ref: RefItem) -> tuple[bool, list[int]]:
+        """Find the stack indices of the reference."""
+        if ref == self.body.get_ref():
+            return (True, [])
+
+        node = ref.resolve(doc=self)
+        parent_ref = node._get_parent_ref(doc=self, stack=[])
+
+        if parent_ref is None:
+            return (False, [])
+
+        stack: list[int] = []
+        while parent_ref is not None:
+            parent = parent_ref.resolve(doc=self)
+
+            index = parent.children.index(node.get_ref())
+            stack.insert(0, index)  # prepend the index
+
+            node = parent
+            parent_ref = node._get_parent_ref(doc=self, stack=[])
+
+        return (True, stack)
+
+    def _insert_item_at_refitem(
+        self, item: NodeItem, ref: RefItem, after: bool
+    ) -> RefItem:
+        """Insert node-item using the self-reference."""
+        success, stack = self._get_stack_of_refitem(ref=ref)
+
+        if not success:
+            raise ValueError(
+                f"Could not insert at {ref.cref}: could not find the stack"
+            )
+
+        return self._insert_item_at_stack(item=item, stack=stack, after=after)
+
+    def _append_item(self, *, item: NodeItem, parent_ref: RefItem) -> RefItem:
+        """Append item of its type."""
+        cref: str = ""  # to be updated
+
+        if isinstance(item, TextItem):
+            item_label = "texts"
+            item_index = len(self.texts)
+
+            cref = f"#/{item_label}/{item_index}"
+
+            item.self_ref = cref
+            item.parent = parent_ref
+
+            self.texts.append(item)
+
+        elif isinstance(item, TableItem):
+            item_label = "tables"
+            item_index = len(self.tables)
+
+            cref = f"#/{item_label}/{item_index}"
+
+            item.self_ref = cref
+            item.parent = parent_ref
+
+            self.tables.append(item)
+
+        elif isinstance(item, PictureItem):
+            item_label = "pictures"
+            item_index = len(self.pictures)
+
+            cref = f"#/{item_label}/{item_index}"
+
+            item.self_ref = cref
+            item.parent = parent_ref
+
+            self.pictures.append(item)
+
+        elif isinstance(item, KeyValueItem):
+            item_label = "key_value_items"
+            item_index = len(self.key_value_items)
+
+            cref = f"#/{item_label}/{item_index}"
+
+            item.self_ref = cref
+            item.parent = parent_ref
+
+            self.key_value_items.append(item)
+
+        elif isinstance(item, FormItem):
+            item_label = "form_items"
+            item_index = len(self.form_items)
+
+            cref = f"#/{item_label}/{item_index}"
+
+            item.self_ref = cref
+            item.parent = parent_ref
+
+            self.form_items.append(item)
+        else:
+            raise ValueError(f"Item {item} is not supported for insertion")
+
+        return RefItem(cref=cref)
+
+    def _pop_item(self, *, item: NodeItem):
+        """Pop the last item of its type."""
+        path = item.self_ref.split("/")
+
+        if len(path) != 3:
+            raise ValueError(f"Can not pop item with path: {path}")
+
+        item_label = path[1]
+        item_index = int(path[2])
+
+        if (
+            len(self.__getattribute__(item_label)) + 1 == item_index
+        ):  # we can only pop the last item
+            del self.__getattribute__(item_label)[item_index]
+        else:
+            msg = f"index:{item_index}, len:{len(self.__getattribute__(item_label))}"
+            raise ValueError(f"Failed to pop: item is not last ({msg})")
+
+    def _insert_item_at_stack(
+        self, item: NodeItem, stack: list[int], after: bool
+    ) -> RefItem:
+        """Insert node-item using the self-reference."""
+        parent_ref = self.body._get_parent_ref(doc=self, stack=stack)
+
+        if parent_ref is None:
+            raise ValueError(f"Could not find a parent at stack: {stack}")
+
+        new_ref = self._append_item(item=item, parent_ref=parent_ref)
+
+        success = self.body._add_sibling(
+            doc=self, stack=stack, new_ref=new_ref, after=after
+        )
+
+        if not success:
+            self._pop_item(item=item)
+
+        return item.get_ref()
+
+    def _delete_items(self, refs: list[RefItem]) -> bool:
+        """Delete document item using the self-reference."""
+        to_be_deleted_items: dict[tuple[int, ...], str] = {}  # stack to cref
+
+        # Identify the to_be_deleted_items
+        for item, stack in self._iterate_items_with_stack(with_groups=True):
+            ref = item.get_ref()
+
+            if ref in refs:
+                to_be_deleted_items[tuple(stack)] = ref.cref
+
+            substacks = [stack[0 : i + 1] for i in range(len(stack) - 1)]
+            for substack in substacks:
+                if tuple(substack) in to_be_deleted_items:
+                    to_be_deleted_items[tuple(stack)] = ref.cref
+
+        if len(to_be_deleted_items) == 0:
+            raise ValueError("Nothing to be deleted ...")
+
+        # Clean the tree, reverse the order to not have to update
+        for stack_, ref_ in reversed(sorted(to_be_deleted_items.items())):
+            success = self.body._delete_child(doc=self, stack=list(stack_))
+
+            if not success:
+                del to_be_deleted_items[stack_]
+            else:
+                _logger.info(f"deleted item in tree at stack: {stack_} => {ref_}")
+
+        # Create a new lookup of the orphans:
+        # dict of item_label (`texts`, `tables`, ...) to a
+        # dict of item_label with delta (default = -1).
+        lookup: dict[str, dict[int, int]] = {}
+
+        for stack_, ref_ in to_be_deleted_items.items():
+            path = ref_.split("/")
+            if len(path) == 3:
+
+                item_label = path[1]
+                item_index = int(path[2])
+
+                if item_label not in lookup:
+                    lookup[item_label] = {}
+
+                lookup[item_label][item_index] = -1
+
+        # Remove the orphans in reverse order
+        for item_label, item_inds in lookup.items():
+            for item_index, val in reversed(
+                sorted(item_inds.items())
+            ):  # make sure you delete the last in the list first!
+                _logger.debug(f"deleting item in doc for {item_label} for {item_index}")
+                del self.__getattribute__(item_label)[item_index]
+
+        self._update_breadth_first_with_lookup(
+            node=self.body, refs_to_be_deleted=refs, lookup=lookup
+        )
+
+        return True
+
+    # Update the references
+    def _update_ref_with_lookup(
+        self, item_label: str, item_index: int, lookup: dict[str, dict[int, int]]
+    ) -> RefItem:
+        """Update ref with lookup."""
+        if item_label not in lookup:  # Nothing to be done
+            return RefItem(cref=f"#/{item_label}/{item_index}")
+
+        # Count how many items have been deleted in front of you
+        delta = sum(
+            val if item_index >= key else 0 for key, val in lookup[item_label].items()
+        )
+        new_index = item_index + delta
+
+        return RefItem(cref=f"#/{item_label}/{new_index}")
+
+    def _update_refitems_with_lookup(
+        self,
+        ref_items: list[RefItem],
+        refs_to_be_deleted: list[RefItem],
+        lookup: dict[str, dict[int, int]],
+    ) -> list[RefItem]:
+        """Update refitems with lookup."""
+        new_refitems = []
+        for ref_item in ref_items:
+
+            if (
+                ref_item not in refs_to_be_deleted
+            ):  # if ref_item is in ref, then delete/skip them
+                path = ref_item._split_ref_to_path()
+                if len(path) == 3:
+                    new_refitems.append(
+                        self._update_ref_with_lookup(
+                            item_label=path[1],
+                            item_index=int(path[2]),
+                            lookup=lookup,
+                        )
+                    )
+                else:
+                    new_refitems.append(ref_item)
+
+        return new_refitems
+
+    def _update_breadth_first_with_lookup(
+        self,
+        node: NodeItem,
+        refs_to_be_deleted: list[RefItem],
+        lookup: dict[str, dict[int, int]],
+    ):
+        """Update breadth first with lookup."""
+        # Update the captions, references and footnote references
+        if isinstance(node, FloatingItem):
+            node.captions = self._update_refitems_with_lookup(
+                ref_items=node.captions,
+                refs_to_be_deleted=refs_to_be_deleted,
+                lookup=lookup,
+            )
+            node.references = self._update_refitems_with_lookup(
+                ref_items=node.references,
+                refs_to_be_deleted=refs_to_be_deleted,
+                lookup=lookup,
+            )
+            node.footnotes = self._update_refitems_with_lookup(
+                ref_items=node.footnotes,
+                refs_to_be_deleted=refs_to_be_deleted,
+                lookup=lookup,
+            )
+
+        # Update the self_ref reference
+        if node.parent is not None:
+            path = node.parent._split_ref_to_path()
+            if len(path) == 3:
+                node.parent = self._update_ref_with_lookup(
+                    item_label=path[1], item_index=int(path[2]), lookup=lookup
+                )
+
+        # Update the parent reference
+        if node.self_ref is not None:
+            path = node.self_ref.split("/")
+            if len(path) == 3:
+                _ref = self._update_ref_with_lookup(
+                    item_label=path[1], item_index=int(path[2]), lookup=lookup
+                )
+                node.self_ref = _ref.cref
+
+        # Update the child references
+        node.children = self._update_refitems_with_lookup(
+            ref_items=node.children,
+            refs_to_be_deleted=refs_to_be_deleted,
+            lookup=lookup,
+        )
+
+        for i, child_ref in enumerate(node.children):
+            node = child_ref.resolve(self)
+            self._update_breadth_first_with_lookup(
+                node=node, refs_to_be_deleted=refs_to_be_deleted, lookup=lookup
+            )
+
     ###################################
     # TODO: refactor add* methods below
     ###################################
@@ -2321,21 +2771,33 @@ class DoclingDocument(BaseModel):
         included_content_layers: Optional[set[ContentLayer]] = None,
         _level: int = 0,  # fixed parameter, carries through the node nesting level
     ) -> typing.Iterable[Tuple[NodeItem, int]]:  # tuple of node and level
-        """iterate_elements.
-
-        :param root: Optional[NodeItem]:  (Default value = None)
-        :param with_groups: bool:  (Default value = False)
-        :param traverse_pictures: bool:  (Default value = False)
-        :param page_no: Optional[int]:  (Default value = None)
-        :param _level:  (Default value = 0)
-        :param # fixed parameter:
-        :param carries through the node nesting level:
-        """
+        """Iterate elements with level."""
+        for item, stack in self._iterate_items_with_stack(
+            root=root,
+            with_groups=with_groups,
+            traverse_pictures=traverse_pictures,
+            page_no=page_no,
+            included_content_layers=included_content_layers,
+        ):
+            yield item, len(stack)
+
+    def _iterate_items_with_stack(
+        self,
+        root: Optional[NodeItem] = None,
+        with_groups: bool = False,
+        traverse_pictures: bool = False,
+        page_no: Optional[int] = None,
+        included_content_layers: Optional[set[ContentLayer]] = None,
+        _stack: Optional[list[int]] = None,
+    ) -> typing.Iterable[Tuple[NodeItem, list[int]]]:  # tuple of node and level
+        """Iterate elements with stack."""
         my_layers = (
             included_content_layers
             if included_content_layers is not None
             else DEFAULT_CONTENT_LAYERS
         )
+        my_stack: list[int] = _stack if _stack is not None else []
+
         if not root:
             root = self.body
 
@@ -2355,25 +2817,31 @@ class DoclingDocument(BaseModel):
         )
 
         if should_yield:
-            yield root, _level
+            yield root, my_stack
 
         # Handle picture traversal - only traverse children if requested
         if isinstance(root, PictureItem) and not traverse_pictures:
             return
 
+        my_stack.append(-1)
+
         # Traverse children
-        for child_ref in root.children:
+        for child_ind, child_ref in enumerate(root.children):
+            my_stack[-1] = child_ind
             child = child_ref.resolve(self)
+
             if isinstance(child, NodeItem):
-                yield from self.iterate_items(
+                yield from self._iterate_items_with_stack(
                     child,
                     with_groups=with_groups,
                     traverse_pictures=traverse_pictures,
                     page_no=page_no,
-                    _level=_level + 1,
+                    _stack=my_stack,
                     included_content_layers=my_layers,
                 )
 
+        my_stack.pop()
+
     def _clear_picture_pil_cache(self):
         """Clear cache storage of all images."""
         for item, level in self.iterate_items(with_groups=False):


Test patch:
diff --git a/test/test_docling_doc.py b/test/test_docling_doc.py
index 6d31f61..f29f19e 100644
--- a/test/test_docling_doc.py
+++ b/test/test_docling_doc.py
@@ -1,5 +1,6 @@
 import os
 from collections import deque
+from copy import deepcopy
 from pathlib import Path
 from typing import List, Optional
 from unittest.mock import Mock
@@ -28,8 +29,10 @@ from docling_core.types.doc.document import (  # BoundingBox,
     ImageRef,
     KeyValueItem,
     ListItem,
+    NodeItem,
     PictureItem,
     ProvenanceItem,
+    RefItem,
     SectionHeaderItem,
     Size,
     TableCell,
@@ -482,7 +485,9 @@ def _verify_regression_test(pred: str, filename: str, ext: str):
         with open(filename + f".{ext}", "r", encoding="utf-8") as fr:
             gt_true = fr.read().rstrip()
 
-        assert gt_true == pred, f"Does not pass regression-test for {filename}.{ext}"
+        assert (
+            gt_true == pred
+        ), f"Does not pass regression-test for {filename}.{ext}\n\n{gt_true}\n\n{pred}"
     else:
         with open(filename + f".{ext}", "w", encoding="utf-8") as fw:
             fw.write(f"{pred}\n")
@@ -1141,8 +1146,13 @@ def _verify_saved_output(filename: str, paths: List[Path]):
         assert pred == gt, f"pred!=gt for {filename}"
 
 
-def _verify_loaded_output(filename: str, pred=None):
-    gt = DoclingDocument.load_from_json(Path(str(filename) + ".gt"))
+def _gt_filename(filename: Path) -> Path:
+    return Path(str(filename) + ".gt")
+
+
+def _verify_loaded_output(filename: Path, pred=None):
+    # gt = DoclingDocument.load_from_json(Path(str(filename) + ".gt"))
+    gt = DoclingDocument.load_from_json(_gt_filename(filename=filename))
 
     pred = pred or DoclingDocument.load_from_json(Path(filename))
     assert isinstance(pred, DoclingDocument)
@@ -1248,3 +1258,143 @@ def test_save_to_disk():
     _verify_saved_output(filename=filename, paths=paths)
 
     assert True
+
+
+def test_document_stack_operations():
+
+    doc: DoclingDocument = _construct_doc()
+
+    # _print(document=doc)
+
+    ref = RefItem(cref="#/texts/12")
+    success, stack = doc._get_stack_of_refitem(ref=ref)
+
+    assert success
+    assert stack == [
+        2,
+        2,
+        2,
+        0,
+        2,
+        0,
+        0,
+    ], f"stack==[2, 2, 2, 0, 2, 0, 0] for stack: {stack}"
+
+
+def test_document_manipulation():
+
+    def _resolve(doc: DoclingDocument, cref: str) -> NodeItem:
+        ref = RefItem(cref=cref)
+        return ref.resolve(doc=doc)
+
+    def _verify(filename: Path, document: DoclingDocument, generate: bool = False):
+        if generate or (not os.path.exists(_gt_filename(filename=filename))):
+            doc.save_as_json(
+                filename=_gt_filename(filename=filename),
+                artifacts_dir=image_dir,
+                image_mode=ImageRefMode.EMBEDDED,
+            )
+        # test if the document is still model-validating
+        DoclingDocument.load_from_json(filename=_gt_filename(filename=filename))
+
+        # test if the document is the same as the stored GT
+        _verify_loaded_output(filename=filename, pred=doc)
+
+    image_dir = Path("./test/data/doc/constructed_images/")
+
+    doc: DoclingDocument = _construct_doc()
+
+    text_item_1 = ListItem(
+        self_ref="#",
+        text="new list item (before)",
+        orig="new list item (before)",
+    )
+    text_item_2 = ListItem(
+        self_ref="#",
+        text="new list item (after)",
+        orig="new list item (after)",
+    )
+
+    node = _resolve(doc=doc, cref="#/texts/10")
+
+    doc.insert_item_before_sibling(new_item=text_item_1, sibling=node)
+    doc.insert_item_after_sibling(new_item=text_item_2, sibling=node)
+
+    filename = Path("test/data/doc/constructed_doc.inserted_text.json")
+    _verify(filename=filename, document=doc, generate=GEN_TEST_DATA)
+
+    items = [_resolve(doc=doc, cref="#/texts/10")]
+    doc.delete_items(node_items=items)
+
+    filename = Path("test/data/doc/constructed_doc.deleted_text.json")
+    _verify(filename=filename, document=doc, generate=GEN_TEST_DATA)
+
+    items = [_resolve(doc=doc, cref="#/groups/1")]
+    doc.delete_items(node_items=items)
+
+    filename = Path("test/data/doc/constructed_doc.deleted_group.json")
+    _verify(filename=filename, document=doc, generate=GEN_TEST_DATA)
+
+    items = [_resolve(doc=doc, cref="#/pictures/1")]
+    doc.delete_items(node_items=items)
+
+    filename = Path("test/data/doc/constructed_doc.deleted_picture.json")
+    _verify(filename=filename, document=doc, generate=GEN_TEST_DATA)
+
+    text_item_3 = TextItem(
+        self_ref="#",
+        text="child text appended at body",
+        orig="child text appended at body",
+        label=DocItemLabel.TEXT,
+    )
+    doc.append_child_item(child=text_item_3)
+
+    text_item_4 = ListItem(
+        self_ref="#",
+        text="child text appended at body",
+        orig="child text appended at body",
+        label=DocItemLabel.LIST_ITEM,
+    )
+    parent = _resolve(doc=doc, cref="#/groups/11")
+    doc.append_child_item(child=text_item_4, parent=parent)
+
+    # try to add a sibling to the root:
+    with pytest.raises(ValueError):
+        doc.insert_item_before_sibling(
+            new_item=TextItem(
+                self_ref="#",
+                label=DocItemLabel.TEXT,
+                text="foo",
+                orig="foo",
+            ),
+            sibling=doc.body,
+        )
+
+    # try to append a child with children of its own:
+    with pytest.raises(ValueError):
+        doc.append_child_item(
+            child=TextItem(
+                self_ref="#",
+                label=DocItemLabel.TEXT,
+                text="foo",
+                orig="foo",
+                children=[
+                    _resolve(doc=deepcopy(doc), cref=text_item_4.self_ref).get_ref()
+                ],
+            ),
+            parent=doc.body,
+        )
+
+    filename = Path("test/data/doc/constructed_doc.appended_child.json")
+    _verify(filename=filename, document=doc, generate=GEN_TEST_DATA)
+
+    text_item_5 = TextItem(
+        self_ref="#",
+        text="new child",
+        orig="new child",
+        label=DocItemLabel.TEXT,
+    )
+    doc.replace_item(old_item=text_item_3, new_item=text_item_5)
+
+    filename = Path("test/data/doc/constructed_doc.replaced_item.json")
+    _verify(filename=filename, document=doc, generate=GEN_TEST_DATA)