diff --git a/docling_core/types/doc/__init__.py b/docling_core/types/doc/__init__.py
index 4625f7402..a8f44fb99 100644
--- a/docling_core/types/doc/__init__.py
+++ b/docling_core/types/doc/__init__.py
@@ -57,6 +57,7 @@
DocTagsPage,
)
from docling_core.types.doc.document import DoclingDocument
+from docling_core.types.doc.items.attachment import AttachmentItem, AttachmentStatus
from docling_core.types.doc.items.code import CodeItem
from docling_core.types.doc.items.content import ContentItem
from docling_core.types.doc.items.form import (
diff --git a/docling_core/types/doc/common/constants.py b/docling_core/types/doc/common/constants.py
index a4d2c2896..b833bb2ec 100644
--- a/docling_core/types/doc/common/constants.py
+++ b/docling_core/types/doc/common/constants.py
@@ -31,6 +31,7 @@
DocItemLabel.FIELD_HINT,
DocItemLabel.MARKER,
DocItemLabel.HANDWRITTEN_TEXT,
+ DocItemLabel.ATTACHMENT,
}
diff --git a/docling_core/types/doc/document.py b/docling_core/types/doc/document.py
index ce795f974..1667c9a56 100644
--- a/docling_core/types/doc/document.py
+++ b/docling_core/types/doc/document.py
@@ -99,6 +99,7 @@
from docling_core.types.doc.common.scalars import CharSpan, LevelNumber, Uint64
from docling_core.types.doc.common.source import BaseSource, SourceType, TrackSource
from docling_core.types.doc.doctags import DocTagsDocument, DocTagsPage
+from docling_core.types.doc.items.attachment import AttachmentItem, AttachmentStatus
from docling_core.types.doc.items.code import CodeItem
from docling_core.types.doc.items.content import ContentItem
from docling_core.types.doc.items.form import FieldHeadingItem, FieldItem, FieldRegionItem, FieldValueItem
@@ -209,6 +210,7 @@ class DoclingDocument(BaseModel):
form_items: list[FormItem] = []
field_regions: list[FieldRegionItem] = []
field_items: list[FieldItem] = []
+ attachments: list[AttachmentItem] = []
pages: dict[int, PageItem] = {} # empty as default
@@ -217,7 +219,7 @@ def _custom_pydantic_serialize(self, handler: SerializerFunctionWrapHandler) ->
dumped = handler(self)
# suppress serializing certain fields when empty:
- for field in {"field_regions", "field_items"}:
+ for field in {"field_regions", "field_items", "attachments"}:
if dumped.get(field) == []:
del dumped[field]
@@ -346,6 +348,7 @@ def clamp_table_cell_bboxes(table: TableItem) -> None:
self.form_items,
self.field_regions,
self.field_items,
+ self.attachments,
)
for item_list in item_lists:
for item in item_list:
@@ -933,6 +936,17 @@ def _append_item(self, *, item: NodeItem, parent_ref: RefItem) -> RefItem:
self.field_items.append(item)
+ elif isinstance(item, AttachmentItem):
+ item_label = "attachments"
+ item_index = len(self.attachments)
+
+ cref = f"#/{item_label}/{item_index}"
+
+ item.self_ref = cref
+ item.parent = parent_ref
+
+ self.attachments.append(item)
+
elif isinstance(item, ListGroup | InlineGroup):
item_label = "groups"
item_index = len(self.groups)
@@ -1761,6 +1775,59 @@ def add_picture(
return fig_item
+ def add_attachment(
+ self,
+ name: str,
+ mime_type: Optional[str] = None,
+ size: Optional[int] = None,
+ target: Optional[Union[str, AnyUrl]] = None,
+ status: AttachmentStatus = "converted",
+ data: Optional[bytes] = None,
+ doc_data: Optional[bytes] = None,
+ prov: Optional[ProvenanceItem] = None,
+ parent: Optional[NodeItem] = None,
+ content_layer: Optional[ContentLayer] = None,
+ ) -> AttachmentItem:
+ """Add an attachment reference to the document.
+
+ :param name: Original attachment filename.
+ :param mime_type: Optional MIME type.
+ :param size: Optional payload size in bytes.
+ :param target: Optional relative path/URL to converted output.
+ :param status: Conversion status.
+ :param data: Optional raw binary payload.
+ :param doc_data: Optional serialized DoclingDocument bytes for the recursively parsed attachment.
+ :param prov: Optional provenance (page + bbox) for inline placement.
+ :param parent: Parent node; defaults to body.
+ :param content_layer: Optional content layer override.
+ """
+ if not parent:
+ parent = self.body
+
+ attachment_index = len(self.attachments)
+ cref = f"#/attachments/{attachment_index}"
+
+ item = AttachmentItem(
+ name=name,
+ mime_type=mime_type,
+ size=size,
+ target=target,
+ status=status,
+ data=data,
+ doc_data=doc_data,
+ self_ref=cref,
+ parent=parent.get_ref(),
+ )
+ if prov:
+ item.prov.append(prov)
+ if content_layer:
+ item.content_layer = content_layer
+
+ self.attachments.append(item)
+ parent.children.append(RefItem(cref=cref))
+
+ return item
+
def add_title(
self,
text: str,
@@ -5218,6 +5285,7 @@ def _validate_unique_refs(self) -> Self:
self.form_items,
self.field_regions,
self.field_items,
+ self.attachments,
]
for item_list in item_lists:
for item in item_list:
@@ -5315,6 +5383,7 @@ class _DocIndex(BaseModel):
form_items: list[FormItem] = []
field_regions: list[FieldRegionItem] = []
field_items: list[FieldItem] = []
+ attachments: list[AttachmentItem] = []
pages: dict[int, PageItem] = {}
@@ -5347,6 +5416,7 @@ def index(self, doc: "DoclingDocument", page_nrs: Optional[set[int]] = None) ->
"form_items",
"field_regions",
"field_items",
+ "attachments",
]
start_indices = {k: len(self.get_item_list(k)) for k in post_processing_keys}
@@ -5476,6 +5546,7 @@ def _update_from_index(self, doc_index: "_DocIndex") -> None:
self.form_items = doc_index.form_items
self.field_regions = doc_index.field_regions
self.field_items = doc_index.field_items
+ self.attachments = doc_index.attachments
self.pages = doc_index.pages
self.name = doc_index.get_name()
diff --git a/docling_core/types/doc/items/attachment.py b/docling_core/types/doc/items/attachment.py
new file mode 100644
index 000000000..159e4e035
--- /dev/null
+++ b/docling_core/types/doc/items/attachment.py
@@ -0,0 +1,95 @@
+"""Attachment document item."""
+
+import typing
+import warnings
+from typing import Optional, Union
+
+from pydantic import AnyUrl, Field, model_validator
+
+from docling_core.types.doc.common.reference import ProvenanceItem
+from docling_core.types.doc.items.node import DocItem
+from docling_core.types.doc.labels import DocItemLabel
+
+if typing.TYPE_CHECKING:
+ from pathlib import Path
+
+ from docling_core.types.doc.document import DoclingDocument
+
+
+AttachmentStatus = typing.Literal["converted", "failed", "unsupported", "depth_limited"]
+
+
+class AttachmentItem(DocItem):
+ """An embedded file attachment referenced by a document."""
+
+ label: typing.Literal[DocItemLabel.ATTACHMENT] = DocItemLabel.ATTACHMENT # type: ignore[assignment]
+
+ name: str = Field(description="Original attachment filename.")
+ mime_type: Optional[str] = Field(default=None, description="MIME type of the attachment payload.")
+ size: Optional[int] = Field(default=None, description="Attachment payload size in bytes.")
+ target: Optional[Union[str, AnyUrl]] = Field(
+ default=None,
+ description="Relative path/URL to the converted attachment output, or None if not converted.",
+ )
+ status: AttachmentStatus = Field(
+ default="converted",
+ description=(
+ "Conversion status of the attachment. Deprecated when `doc_data` is set: "
+ "a present `doc_data` implies successful conversion and `status` is ignored."
+ ),
+ )
+ data: Optional[bytes] = Field(
+ default=None,
+ description="Raw binary payload of the attachment. Stored as base64 in JSON.",
+ )
+ doc_data: Optional[bytes] = Field(
+ default=None,
+ description=(
+ "Serialized DoclingDocument (e.g. JSON/DCLG/DCLX) of the recursively parsed attachment. "
+ "When present, the attachment is implicitly considered converted and `status` is ignored."
+ ),
+ )
+
+ @model_validator(mode="after")
+ def _validate_status_with_doc_data(self) -> "AttachmentItem":
+ if self.doc_data is not None and self.status != "converted":
+ warnings.warn(
+ f"Attachment '{self.name}' has doc_data set but status='{self.status}'; "
+ "doc_data implies converted, status will be ignored.",
+ UserWarning,
+ stacklevel=2,
+ )
+ return self
+
+ def export_to_doctags(
+ self,
+ doc: "DoclingDocument",
+ new_line: str = "", # deprecated
+ xsize: int = 500,
+ ysize: int = 500,
+ add_location: bool = True,
+ add_content: bool = True,
+ ):
+ """Export to document tokens format."""
+ # Simple fallback without requiring DocTagsAttachmentSerializer (deferred per #713).
+ # Serializer support will be added in a follow-up PR.
+ parts: list[str] = []
+ if add_location and self.prov:
+ try:
+ loc = self.get_location_tokens(doc=doc, xsize=xsize, ysize=ysize)
+ if loc:
+ parts.append(loc)
+ except Exception:
+ pass
+ if add_content:
+ if self.status == "converted" and self.target:
+ parts.append(f"{self.name} ({self.target})")
+ elif self.doc_data is not None:
+ parts.append(f"{self.name} (converted, embedded document)")
+ else:
+ reason = self.status.replace("_", " ")
+ parts.append(f"{self.name} (not converted: {reason})")
+ text = "".join(parts)
+ if text:
+ text = f"{text}"
+ return text
diff --git a/docling_core/types/doc/labels.py b/docling_core/types/doc/labels.py
index e0810a47a..af49b22c8 100644
--- a/docling_core/types/doc/labels.py
+++ b/docling_core/types/doc/labels.py
@@ -29,6 +29,7 @@ class DocItemLabel(str, Enum):
# e.g. ★★☆☆☆
HANDWRITTEN_TEXT = "handwritten_text"
EMPTY_VALUE = "empty_value" # used for empty value fields in fillable forms
+ ATTACHMENT = "attachment"
# Additional labels for markup-based formats (e.g. HTML, Word)
PARAGRAPH = "paragraph"
@@ -80,6 +81,7 @@ def get_color(label: "DocItemLabel") -> tuple[int, int, int]:
DocItemLabel.FIELD_VALUE: (135, 80, 20),
DocItemLabel.FIELD_HINT: (190, 120, 90),
DocItemLabel.MARKER: (205, 85, 120),
+ DocItemLabel.ATTACHMENT: (204, 204, 255),
}
return color_map.get(label, (0, 0, 0))
diff --git a/docling_core/types/doc/page.py b/docling_core/types/doc/page.py
index 8a65f8d84..25c8366a9 100644
--- a/docling_core/types/doc/page.py
+++ b/docling_core/types/doc/page.py
@@ -415,6 +415,43 @@ def parse_uri(cls, v: Any) -> Union[AnyUrl, str, None]:
return str(v)
+class FileAttachmentAnnotation(BaseModel):
+ """Position of a FileAttachment annotation on a page (1-based, PDF convention).
+
+ Attributes:
+ page_no: 1-based page number (1 = first page), translated from 0-based C++ storage.
+ bbox: Bounding rectangle in PDF user space (bottom-left origin).
+ """
+
+ model_config = {"validate_assignment": True} # type: ignore[dict-item]
+
+ page_no: PageNumber
+ bbox: BoundingRectangle
+
+
+class PdfAttachment(BaseModel):
+ """PDF attachment (embedded file) with optional page annotations.
+
+ Attributes:
+ name: Filename from /UF or /F.
+ mime_type: MIME subtype if present (e.g., 'text/plain').
+ size: Decoded size in bytes (from /Params /Size or /Length).
+ annotations: List of FileAttachment annotation positions (empty if unanchored).
+ data: Raw binary payload of the embedded file, if available.
+ """
+
+ model_config = {"validate_assignment": True} # type: ignore[dict-item]
+
+ name: str
+ mime_type: Optional[str] = None
+ size: int = 0
+ annotations: list[FileAttachmentAnnotation] = []
+ data: Optional[bytes] = Field(
+ default=None,
+ description="Raw binary payload of the embedded file, if available.",
+ )
+
+
class BitmapResource(OrderedElement):
"""Model representing a bitmap resource with positioning and URI information."""
@@ -754,6 +791,11 @@ class SegmentedPdfPage(SegmentedPage):
)
shapes: list[PdfShape] = []
+ attachments: list[PdfAttachment] = Field(
+ default_factory=list,
+ description="File attachments anchored to this page (via FileAttachment annotations).",
+ )
+
# Redefine typing of elements to include PdfTextCell
char_cells: list[Union[PdfTextCell, TextCell]]
word_cells: list[Union[PdfTextCell, TextCell]]
@@ -1530,6 +1572,11 @@ class ParsedPdfDocument(BaseModel):
pages: dict[PageNumber, SegmentedPdfPage] = {}
+ attachments: list[PdfAttachment] = Field(
+ default_factory=list,
+ description="Embedded file attachments extracted from the PDF document.",
+ )
+
meta_data: Optional[PdfMetaData] = None
table_of_contents: Optional[PdfTableOfContents] = None
diff --git a/docling_core/types/doc/tokens.py b/docling_core/types/doc/tokens.py
index 5da137cd0..fe0dea689 100644
--- a/docling_core/types/doc/tokens.py
+++ b/docling_core/types/doc/tokens.py
@@ -202,6 +202,7 @@ class DocumentToken(str, Enum):
PARAGRAPH = "paragraph"
REFERENCE = "reference"
HANDWRITTEN_TEXT = "handwritten_text"
+ ATTACHMENT = "attachment"
@classmethod
def get_special_tokens(
@@ -255,6 +256,7 @@ def create_token_name_from_doc_item_label(cls, label: str, level: int = 1) -> st
DocItemLabel.REFERENCE: DocumentToken.REFERENCE,
DocItemLabel.CHART: DocumentToken.CHART,
DocItemLabel.HANDWRITTEN_TEXT: DocumentToken.HANDWRITTEN_TEXT,
+ DocItemLabel.ATTACHMENT: DocumentToken.ATTACHMENT,
}
res: str
diff --git a/docs/DoclingDocument.json b/docs/DoclingDocument.json
index 74f4d839d..59fb99410 100644
--- a/docs/DoclingDocument.json
+++ b/docs/DoclingDocument.json
@@ -1,5 +1,186 @@
{
"$defs": {
+ "AttachmentItem": {
+ "additionalProperties": false,
+ "description": "An embedded file attachment referenced by a document.",
+ "properties": {
+ "self_ref": {
+ "pattern": "^#(?:/([\\w-]+)(?:/(\\d+))?)?$",
+ "title": "Self Ref",
+ "type": "string"
+ },
+ "parent": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/RefItem"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null
+ },
+ "children": {
+ "default": [],
+ "items": {
+ "$ref": "#/$defs/RefItem"
+ },
+ "title": "Children",
+ "type": "array"
+ },
+ "content_layer": {
+ "$ref": "#/$defs/ContentLayer",
+ "default": "body"
+ },
+ "meta": {
+ "anyOf": [
+ {
+ "$ref": "#/$defs/BaseMeta"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null
+ },
+ "label": {
+ "const": "attachment",
+ "default": "attachment",
+ "title": "Label",
+ "type": "string"
+ },
+ "prov": {
+ "default": [],
+ "items": {
+ "$ref": "#/$defs/ProvenanceItem"
+ },
+ "title": "Prov",
+ "type": "array"
+ },
+ "source": {
+ "default": [],
+ "description": "The provenance of this document item. Currently, it is only used for media track provenance.",
+ "items": {
+ "discriminator": {
+ "mapping": {
+ "track": "#/$defs/TrackSource"
+ },
+ "propertyName": "kind"
+ },
+ "oneOf": [
+ {
+ "$ref": "#/$defs/TrackSource"
+ }
+ ]
+ },
+ "title": "Source",
+ "type": "array"
+ },
+ "comments": {
+ "default": [],
+ "items": {
+ "$ref": "#/$defs/FineRef"
+ },
+ "title": "Comments",
+ "type": "array"
+ },
+ "name": {
+ "description": "Original attachment filename.",
+ "title": "Name",
+ "type": "string"
+ },
+ "mime_type": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "description": "MIME type of the attachment payload.",
+ "title": "Mime Type"
+ },
+ "size": {
+ "anyOf": [
+ {
+ "type": "integer"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "description": "Attachment payload size in bytes.",
+ "title": "Size"
+ },
+ "target": {
+ "anyOf": [
+ {
+ "type": "string"
+ },
+ {
+ "format": "uri",
+ "minLength": 1,
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "description": "Relative path/URL to the converted attachment output, or None if not converted.",
+ "title": "Target"
+ },
+ "status": {
+ "default": "converted",
+ "description": "Conversion status of the attachment. Deprecated when `doc_data` is set: a present `doc_data` implies successful conversion and `status` is ignored.",
+ "enum": [
+ "converted",
+ "failed",
+ "unsupported",
+ "depth_limited"
+ ],
+ "title": "Status",
+ "type": "string"
+ },
+ "data": {
+ "anyOf": [
+ {
+ "format": "binary",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "description": "Raw binary payload of the attachment. Stored as base64 in JSON.",
+ "title": "Data"
+ },
+ "doc_data": {
+ "anyOf": [
+ {
+ "format": "binary",
+ "type": "string"
+ },
+ {
+ "type": "null"
+ }
+ ],
+ "default": null,
+ "description": "Serialized DoclingDocument (e.g. JSON/DCLG/DCLX) of the recursively parsed attachment. When present, the attachment is implicitly considered converted and `status` is ignored.",
+ "title": "Doc Data"
+ }
+ },
+ "required": [
+ "self_ref",
+ "name"
+ ],
+ "title": "AttachmentItem",
+ "type": "object"
+ },
"BaseMeta": {
"additionalProperties": true,
"description": "Base class for metadata.",
@@ -4638,6 +4819,14 @@
"title": "Field Items",
"type": "array"
},
+ "attachments": {
+ "default": [],
+ "items": {
+ "$ref": "#/$defs/AttachmentItem"
+ },
+ "title": "Attachments",
+ "type": "array"
+ },
"pages": {
"additionalProperties": {
"$ref": "#/$defs/PageItem"
@@ -4652,4 +4841,4 @@
],
"title": "DoclingDocument",
"type": "object"
-}
\ No newline at end of file
+}
diff --git a/test/data/docling_document/unit/AttachmentItem.yaml b/test/data/docling_document/unit/AttachmentItem.yaml
new file mode 100644
index 000000000..a2f013683
--- /dev/null
+++ b/test/data/docling_document/unit/AttachmentItem.yaml
@@ -0,0 +1,14 @@
+children: []
+content_layer: body
+data: null
+doc_data: null
+label: attachment
+meta: null
+mime_type: null
+name: report.pdf
+parent: null
+prov: []
+self_ref: '#'
+size: null
+status: converted
+target: null
diff --git a/test/test_attachment_serialization.py b/test/test_attachment_serialization.py
new file mode 100644
index 000000000..1a3d212e8
--- /dev/null
+++ b/test/test_attachment_serialization.py
@@ -0,0 +1,231 @@
+"""Tests for AttachmentItem schema and storage (data-model only, no serializers)."""
+
+import warnings
+
+from docling_core.types.doc import (
+ AttachmentItem,
+ DocItemLabel,
+ DoclingDocument,
+ ProvenanceItem,
+)
+from docling_core.types.doc.page import BoundingBox
+
+
+def _prov() -> ProvenanceItem:
+ return ProvenanceItem(
+ page_no=1,
+ bbox=BoundingBox(l=0, t=0, r=10, b=10),
+ charspan=(0, 0),
+ )
+
+
+def test_attachment_item_defaults():
+ item = AttachmentItem(name="file.pdf", self_ref="#")
+ assert item.label == DocItemLabel.ATTACHMENT
+ assert item.status == "converted"
+ assert item.target is None
+
+
+def test_attachment_storage_and_traversal():
+ """Attachments are stored in DoclingDocument and reachable via iterate_items."""
+ doc = DoclingDocument(name="test")
+ doc.add_attachment(name="report.pdf", target="report.md")
+ assert len(doc.attachments) == 1
+ # not yet serialized to markdown (serializers deferred to follow-up PR)
+ # but stored and reachable
+ found = [item for item, _ in doc.iterate_items(with_groups=True) if isinstance(item, AttachmentItem)]
+ assert len(found) == 1
+ assert found[0].name == "report.pdf"
+
+
+def test_attachment_positioned_via_prov():
+ doc = DoclingDocument(name="test")
+ doc.add_text(DocItemLabel.PARAGRAPH, "Before")
+ doc.add_attachment(
+ name="annex.pdf",
+ target="annex.md",
+ prov=_prov(),
+ )
+ doc.add_text(DocItemLabel.PARAGRAPH, "After")
+ assert doc.attachments[0].prov[0].page_no == 1
+ lines = [item.text if hasattr(item, "text") else item.name for item, _ in doc.iterate_items()]
+ assert "Before" in lines and "annex.pdf" in lines and "After" in lines
+
+
+def test_attachment_json_roundtrip():
+ doc = DoclingDocument(name="test")
+ doc.add_attachment(name="report.pdf", target="report.md", size=1234)
+ dumped = doc.model_dump_json()
+ loaded = DoclingDocument.model_validate_json(dumped)
+ assert len(loaded.attachments) == 1
+ assert loaded.attachments[0].name == "report.pdf"
+ assert loaded.attachments[0].target == "report.md"
+ assert loaded.attachments[0].size == 1234
+
+
+def test_attachment_normalize_references():
+ doc = DoclingDocument(name="test")
+ doc.add_attachment(name="doc.pdf", target="doc.md")
+ doc._normalize_references()
+ assert len(doc.attachments) == 1
+ assert doc.attachments[0].self_ref == "#/attachments/0"
+
+
+def test_add_item_attachment():
+ doc = DoclingDocument(name="test")
+ item = AttachmentItem(name="manual.pdf", target="manual.md", self_ref="#")
+ cref = doc._append_item(item=item, parent_ref=doc.body.get_ref())
+ assert cref.cref == "#/attachments/0"
+ assert len(doc.attachments) == 1
+ assert doc.attachments[0].name == "manual.pdf"
+ assert doc.attachments[0].self_ref == "#/attachments/0"
+
+
+def test_attachment_with_binary_data_roundtrip():
+ doc = DoclingDocument(name="test")
+ raw = b"%PDF-1.4 fake content"
+ parsed = b'{"name": "inner"}'
+ att = doc.add_attachment(
+ name="report.pdf",
+ mime_type="application/pdf",
+ size=len(raw),
+ data=raw,
+ doc_data=parsed,
+ )
+ assert att.data == raw
+ assert att.doc_data == parsed
+ # JSON roundtrip preserves base64-encoded bytes
+ dumped = doc.model_dump_json()
+ loaded = DoclingDocument.model_validate_json(dumped)
+ assert loaded.attachments[0].data == raw
+ assert loaded.attachments[0].doc_data == parsed
+ # YAML gold-file path also roundtrips
+ assert AttachmentItem(name="x.pdf", self_ref="#", data=raw).data == raw
+
+
+def test_attachment_doc_data_implies_converted():
+ """Point 3: doc_data presence implies converted; status is ignored with warning."""
+ # doc_data set with non-converted status should warn but succeed
+ with warnings.catch_warnings(record=True) as w:
+ warnings.simplefilter("always")
+ item = AttachmentItem(name="x.pdf", self_ref="#", data=b"raw", doc_data=b'{"x":1}', status="failed")
+ assert any("doc_data implies converted" in str(x.message) for x in w)
+ assert item.doc_data == b'{"x": 1}' or item.doc_data == b'{"x":1}'
+ # export_to_doctags uses doc_data branch when present
+ doc = DoclingDocument(name="test")
+ att = doc.add_attachment(name="inner.pdf", data=b"raw", doc_data=b'{"doc":1}')
+ assert "converted" in att.export_to_doctags(doc=doc).lower() or "inner.pdf" in att.export_to_doctags(doc=doc)
+
+
+def test_attachment_content_layer_and_prov():
+ from docling_core.types.doc.common.content_layer import ContentLayer
+
+ doc = DoclingDocument(name="test")
+ att = doc.add_attachment(
+ name="layered.pdf",
+ target="layered.md",
+ prov=_prov(),
+ content_layer=ContentLayer.FURNITURE,
+ )
+ assert att.content_layer == ContentLayer.FURNITURE
+ assert len(att.prov) == 1
+ # also exercise _append_item content_layer path
+ item2 = AttachmentItem(name="via_append.pdf", self_ref="#", content_layer=ContentLayer.FURNITURE)
+ item2.content_layer = ContentLayer.FURNITURE
+ cref = doc._append_item(item=item2, parent_ref=doc.body.get_ref())
+ assert cref.cref == "#/attachments/1"
+
+
+def test_pdf_attachment_with_data_and_parsed_document():
+ from docling_core.types.doc.page import (
+ BoundingRectangle,
+ FileAttachmentAnnotation,
+ ParsedPdfDocument,
+ PdfAttachment,
+ )
+
+ annot = FileAttachmentAnnotation(
+ page_no=1,
+ bbox=BoundingRectangle(r_x0=0, r_y0=0, r_x1=10, r_y1=10, r_x2=10, r_y2=10, r_x3=0, r_y3=10),
+ )
+ pdf_att = PdfAttachment(
+ name="embedded.pdf",
+ mime_type="application/pdf",
+ size=123,
+ annotations=[annot],
+ data=b"binary payload",
+ )
+ assert pdf_att.data == b"binary payload"
+ # ParsedPdfDocument roundtrip with attachments
+ parsed = ParsedPdfDocument(attachments=[pdf_att])
+ dumped = parsed.model_dump_json()
+ loaded = ParsedPdfDocument.model_validate_json(dumped)
+ assert loaded.attachments[0].data == b"binary payload"
+ assert loaded.attachments[0].name == "embedded.pdf"
+
+
+def test_segmented_pdf_page_attachments():
+ """Point 1: PdfAttachment is part of SegmentedPdfPage (and ParsedPdfDocument)."""
+ from docling_core.types.doc.base import CoordOrigin
+ from docling_core.types.doc.page import (
+ BoundingBox,
+ BoundingRectangle,
+ FileAttachmentAnnotation,
+ ParsedPdfDocument,
+ PdfAttachment,
+ PdfPageBoundaryType,
+ PdfPageGeometry,
+ SegmentedPdfPage,
+ )
+
+ annot = FileAttachmentAnnotation(
+ page_no=1,
+ bbox=BoundingRectangle(r_x0=0, r_y0=0, r_x1=10, r_y1=10, r_x2=10, r_y2=10, r_x3=0, r_y3=10),
+ )
+ pdf_att = PdfAttachment(name="page_att.pdf", data=b"page data", annotations=[annot])
+
+ # SegmentedPdfPage can hold attachments
+ page = SegmentedPdfPage(
+ dimension=PdfPageGeometry(
+ angle=0,
+ rect=BoundingRectangle(r_x0=0, r_y0=0, r_x1=100, r_y1=0, r_x2=100, r_y2=100, r_x3=0, r_y3=100),
+ boundary_type=PdfPageBoundaryType.CROP_BOX,
+ art_bbox=BoundingBox(l=0, b=0, r=100, t=100, coord_origin=CoordOrigin.BOTTOMLEFT),
+ bleed_bbox=BoundingBox(l=0, b=0, r=100, t=100, coord_origin=CoordOrigin.BOTTOMLEFT),
+ crop_bbox=BoundingBox(l=0, b=0, r=100, t=100, coord_origin=CoordOrigin.BOTTOMLEFT),
+ media_bbox=BoundingBox(l=0, b=0, r=100, t=100, coord_origin=CoordOrigin.BOTTOMLEFT),
+ trim_bbox=BoundingBox(l=0, b=0, r=100, t=100, coord_origin=CoordOrigin.BOTTOMLEFT),
+ ),
+ char_cells=[],
+ word_cells=[],
+ textline_cells=[],
+ attachments=[pdf_att],
+ )
+ assert len(page.attachments) == 1
+ assert page.attachments[0].data == b"page data"
+ # JSON roundtrip
+ dumped = page.model_dump_json()
+ loaded = SegmentedPdfPage.model_validate_json(dumped)
+ assert loaded.attachments[0].name == "page_att.pdf"
+
+ # Also still works at ParsedPdfDocument level
+ parsed = ParsedPdfDocument(pages={1: page}, attachments=[pdf_att])
+ assert len(parsed.attachments) == 1
+
+
+def test_attachment_export_to_doctags_fallback():
+ """Fallback export_to_doctags without serializer (deferred)."""
+ from docling_core.types.doc.tokens import DocumentToken
+
+ doc = DoclingDocument(name="test")
+ att = doc.add_attachment(name="spec.pdf", target="spec.md")
+ doctags = att.export_to_doctags(doc=doc)
+ assert "" in doctags
+ assert "spec.pdf" in doctags
+ assert (
+ DocumentToken.create_token_name_from_doc_item_label(DocItemLabel.ATTACHMENT) == DocumentToken.ATTACHMENT.value
+ )
+
+ # non-converted
+ att2 = AttachmentItem(name="bad.exe", self_ref="#", status="unsupported")
+ assert "not converted" in att2.export_to_doctags(doc=doc)
diff --git a/test/test_docling_doc.py b/test/test_docling_doc.py
index c073f9011..801e21486 100644
--- a/test/test_docling_doc.py
+++ b/test/test_docling_doc.py
@@ -16,6 +16,7 @@
from pydantic import AnyUrl, BaseModel, ValidationError
from docling_core.types.doc import (
+ AttachmentItem,
BoundingBox,
CodeItem,
ContentLayer,
@@ -582,6 +583,12 @@ def verify(dc, obj):
level=2,
)
verify(dc, obj)
+ elif dc is AttachmentItem:
+ obj = dc(
+ name="report.pdf",
+ self_ref="#",
+ )
+ verify(dc, obj)
elif dc is GraphData: # we skip this on purpose
continue
else: