diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index e641f138d6..9892444874 100644 --- a/docling/backend/mspowerpoint_backend.py +++ b/docling/backend/mspowerpoint_backend.py @@ -48,7 +48,7 @@ TabularChartMetaField, TextItem, ) -from docling_core.types.doc.document import ContentLayer +from docling_core.types.doc.document import ContentLayer, Formatting from lxml import etree from PIL import Image, UnidentifiedImageError from pydantic import AnyUrl, ValidationError @@ -85,6 +85,7 @@ from pptx.shapes.autoshape import Shape from pptx.shapes.base import BaseShape from pptx.shapes.picture import Picture + from pptx.text.text import _Paragraph, _Run _PPTX_AVAILABLE = True except ImportError as e: # pragma: no cover - import-time guard @@ -186,27 +187,11 @@ def _iter_paragraph_elements(element: etree._Element) -> Iterator[etree._Element yield child -def _paragraph_fragments(element: etree._Element) -> list[tuple[DocItemLabel, str]]: - """Coalesce adjacent text runs while keeping equations as separate fragments.""" - fragments: list[tuple[DocItemLabel, str]] = [] - text = "" - for child in _iter_paragraph_elements(element): - if child.tag == f"{{{_MATH_NAMESPACE}}}oMath": - if text: - fragments.append((DocItemLabel.TEXT, text)) - text = "" - equation = str(oMath2Latex(child)).strip() - if equation: - fragments.append((DocItemLabel.FORMULA, equation)) - elif isinstance(child, CT_TextLineBreak): - text += " " - else: - run_text = child.find(qn("a:t")) - if run_text is not None: - text += run_text.text or "" - if text: - fragments.append((DocItemLabel.TEXT, text)) - return fragments +# ``(label, text, formatting, hyperlink)``; equations are FORMULA entries with +# neither formatting nor hyperlink. +_ParagraphRun = tuple[ + DocItemLabel, str, Optional[Formatting], Optional[Union[AnyUrl, Path]] +] _SAFE_XML_PARSER: Final = etree.XMLParser( @@ -1048,8 +1033,128 @@ class _ShapeInfo(NamedTuple): for si in sorted(row, key=lambda si: (si.left, si.index)): yield si.shape + def _get_run_format(self, run: _Run) -> Formatting: + """Build a ``Formatting`` (bold, italic, underline) from a run's font.""" + font = run.font + return Formatting( + bold=bool(font.bold), + italic=bool(font.italic), + underline=bool(font.underline), + ) + + def _get_run_hyperlink_target(self, run: _Run) -> Optional[AnyUrl | Path]: + """Resolve a run's hyperlink to a URL or local path, or ``None``.""" + try: + address = run.hyperlink.address + except KeyError: + return None + if not address: + return None + if not urlparse(address).scheme: + return Path(address) + try: + return AnyUrl(address) + except ValidationError: + _log.warning("Skipping malformed hyperlink address: %r", address) + return None + + def _iter_paragraph_runs(self, paragraph: _Paragraph) -> list[_ParagraphRun]: + """Split a paragraph into ``(label, text, formatting, hyperlink)`` units.""" + elements: list[_ParagraphRun] = [] + for e in _iter_paragraph_elements(paragraph._element): + if e.tag == f"{{{_MATH_NAMESPACE}}}oMath": + equation = str(oMath2Latex(e)).strip() + if equation: + elements.append((DocItemLabel.FORMULA, equation, None, None)) + elif isinstance(e, CT_TextLineBreak): + elements.append((DocItemLabel.TEXT, " ", None, None)) + else: + run = _Run(e, paragraph) + elements.append( + ( + DocItemLabel.TEXT, + run.text, + self._get_run_format(run), + self._get_run_hyperlink_target(run), + ) + ) + + # Merge adjacent text runs that share formatting and hyperlink, so a word + # split across runs doesn't get a spurious space between its parts. + coalesced: list[_ParagraphRun] = [] + for label, text, format, hyperlink in elements: + if ( + coalesced + and label == DocItemLabel.TEXT + and coalesced[-1][0] == DocItemLabel.TEXT + and coalesced[-1][2] == format + and coalesced[-1][3] == hyperlink + ): + prev_text = coalesced[-1][1] + coalesced[-1] = (label, prev_text + text, format, hyperlink) + else: + coalesced.append((label, text, format, hyperlink)) + return coalesced + + def _add_list_item_with_formatting( + self, + doc: DoclingDocument, + elements: list[_ParagraphRun], + marker: str, + enumerated: bool, + parent: ListGroup, + shape, + slide_ind: int, + slide_size, + content_layer: Optional[ContentLayer] = None, + ) -> ListItem: + """Add a list item; several runs or an equation go in an inline group.""" + non_empty = [element for element in elements if element[1]] + has_equations = any(label == DocItemLabel.FORMULA for label, *_ in non_empty) + if len(non_empty) <= 1 and not has_equations: + _, text, format, hyperlink = ( + non_empty[0] if non_empty else (DocItemLabel.TEXT, "", None, None) + ) + return doc.add_list_item( + marker=marker, + enumerated=enumerated, + parent=parent, + text=text, + prov=self._generate_prov(shape, slide_ind, text, slide_size), + formatting=format, + hyperlink=hyperlink, + content_layer=content_layer, + ) + + item = doc.add_list_item( + marker=marker, + enumerated=enumerated, + parent=parent, + text="", + prov=self._generate_prov(shape, slide_ind, "", slide_size), + content_layer=content_layer, + ) + inline_parent = doc.add_inline_group(parent=item, content_layer=content_layer) + for label, text, format, hyperlink in non_empty: + doc.add_text( + label=label, + parent=inline_parent, + text=text, + prov=self._generate_prov(shape, slide_ind, text, slide_size), + formatting=format, + hyperlink=hyperlink, + content_layer=content_layer, + ) + return item + def _handle_text_elements( - self, shape, parent_slide, slide_ind, doc: DoclingDocument, slide_size + self, + shape, + parent_slide, + slide_ind, + doc: DoclingDocument, + slide_size, + paragraph_runs: list[list[_ParagraphRun]], ): # Lists that are open, outermost first; a deeper paragraph level opens a # list nested under the last item of the enclosing one. @@ -1067,15 +1172,16 @@ def _handle_text_elements( content_layer = ContentLayer.FURNITURE # Iterate through paragraphs to build up text - for paragraph in shape.text_frame.paragraphs: + for paragraph, paragraph_elements in zip( + shape.text_frame.paragraphs, paragraph_runs, strict=True + ): is_a_list, bullet_type = self._is_list_item(paragraph) p = paragraph._element - fragments = _paragraph_fragments(p) - p_text = "".join(text for _, text in fragments) - has_equations = any(label == DocItemLabel.FORMULA for label, _ in fragments) - - prov = self._generate_prov(shape, slide_ind, p_text, slide_size) + has_equations = any( + label == DocItemLabel.FORMULA for label, *_ in paragraph_elements + ) + p_text = "".join(text for _, text, _, _ in paragraph_elements) if is_a_list: enum_marker = "" @@ -1114,28 +1220,17 @@ def _handle_text_elements( current.counter += 1 enum_marker = str(current.counter) + "." - current.last_item = doc.add_list_item( + current.last_item = self._add_list_item_with_formatting( + doc=doc, + elements=paragraph_elements, marker=enum_marker, enumerated=enumerated, parent=current.group, - text="" if has_equations else p_text, - prov=prov, + shape=shape, + slide_ind=slide_ind, + slide_size=slide_size, content_layer=content_layer, ) - if has_equations: - inline_group = doc.add_inline_group( - parent=current.last_item, content_layer=content_layer - ) - for label, text in fragments: - doc.add_text( - label=label, - parent=inline_group, - text=text, - prov=self._generate_prov( - shape, slide_ind, text, slide_size - ), - content_layer=content_layer, - ) else: # is paragraph not a list item open_lists.clear() # Assign proper label to the text, depending if it's a Title or Section Header @@ -1151,14 +1246,20 @@ def _handle_text_elements( doc_label = DocItemLabel.TITLE if has_equations: + # Whitespace-only runs are dropped. has_text = any( label != DocItemLabel.FORMULA and text.strip() - for label, text in fragments + for label, text, _, _ in paragraph_elements ) fragment_parent = parent_slide if has_text and doc_label == DocItemLabel.TITLE: fragment_parent = doc.add_text( - label=doc_label, parent=parent_slide, text="", prov=prov + label=doc_label, + parent=parent_slide, + text="", + prov=self._generate_prov( + shape, slide_ind, p_text, slide_size + ), ) parent = ( doc.add_inline_group( @@ -1167,7 +1268,7 @@ def _handle_text_elements( if has_text else fragment_parent ) - for label, text in fragments: + for label, text, format, hyperlink in paragraph_elements: if text.strip(): doc.add_text( label=label, @@ -1177,15 +1278,42 @@ def _handle_text_elements( shape, slide_ind, text, slide_size ), content_layer=content_layer, + formatting=format, + hyperlink=hyperlink, ) - else: + elif doc_label == DocItemLabel.TITLE: + # Titles stay a single item; run formatting is not applied. doc.add_text( label=doc_label, parent=parent_slide, text=p_text, - prov=prov, + prov=self._generate_prov(shape, slide_ind, p_text, slide_size), content_layer=content_layer, ) + else: + # One item per run, in an inline group when there are several. + non_empty = [ + element for element in paragraph_elements if element[1] + ] + text_parent = ( + doc.add_inline_group( + parent=parent_slide, content_layer=content_layer + ) + if len(non_empty) > 1 + else parent_slide + ) + for _, text, format, hyperlink in non_empty: + doc.add_text( + label=doc_label, + parent=text_parent, + text=text, + prov=self._generate_prov( + shape, slide_ind, text, slide_size + ), + formatting=format, + hyperlink=hyperlink, + content_layer=content_layer, + ) return def _rasterize_metafile(self, image_bytes: bytes) -> Optional[Image.Image]: @@ -1970,16 +2098,19 @@ def handle_shape_content(shape, parent_slide, slide_ind, doc, slide_size): if not shape.has_text_frame: _log.warning("Warning: shape has text but not text_frame") return - if not shape.text.strip() and not any( - _paragraph_fragments(paragraph._element) + paragraph_runs = [ + self._iter_paragraph_runs(paragraph) for paragraph in shape.text_frame.paragraphs + ] + if not shape.text.strip() and not any( + text for runs in paragraph_runs for _, text, _, _ in runs ): return # Handle other text elements, including lists (bullet lists, numbered # lists) first_new_text = len(doc.texts) self._handle_text_elements( - shape, parent_slide, slide_ind, doc, slide_size + shape, parent_slide, slide_ind, doc, slide_size, paragraph_runs ) self._apply_shape_hyperlink(shape, doc.texts[first_new_text:]) return diff --git a/tests/data/pptx/groundtruth/powerpoint_comments.pptx.itxt b/tests/data/pptx/groundtruth/powerpoint_comments.pptx.itxt index 28c7679a92..dc0b785033 100644 --- a/tests/data/pptx/groundtruth/powerpoint_comments.pptx.itxt +++ b/tests/data/pptx/groundtruth/powerpoint_comments.pptx.itxt @@ -4,10 +4,13 @@ item-0 at level 0: unspecified: group _root_ item-3 at level 2: paragraph: Docling converts messy documents ... as, reading order, OCR, and much more. item-4 at level 1: chapter: group slide-1 item-5 at level 2: title: Start - item-6 at level 2: paragraph: Install Docling as a Python library with your favorite package manager - item-7 at level 1: chapter: group slide-2 - item-8 at level 2: title: Features - item-9 at level 2: list: group list - item-10 at level 3: list_item: Import many document formats int ... ages via an OCR engine of your choice. - item-11 at level 3: list_item: Export a parsed document to form ... on into AI, RAG, and agentic systems. - item-12 at level 3: list_item: Extract document components and ... properties from the Docling Document. \ No newline at end of file + item-6 at level 2: inline: group group + item-7 at level 3: paragraph: Install Docling as a + item-8 at level 3: paragraph: Python library + item-9 at level 3: paragraph: with your favorite package manager + item-10 at level 1: chapter: group slide-2 + item-11 at level 2: title: Features + item-12 at level 2: list: group list + item-13 at level 3: list_item: Import many document formats int ... ages via an OCR engine of your choice. + item-14 at level 3: list_item: Export a parsed document to form ... on into AI, RAG, and agentic systems. + item-15 at level 3: list_item: Extract document components and ... properties from the Docling Document. \ No newline at end of file diff --git a/tests/data/pptx/groundtruth/powerpoint_comments.pptx.json b/tests/data/pptx/groundtruth/powerpoint_comments.pptx.json index 57ec9416fa..b633bdb39a 100644 --- a/tests/data/pptx/groundtruth/powerpoint_comments.pptx.json +++ b/tests/data/pptx/groundtruth/powerpoint_comments.pptx.json @@ -30,13 +30,13 @@ "$ref": "#/groups/3" }, { - "$ref": "#/groups/4" + "$ref": "#/groups/5" }, { - "$ref": "#/groups/6" + "$ref": "#/groups/7" }, { - "$ref": "#/groups/7" + "$ref": "#/groups/8" } ], "content_layer": "body", @@ -99,7 +99,7 @@ "$ref": "#/texts/4" }, { - "$ref": "#/texts/5" + "$ref": "#/groups/4" } ], "content_layer": "body", @@ -109,14 +109,34 @@ { "self_ref": "#/groups/4", "parent": { - "$ref": "#/body" + "$ref": "#/groups/3" }, "children": [ + { + "$ref": "#/texts/5" + }, { "$ref": "#/texts/6" }, { - "$ref": "#/groups/5" + "$ref": "#/texts/7" + } + ], + "content_layer": "body", + "name": "group", + "label": "inline" + }, + { + "self_ref": "#/groups/5", + "parent": { + "$ref": "#/body" + }, + "children": [ + { + "$ref": "#/texts/8" + }, + { + "$ref": "#/groups/6" } ], "content_layer": "body", @@ -124,19 +144,19 @@ "label": "chapter" }, { - "self_ref": "#/groups/5", + "self_ref": "#/groups/6", "parent": { - "$ref": "#/groups/4" + "$ref": "#/groups/5" }, "children": [ { - "$ref": "#/texts/7" + "$ref": "#/texts/9" }, { - "$ref": "#/texts/8" + "$ref": "#/texts/10" }, { - "$ref": "#/texts/9" + "$ref": "#/texts/11" } ], "content_layer": "body", @@ -144,13 +164,13 @@ "label": "list" }, { - "self_ref": "#/groups/6", + "self_ref": "#/groups/7", "parent": { "$ref": "#/body" }, "children": [ { - "$ref": "#/texts/10" + "$ref": "#/texts/12" } ], "content_layer": "notes", @@ -158,13 +178,13 @@ "label": "comment_section" }, { - "self_ref": "#/groups/7", + "self_ref": "#/groups/8", "parent": { "$ref": "#/body" }, "children": [ { - "$ref": "#/texts/11" + "$ref": "#/texts/13" } ], "content_layer": "notes", @@ -225,7 +245,14 @@ } ], "orig": "Docling converts messy documents into structured data and simplifies downstream document and AI processing by detecting tables, formulas, reading order, OCR, and much more.", - "text": "Docling converts messy documents into structured data and simplifies downstream document and AI processing by detecting tables, formulas, reading order, OCR, and much more." + "text": "Docling converts messy documents into structured data and simplifies downstream document and AI processing by detecting tables, formulas, reading order, OCR, and much more.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/2", @@ -281,7 +308,7 @@ { "self_ref": "#/texts/5", "parent": { - "$ref": "#/groups/3" + "$ref": "#/groups/4" }, "children": [], "content_layer": "body", @@ -298,12 +325,19 @@ }, "charspan": [ 0, - 70 + 21 ] } ], - "orig": "Install Docling as a Python library with your favorite package manager", - "text": "Install Docling as a Python library with your favorite package manager" + "orig": "Install Docling as a ", + "text": "Install Docling as a ", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/6", @@ -312,6 +346,75 @@ }, "children": [], "content_layer": "body", + "label": "paragraph", + "prov": [ + { + "page_no": 2, + "bbox": { + "l": 457200.0, + "t": 1600200.0, + "r": 8686800.0, + "b": 6126163.0, + "coord_origin": "TOPLEFT" + }, + "charspan": [ + 0, + 14 + ] + } + ], + "orig": "Python library", + "text": "Python library", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, + "hyperlink": "https://pypi.org/project/docling" + }, + { + "self_ref": "#/texts/7", + "parent": { + "$ref": "#/groups/4" + }, + "children": [], + "content_layer": "body", + "label": "paragraph", + "prov": [ + { + "page_no": 2, + "bbox": { + "l": 457200.0, + "t": 1600200.0, + "r": 8686800.0, + "b": 6126163.0, + "coord_origin": "TOPLEFT" + }, + "charspan": [ + 0, + 35 + ] + } + ], + "orig": " with your favorite package manager", + "text": " with your favorite package manager", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } + }, + { + "self_ref": "#/texts/8", + "parent": { + "$ref": "#/groups/5" + }, + "children": [], + "content_layer": "body", "label": "title", "prov": [ { @@ -333,9 +436,9 @@ "text": "Features" }, { - "self_ref": "#/texts/7", + "self_ref": "#/texts/9", "parent": { - "$ref": "#/groups/5" + "$ref": "#/groups/6" }, "children": [], "content_layer": "body", @@ -358,13 +461,20 @@ ], "orig": "Import many document formats into a unified and structured Docling Document , including scanned pages via an OCR engine of your choice.", "text": "Import many document formats into a unified and structured Docling Document , including scanned pages via an OCR engine of your choice.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, { - "self_ref": "#/texts/8", + "self_ref": "#/texts/10", "parent": { - "$ref": "#/groups/5" + "$ref": "#/groups/6" }, "children": [], "content_layer": "body", @@ -387,13 +497,20 @@ ], "orig": "Export a parsed document to formats that simplify processing and ingestion into AI, RAG, and agentic systems. ", "text": "Export a parsed document to formats that simplify processing and ingestion into AI, RAG, and agentic systems. ", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, { - "self_ref": "#/texts/9", + "self_ref": "#/texts/11", "parent": { - "$ref": "#/groups/5" + "$ref": "#/groups/6" }, "children": [], "content_layer": "body", @@ -416,13 +533,20 @@ ], "orig": "Extract document components and their properties from the Docling Document.", "text": "Extract document components and their properties from the Docling Document.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, { - "self_ref": "#/texts/10", + "self_ref": "#/texts/12", "parent": { - "$ref": "#/groups/6" + "$ref": "#/groups/7" }, "children": [], "content_layer": "notes", @@ -432,9 +556,9 @@ "text": "[author: Jane Smith (JS), time: 2026-06-18T17:37:55.253]: This is a cool feature" }, { - "self_ref": "#/texts/11", + "self_ref": "#/texts/13", "parent": { - "$ref": "#/groups/7" + "$ref": "#/groups/8" }, "children": [], "content_layer": "notes", diff --git a/tests/data/pptx/groundtruth/powerpoint_comments.pptx.md b/tests/data/pptx/groundtruth/powerpoint_comments.pptx.md index a8eceb3b5f..90c05d76dd 100644 --- a/tests/data/pptx/groundtruth/powerpoint_comments.pptx.md +++ b/tests/data/pptx/groundtruth/powerpoint_comments.pptx.md @@ -8,7 +8,7 @@ Docling converts messy documents into structured data and simplifies downstream # Start -Install Docling as a Python library with your favorite package manager +Install Docling as a [Python library](https://pypi.org/project/docling) with your favorite package manager # Features diff --git a/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.json b/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.json index 80214ac0d1..7e0d154162 100644 --- a/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.json +++ b/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.json @@ -149,7 +149,14 @@ } ], "orig": "Key Benefits:", - "text": "Key Benefits:" + "text": "Key Benefits:", + "formatting": { + "bold": true, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/2", @@ -177,6 +184,13 @@ ], "orig": "Open-source software is cost-effective as it eliminates license fees, reducing overall expenses.", "text": "Open-source software is cost-effective as it eliminates license fees, reducing overall expenses.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -206,6 +220,13 @@ ], "orig": "Ehnhances security and transparency by allowing public access to source code", "text": "Ehnhances security and transparency by allowing public access to source code", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -235,6 +256,13 @@ ], "orig": "Strong global community improves and maintains open-source projects.", "text": "Strong global community improves and maintains open-source projects.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -264,6 +292,13 @@ ], "orig": "Offers flexibility, allowing users to customize and adapt the software", "text": "Offers flexibility, allowing users to customize and adapt the software", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -321,7 +356,14 @@ } ], "orig": "Considerations When Using Open-Source Software:", - "text": "Considerations When Using Open-Source Software:" + "text": "Considerations When Using Open-Source Software:", + "formatting": { + "bold": true, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/8", @@ -349,6 +391,13 @@ ], "orig": "Open-source projects often rely on community support, which may not be as reliable as dedicated customer service.", "text": "Open-source projects often rely on community support, which may not be as reliable as dedicated customer service.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -378,6 +427,13 @@ ], "orig": "Some open-source solutions may require advanced technical expertise for implementation and maintenance.", "text": "Some open-source solutions may require advanced technical expertise for implementation and maintenance.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" } diff --git a/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.md b/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.md index ad883ff00e..4a50ddfcc9 100644 --- a/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.md +++ b/tests/data/pptx/groundtruth/powerpoint_issue_2663.pptx.md @@ -1,6 +1,6 @@ # Open-Source Software -Key Benefits: +**Key Benefits:** - Open-source software is cost-effective as it eliminates license fees, reducing overall expenses. - Ehnhances security and transparency by allowing public access to source code @@ -8,7 +8,7 @@ Key Benefits: - Offers flexibility, allowing users to customize and adapt the software - -Considerations When Using Open-Source Software: +**Considerations When Using Open-Source Software:** - Open-source projects often rely on community support, which may not be as reliable as dedicated customer service. - Some open-source solutions may require advanced technical expertise for implementation and maintenance. \ No newline at end of file diff --git a/tests/data/pptx/groundtruth/powerpoint_malformed_pictures.pptx.json b/tests/data/pptx/groundtruth/powerpoint_malformed_pictures.pptx.json index 1768b01000..bcd1578e71 100644 --- a/tests/data/pptx/groundtruth/powerpoint_malformed_pictures.pptx.json +++ b/tests/data/pptx/groundtruth/powerpoint_malformed_pictures.pptx.json @@ -101,7 +101,14 @@ } ], "orig": "Slide With Missing BlipFill", - "text": "Slide With Missing BlipFill" + "text": "Slide With Missing BlipFill", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/1", @@ -128,7 +135,14 @@ } ], "orig": "Slide With Dangling Rel", - "text": "Slide With Dangling Rel" + "text": "Slide With Dangling Rel", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/2", @@ -155,7 +169,14 @@ } ], "orig": "Slide With Wrong Content Type", - "text": "Slide With Wrong Content Type" + "text": "Slide With Wrong Content Type", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } } ], "pictures": [], diff --git a/tests/data/pptx/groundtruth/powerpoint_sample.pptx.json b/tests/data/pptx/groundtruth/powerpoint_sample.pptx.json index c96319ee0c..adda7e5658 100644 --- a/tests/data/pptx/groundtruth/powerpoint_sample.pptx.json +++ b/tests/data/pptx/groundtruth/powerpoint_sample.pptx.json @@ -287,7 +287,14 @@ } ], "orig": "With footnote", - "text": "With footnote" + "text": "With footnote", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/2", @@ -341,7 +348,14 @@ } ], "orig": "A rectangle shape with this text inside.", - "text": "A rectangle shape with this text inside." + "text": "A rectangle shape with this text inside.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/4", @@ -368,7 +382,14 @@ } ], "orig": "Let’s introduce a list", - "text": "Let’s introduce a list" + "text": "Let’s introduce a list", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/5", @@ -396,6 +417,13 @@ ], "orig": "With foo", "text": "With foo", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -425,6 +453,13 @@ ], "orig": "Bar", "text": "Bar", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -454,6 +489,13 @@ ], "orig": "And baz things", "text": "And baz things", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -510,6 +552,13 @@ ], "orig": "List item4", "text": "List item4", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": true, "marker": "1." }, @@ -539,6 +588,13 @@ ], "orig": "List item5", "text": "List item5", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": true, "marker": "2." }, @@ -568,6 +624,13 @@ ], "orig": "List item6", "text": "List item6", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": true, "marker": "3." }, @@ -597,6 +660,13 @@ ], "orig": "I1", "text": "I1", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -626,6 +696,13 @@ ], "orig": "I2", "text": "I2", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -655,6 +732,13 @@ ], "orig": "I3", "text": "I3", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -684,6 +768,13 @@ ], "orig": "I4", "text": "I4", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -712,7 +803,14 @@ } ], "orig": "Some info:", - "text": "Some info:" + "text": "Some info:", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/17", @@ -740,6 +838,13 @@ ], "orig": "Item A", "text": "Item A", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -769,6 +874,13 @@ ], "orig": "Item B", "text": "Item B", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -797,7 +909,14 @@ } ], "orig": "Maybe a list?", - "text": "Maybe a list?" + "text": "Maybe a list?", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/20", @@ -825,6 +944,13 @@ ], "orig": "List1", "text": "List1", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": true, "marker": "1." }, @@ -854,6 +980,13 @@ ], "orig": "List2", "text": "List2", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": true, "marker": "2." }, @@ -883,6 +1016,13 @@ ], "orig": "List3", "text": "List3", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": true, "marker": "3." }, @@ -912,6 +1052,13 @@ ], "orig": "l1 ", "text": "l1 ", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -941,6 +1088,13 @@ ], "orig": "l2", "text": "l2", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -970,6 +1124,13 @@ ], "orig": "l3", "text": "l3", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, diff --git a/tests/data/pptx/groundtruth/powerpoint_unrecognized_shape.pptx.json b/tests/data/pptx/groundtruth/powerpoint_unrecognized_shape.pptx.json index 57ab17c20d..91176788b5 100644 --- a/tests/data/pptx/groundtruth/powerpoint_unrecognized_shape.pptx.json +++ b/tests/data/pptx/groundtruth/powerpoint_unrecognized_shape.pptx.json @@ -184,6 +184,13 @@ ], "orig": "Total revenue grew 18% year-over-year.", "text": "Total revenue grew 18% year-over-year.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -213,6 +220,13 @@ ], "orig": "Enterprise segment led growth at 24%.", "text": "Enterprise segment led growth at 24%.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -242,6 +256,13 @@ ], "orig": "APAC region exceeded targets by 12%.", "text": "APAC region exceeded targets by 12%.", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -270,7 +291,14 @@ } ], "orig": "Key Metrics", - "text": "Key Metrics" + "text": "Key Metrics", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/5", @@ -297,7 +325,14 @@ } ], "orig": "Monthly active users: 4.2M", - "text": "Monthly active users: 4.2M" + "text": "Monthly active users: 4.2M", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/6", @@ -324,7 +359,14 @@ } ], "orig": "Net retention rate: 112%", - "text": "Net retention rate: 112%" + "text": "Net retention rate: 112%", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/7", @@ -351,7 +393,14 @@ } ], "orig": "Text in an unrecognized shape", - "text": "Text in an unrecognized shape" + "text": "Text in an unrecognized shape", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/8", @@ -406,6 +455,13 @@ ], "orig": "1. Finalize pricing model", "text": "1. Finalize pricing model", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -435,6 +491,13 @@ ], "orig": "2. Launch APAC campaign", "text": "2. Launch APAC campaign", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" }, @@ -464,6 +527,13 @@ ], "orig": "3. Hire 3 additional SEs", "text": "3. Hire 3 additional SEs", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + }, "enumerated": false, "marker": "" } diff --git a/tests/data/pptx/groundtruth/powerpoint_with_image.pptx.json b/tests/data/pptx/groundtruth/powerpoint_with_image.pptx.json index 5e12d432d2..d703b21f2c 100644 --- a/tests/data/pptx/groundtruth/powerpoint_with_image.pptx.json +++ b/tests/data/pptx/groundtruth/powerpoint_with_image.pptx.json @@ -126,7 +126,14 @@ } ], "orig": "Image test", - "text": "Image test" + "text": "Image test", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/2", @@ -153,7 +160,14 @@ } ], "orig": "Image linked to file", - "text": "Image linked to file" + "text": "Image linked to file", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } }, { "self_ref": "#/texts/3", @@ -207,7 +221,14 @@ } ], "orig": "This is a picture inserted through a layout’s picture placeholder", - "text": "This is a picture inserted through a layout’s picture placeholder" + "text": "This is a picture inserted through a layout’s picture placeholder", + "formatting": { + "bold": false, + "italic": false, + "underline": false, + "strikethrough": false, + "script": "baseline" + } } ], "pictures": [ diff --git a/tests/test_backend_pptx_runs.py b/tests/test_backend_pptx_runs.py new file mode 100644 index 0000000000..1cd89ee78d --- /dev/null +++ b/tests/test_backend_pptx_runs.py @@ -0,0 +1,125 @@ +# SPDX-FileCopyrightText: The Docling Contributors +# SPDX-License-Identifier: MIT + +from pathlib import Path + +import pytest +from pptx import Presentation +from pptx.oxml.ns import qn +from pptx.util import Inches + +from docling.backend.mspowerpoint_backend import MsPowerpointDocumentBackend +from docling.datamodel.base_models import InputFormat +from docling.document_converter import DocumentConverter + + +def get_converter() -> DocumentConverter: + return DocumentConverter(allowed_formats=[InputFormat.PPTX]) + + +def test_pptx_preserves_run_level_formatting_and_hyperlinks(tmp_path: Path): + """Each run in a paragraph keeps its own formatting and hyperlink.""" + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + box = slide.shapes.add_textbox(Inches(0), Inches(0), Inches(4), Inches(1)) + p = box.text_frame.paragraphs[0] + + bold_run = p.add_run() + bold_run.text = "Bold" + bold_run.font.bold = True + + plain_run = p.add_run() + plain_run.text = " plain " + + link_run = p.add_run() + link_run.text = "link" + link_run.hyperlink.address = "https://example.com/docs" + + pptx_path = tmp_path / "mixed_runs.pptx" + prs.save(pptx_path) + + doc = get_converter().convert(pptx_path).document + + texts = [t for t in doc.texts if t.text] + assert [t.text for t in texts] == ["Bold", " plain ", "link"] + + assert texts[0].formatting is not None + assert texts[0].formatting.bold is True + assert texts[0].hyperlink is None + + assert texts[1].formatting is not None + assert texts[1].formatting.bold is False + assert texts[1].hyperlink is None + + assert texts[2].formatting is not None + assert texts[2].formatting.bold is False + assert texts[2].hyperlink is not None + assert str(texts[2].hyperlink) == "https://example.com/docs" + + +@pytest.mark.parametrize("malformation", ["dangling", "empty_rid", "no_rid"]) +def test_pptx_malformed_run_hyperlink_is_dropped(tmp_path: Path, malformation: str): + """A run whose hyperlink relationship cannot be resolved keeps its text.""" + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + box = slide.shapes.add_textbox(Inches(0), Inches(0), Inches(4), Inches(1)) + p = box.text_frame.paragraphs[0] + + broken_run = p.add_run() + broken_run.text = "broken" + broken_run.font.bold = True + broken_run.hyperlink.address = "https://example.com/broken" + + p.add_run().text = " and " + + valid_run = p.add_run() + valid_run.text = "valid" + valid_run.hyperlink.address = "https://example.com/valid" + + hlink_click = broken_run._r.rPr.hlinkClick + if malformation == "dangling": + slide.part.rels.pop(hlink_click.rId) + elif malformation == "empty_rid": + hlink_click.set(qn("r:id"), "") + else: + del hlink_click.attrib[qn("r:id")] + + pptx_path = tmp_path / "malformed_hyperlink.pptx" + prs.save(pptx_path) + + doc = get_converter().convert(pptx_path).document + + texts = [t for t in doc.texts if t.text] + assert [t.text for t in texts] == ["broken", " and ", "valid"] + assert texts[0].formatting is not None + assert texts[0].formatting.bold is True + assert texts[0].hyperlink is None + assert texts[1].hyperlink is None + assert str(texts[2].hyperlink) == "https://example.com/valid" + + +def test_pptx_paragraph_runs_are_read_once(tmp_path: Path, monkeypatch): + """Each paragraph is split into runs once, including in whitespace-only shapes.""" + + prs = Presentation() + slide = prs.slides.add_slide(prs.slide_layouts[6]) + for text in ["first", " "]: + box = slide.shapes.add_textbox(Inches(0), Inches(0), Inches(4), Inches(1)) + box.text_frame.text = text + pptx_path = tmp_path / "paragraph_runs.pptx" + prs.save(pptx_path) + + calls: list[str] = [] + iter_runs = MsPowerpointDocumentBackend._iter_paragraph_runs + + def spy(self, paragraph): + calls.append(paragraph.text) + return iter_runs(self, paragraph) + + monkeypatch.setattr(MsPowerpointDocumentBackend, "_iter_paragraph_runs", spy) + + get_converter().convert(pptx_path) + + assert sorted(calls) == [" ", "first"]