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: