From 484b5f7fa5bebc96caaa24ea3363302b2f537f69 Mon Sep 17 00:00:00 2001 From: Avanish Garg Date: Tue, 29 Sep 2026 16:28:12 +0530 Subject: [PATCH 1/4] fix(pptx): preserve run-level hyperlinks and character formatting The PPTX text-extraction loop built each paragraph's text via plain string concatenation (p_text += e.text) and handed the whole thing to a single add_text/add_list_item call, discarding every run's own hyperlink and bold/italic/underline formatting - the same information DOCX/HTML/ODF/MD backends already preserve per run. Splits each paragraph into (text, formatting, hyperlink) tuples per run, mirroring msword_backend.py's existing _iter_paragraph_content/ _get_format_from_run/_get_hyperlink_target pattern: - Adjacent runs with identical formatting/hyperlink are coalesced back into one unit first, since PowerPoint sometimes splits a single word across runs with no actual formatting difference (e.g. spell-check markup) - without this, wrapping each such run in its own item would let the markdown serializer's inter-sibling space split real words. - A paragraph with more than one run is wrapped in an inline group (mirroring the DOCX backend's own _create_or_reuse_parent), so a sentence with a bold word or a link in the middle stays one flowing unit on export instead of splintering into separate blocks. - Title/section-header paragraphs are kept as a single item as before: a heading is one outline-level unit, not a sequence of independently formatted spans. - Each item's own provenance is now built from its own text rather than the whole paragraph's, so charspan stays correct once a paragraph can produce more than one item (python-pptx exposes no finer bbox than the shape's, so the bbox itself stays shape-level). New test: test_pptx_preserves_run_level_formatting_and_hyperlinks, covering a bold run, a plain run, and a hyperlinked run in one paragraph. Golden fixtures regenerated; diffs are exactly the fixed behavior (a real markdown hyperlink where the URL was silently dropped, formatting: {...} now present throughout) with no unrelated changes. Native Office Math equations (#4625) keep their own FORMULA items; the text runs around them now carry their run-level formatting and hyperlinks too. Signed-off-by: Avanish Garg --- docling/backend/mspowerpoint_backend.py | 272 ++++++++++++++---- .../groundtruth/powerpoint_comments.pptx.itxt | 17 +- .../groundtruth/powerpoint_comments.pptx.json | 184 ++++++++++-- .../groundtruth/powerpoint_comments.pptx.md | 2 +- .../powerpoint_issue_2663.pptx.json | 60 +++- .../groundtruth/powerpoint_issue_2663.pptx.md | 4 +- .../powerpoint_malformed_pictures.pptx.json | 27 +- .../groundtruth/powerpoint_sample.pptx.json | 171 ++++++++++- .../powerpoint_unrecognized_shape.pptx.json | 78 ++++- .../powerpoint_with_image.pptx.json | 27 +- tests/test_backend_pptx.py | 49 ++++ 11 files changed, 784 insertions(+), 107 deletions(-) diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index e641f138d6..3c4082187c 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 _Run _PPTX_AVAILABLE = True except ImportError as e: # pragma: no cover - import-time guard @@ -186,27 +187,12 @@ 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 +# One unit of paragraph content: ``(label, text, formatting, hyperlink)``. Text +# runs carry their own formatting/hyperlink; equations are ``FORMULA`` entries +# holding LaTeX, with neither. +_ParagraphRun = tuple[ + DocItemLabel, str, Optional[Formatting], Optional[Union[AnyUrl, Path]] +] _SAFE_XML_PARSER: Final = etree.XMLParser( @@ -1048,6 +1034,153 @@ 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`` from a run's font, mirroring the DOCX backend. + + Only bold/italic/underline are read: ``python-pptx``'s public ``Font`` + API exposes nothing for strikethrough or sub/superscript, unlike + python-docx's richer run API. + """ + 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 address to a URL or a local path. + + Mirrors the DOCX backend's ``_get_hyperlink_target``: addresses + without a URL scheme are treated as (relative) filesystem paths, and + a malformed address is dropped (returning ``None``) rather than + aborting the whole conversion. + """ + address = run.hyperlink.address + 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) -> list[_ParagraphRun]: + """Split a paragraph into ``(label, text, formatting, hyperlink)`` units. + + Text runs carry their own formatting and hyperlink; native Office Math + equations are kept as separate ``FORMULA`` units (LaTeX, no formatting). + A line break (``a:br``) is a sibling of ``a:r`` at the paragraph + level in DrawingML (unlike python-docx, where an embedded break is + already folded into ``Run.text``), so it is kept as its own + formatting-less, hyperlink-less space entry. + """ + 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), + ) + ) + + # PowerPoint sometimes splits a single word across adjacent runs with + # no formatting difference at all (e.g. spell-check markup); joining + # each run into its own item would then split that word with a + # spurious space wherever they land in an inline group (the markdown + # serializer always inserts one between siblings). Coalescing + # adjacent text runs that share the exact same formatting/hyperlink + # keeps those words intact while still separating any run that is + # actually different. Equations are never merged into text. + 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, preserving per-run formatting/hyperlinks. + + A single (or no) text run keeps the one-``ListItem``-per-paragraph + shape. Multiple runs, or any equation, need an empty marker item plus + an inline group of per-run items, mirroring the DOCX backend's + ``_add_formatted_list_item`` — a bare ``ListItem`` has no per-span + formatting of its own, and cannot hold an equation. Each item gets its + own provenance, built from its own text, so its charspan matches its + own length rather than the whole paragraph's (python-pptx exposes no + finer bbox than the shape's, so the bbox itself stays shape-level for + every item). + """ + 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 ): @@ -1071,11 +1204,16 @@ def _handle_text_elements( 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) + # Split into (label, text, formatting, hyperlink) per run so a bold + # word or a link inside an otherwise plain sentence isn't lost, and + # native equations stay separate FORMULA units. Each resulting item + # gets its own provenance (below), built from its own text, not one + # shared paragraph-wide span. + paragraph_elements = self._iter_paragraph_runs(paragraph) + 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 +1252,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 +1278,22 @@ def _handle_text_elements( doc_label = DocItemLabel.TITLE if has_equations: + # Equations stay separate FORMULA items next to their + # surrounding text runs, which keep their own + # formatting/hyperlink; 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 +1302,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 +1312,51 @@ def _handle_text_elements( shape, slide_ind, text, slide_size ), content_layer=content_layer, + formatting=format, + hyperlink=hyperlink, ) - else: + elif doc_label == DocItemLabel.TITLE: + # A title/section-header is one heading-level unit, not a + # sequence of independently formatted spans: keep it a + # single item exactly as before, run-formatting is not + # meaningful at this level. 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: + # output accumulated inline text, one item per run so + # each run's own formatting/hyperlink survives. Multiple + # runs are nested under an inline group (mirroring the + # DOCX backend's own _create_or_reuse_parent) so a + # sentence with a bold word or a link in the middle stays + # one flowing paragraph on export instead of splintering + # into separate blocks. + 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]: @@ -1971,8 +2142,9 @@ def handle_shape_content(shape, parent_slide, slide_ind, doc, slide_size): _log.warning("Warning: shape has text but not text_frame") return if not shape.text.strip() and not any( - _paragraph_fragments(paragraph._element) + text for paragraph in shape.text_frame.paragraphs + for _, text, _, _ in self._iter_paragraph_runs(paragraph) ): return # Handle other text elements, including lists (bullet lists, numbered 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.py b/tests/test_backend_pptx.py index e764035487..6306c4adde 100644 --- a/tests/test_backend_pptx.py +++ b/tests/test_backend_pptx.py @@ -319,6 +319,55 @@ def test_pptx_left_flush_shape_keeps_own_bbox(tmp_path: Path): assert bbox.r != prs.slide_width +def test_pptx_preserves_run_level_formatting_and_hyperlinks(tmp_path: Path): + """A paragraph mixing a bold run, a plain run, and a hyperlinked run + must keep each run's own formatting/hyperlink. + + The text-extraction loop used to flatten every run in a paragraph into + one plain string, silently dropping both bold/italic/underline and any + hyperlink target. + """ + from pptx import Presentation + from pptx.util import Inches + + 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" + + def test_pptx_page_range(): converter = get_converter() pptx_path = Path("./tests/data/pptx/sources/powerpoint_sample.pptx") From 59e1cedb1f292e8513f7de2e0cc6c331cdb63773 Mon Sep 17 00:00:00 2001 From: Avanish Garg Date: Wed, 7 Oct 2026 19:41:20 +0530 Subject: [PATCH 2/4] refactor(pptx): trim comments and docstrings in the formatting code Signed-off-by: Avanish Garg --- docling/backend/mspowerpoint_backend.py | 75 ++++--------------------- tests/test_backend_pptx.py | 8 +-- 2 files changed, 12 insertions(+), 71 deletions(-) diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index 3c4082187c..1766bf164f 100644 --- a/docling/backend/mspowerpoint_backend.py +++ b/docling/backend/mspowerpoint_backend.py @@ -187,9 +187,8 @@ def _iter_paragraph_elements(element: etree._Element) -> Iterator[etree._Element yield child -# One unit of paragraph content: ``(label, text, formatting, hyperlink)``. Text -# runs carry their own formatting/hyperlink; equations are ``FORMULA`` entries -# holding LaTeX, with neither. +# ``(label, text, formatting, hyperlink)``; equations are FORMULA entries with +# neither formatting nor hyperlink. _ParagraphRun = tuple[ DocItemLabel, str, Optional[Formatting], Optional[Union[AnyUrl, Path]] ] @@ -1035,12 +1034,7 @@ class _ShapeInfo(NamedTuple): yield si.shape def _get_run_format(self, run: _Run) -> Formatting: - """Build a ``Formatting`` from a run's font, mirroring the DOCX backend. - - Only bold/italic/underline are read: ``python-pptx``'s public ``Font`` - API exposes nothing for strikethrough or sub/superscript, unlike - python-docx's richer run API. - """ + """Build a ``Formatting`` (bold, italic, underline) from a run's font.""" font = run.font return Formatting( bold=bool(font.bold), @@ -1049,13 +1043,7 @@ def _get_run_format(self, run: _Run) -> Formatting: ) def _get_run_hyperlink_target(self, run: _Run) -> Optional[AnyUrl | Path]: - """Resolve a run's hyperlink address to a URL or a local path. - - Mirrors the DOCX backend's ``_get_hyperlink_target``: addresses - without a URL scheme are treated as (relative) filesystem paths, and - a malformed address is dropped (returning ``None``) rather than - aborting the whole conversion. - """ + """Resolve a run's hyperlink to a URL or local path, or ``None``.""" address = run.hyperlink.address if not address: return None @@ -1068,15 +1056,7 @@ def _get_run_hyperlink_target(self, run: _Run) -> Optional[AnyUrl | Path]: return None def _iter_paragraph_runs(self, paragraph) -> list[_ParagraphRun]: - """Split a paragraph into ``(label, text, formatting, hyperlink)`` units. - - Text runs carry their own formatting and hyperlink; native Office Math - equations are kept as separate ``FORMULA`` units (LaTeX, no formatting). - A line break (``a:br``) is a sibling of ``a:r`` at the paragraph - level in DrawingML (unlike python-docx, where an embedded break is - already folded into ``Run.text``), so it is kept as its own - formatting-less, hyperlink-less space entry. - """ + """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": @@ -1096,14 +1076,8 @@ def _iter_paragraph_runs(self, paragraph) -> list[_ParagraphRun]: ) ) - # PowerPoint sometimes splits a single word across adjacent runs with - # no formatting difference at all (e.g. spell-check markup); joining - # each run into its own item would then split that word with a - # spurious space wherever they land in an inline group (the markdown - # serializer always inserts one between siblings). Coalescing - # adjacent text runs that share the exact same formatting/hyperlink - # keeps those words intact while still separating any run that is - # actually different. Equations are never merged into text. + # 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 ( @@ -1131,18 +1105,7 @@ def _add_list_item_with_formatting( slide_size, content_layer: Optional[ContentLayer] = None, ) -> ListItem: - """Add a list item, preserving per-run formatting/hyperlinks. - - A single (or no) text run keeps the one-``ListItem``-per-paragraph - shape. Multiple runs, or any equation, need an empty marker item plus - an inline group of per-run items, mirroring the DOCX backend's - ``_add_formatted_list_item`` — a bare ``ListItem`` has no per-span - formatting of its own, and cannot hold an equation. Each item gets its - own provenance, built from its own text, so its charspan matches its - own length rather than the whole paragraph's (python-pptx exposes no - finer bbox than the shape's, so the bbox itself stays shape-level for - every item). - """ + """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: @@ -1204,11 +1167,6 @@ def _handle_text_elements( is_a_list, bullet_type = self._is_list_item(paragraph) p = paragraph._element - # Split into (label, text, formatting, hyperlink) per run so a bold - # word or a link inside an otherwise plain sentence isn't lost, and - # native equations stay separate FORMULA units. Each resulting item - # gets its own provenance (below), built from its own text, not one - # shared paragraph-wide span. paragraph_elements = self._iter_paragraph_runs(paragraph) has_equations = any( label == DocItemLabel.FORMULA for label, *_ in paragraph_elements @@ -1278,9 +1236,7 @@ def _handle_text_elements( doc_label = DocItemLabel.TITLE if has_equations: - # Equations stay separate FORMULA items next to their - # surrounding text runs, which keep their own - # formatting/hyperlink; whitespace-only runs are dropped. + # Whitespace-only runs are dropped. has_text = any( label != DocItemLabel.FORMULA and text.strip() for label, text, _, _ in paragraph_elements @@ -1316,10 +1272,7 @@ def _handle_text_elements( hyperlink=hyperlink, ) elif doc_label == DocItemLabel.TITLE: - # A title/section-header is one heading-level unit, not a - # sequence of independently formatted spans: keep it a - # single item exactly as before, run-formatting is not - # meaningful at this level. + # Titles stay a single item; run formatting is not applied. doc.add_text( label=doc_label, parent=parent_slide, @@ -1328,13 +1281,7 @@ def _handle_text_elements( content_layer=content_layer, ) else: - # output accumulated inline text, one item per run so - # each run's own formatting/hyperlink survives. Multiple - # runs are nested under an inline group (mirroring the - # DOCX backend's own _create_or_reuse_parent) so a - # sentence with a bold word or a link in the middle stays - # one flowing paragraph on export instead of splintering - # into separate blocks. + # One item per run, in an inline group when there are several. non_empty = [ element for element in paragraph_elements if element[1] ] diff --git a/tests/test_backend_pptx.py b/tests/test_backend_pptx.py index 6306c4adde..5b773550c8 100644 --- a/tests/test_backend_pptx.py +++ b/tests/test_backend_pptx.py @@ -320,13 +320,7 @@ def test_pptx_left_flush_shape_keeps_own_bbox(tmp_path: Path): def test_pptx_preserves_run_level_formatting_and_hyperlinks(tmp_path: Path): - """A paragraph mixing a bold run, a plain run, and a hyperlinked run - must keep each run's own formatting/hyperlink. - - The text-extraction loop used to flatten every run in a paragraph into - one plain string, silently dropping both bold/italic/underline and any - hyperlink target. - """ + """Each run in a paragraph keeps its own formatting and hyperlink.""" from pptx import Presentation from pptx.util import Inches From ed1a75b531b87f06323915824d41ab541a0f27a3 Mon Sep 17 00:00:00 2001 From: Avanish Garg Date: Wed, 14 Oct 2026 18:26:17 +0530 Subject: [PATCH 3/4] fix(pptx): skip run hyperlinks whose relationship cannot be resolved A run hyperlink pointing at a missing, empty or absent relationship id made python-pptx raise KeyError and failed the whole conversion. Treat such a link as absent and keep the run text. Split each paragraph into runs once per shape instead of once for the empty-shape check and again when building items, and annotate the paragraph parameter. Signed-off-by: Avanish Garg --- docling/backend/mspowerpoint_backend.py | 32 +++++++---- tests/test_backend_pptx.py | 72 +++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 10 deletions(-) diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index 1766bf164f..9892444874 100644 --- a/docling/backend/mspowerpoint_backend.py +++ b/docling/backend/mspowerpoint_backend.py @@ -85,7 +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 _Run + from pptx.text.text import _Paragraph, _Run _PPTX_AVAILABLE = True except ImportError as e: # pragma: no cover - import-time guard @@ -1044,7 +1044,10 @@ def _get_run_format(self, run: _Run) -> Formatting: def _get_run_hyperlink_target(self, run: _Run) -> Optional[AnyUrl | Path]: """Resolve a run's hyperlink to a URL or local path, or ``None``.""" - address = run.hyperlink.address + try: + address = run.hyperlink.address + except KeyError: + return None if not address: return None if not urlparse(address).scheme: @@ -1055,7 +1058,7 @@ def _get_run_hyperlink_target(self, run: _Run) -> Optional[AnyUrl | Path]: _log.warning("Skipping malformed hyperlink address: %r", address) return None - def _iter_paragraph_runs(self, paragraph) -> list[_ParagraphRun]: + 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): @@ -1145,7 +1148,13 @@ def _add_list_item_with_formatting( 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. @@ -1163,11 +1172,12 @@ 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 - paragraph_elements = self._iter_paragraph_runs(paragraph) has_equations = any( label == DocItemLabel.FORMULA for label, *_ in paragraph_elements ) @@ -2088,17 +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( - text + paragraph_runs = [ + self._iter_paragraph_runs(paragraph) for paragraph in shape.text_frame.paragraphs - for _, text, _, _ in self._iter_paragraph_runs(paragraph) + ] + 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/test_backend_pptx.py b/tests/test_backend_pptx.py index 5b773550c8..ecb786b269 100644 --- a/tests/test_backend_pptx.py +++ b/tests/test_backend_pptx.py @@ -362,6 +362,78 @@ def test_pptx_preserves_run_level_formatting_and_hyperlinks(tmp_path: Path): 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.""" + from pptx import Presentation + from pptx.oxml.ns import qn + from pptx.util import Inches + + 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.""" + from pptx import Presentation + from pptx.util import Inches + + 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"] + + def test_pptx_page_range(): converter = get_converter() pptx_path = Path("./tests/data/pptx/sources/powerpoint_sample.pptx") From 450cdaccee8290586f303bd00ce7fb0cc7b35f01 Mon Sep 17 00:00:00 2001 From: Avanish Garg Date: Thu, 15 Oct 2026 14:30:29 +0530 Subject: [PATCH 4/4] test(pptx): move run-level hyperlink tests to their own module tests/test_backend_pptx.py went over the 1500-line limit enforced by the max-lines hook. Signed-off-by: Avanish Garg --- tests/test_backend_pptx.py | 115 ----------------------------- tests/test_backend_pptx_runs.py | 125 ++++++++++++++++++++++++++++++++ 2 files changed, 125 insertions(+), 115 deletions(-) create mode 100644 tests/test_backend_pptx_runs.py diff --git a/tests/test_backend_pptx.py b/tests/test_backend_pptx.py index ecb786b269..e764035487 100644 --- a/tests/test_backend_pptx.py +++ b/tests/test_backend_pptx.py @@ -319,121 +319,6 @@ def test_pptx_left_flush_shape_keeps_own_bbox(tmp_path: Path): assert bbox.r != prs.slide_width -def test_pptx_preserves_run_level_formatting_and_hyperlinks(tmp_path: Path): - """Each run in a paragraph keeps its own formatting and hyperlink.""" - from pptx import Presentation - from pptx.util import Inches - - 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.""" - from pptx import Presentation - from pptx.oxml.ns import qn - from pptx.util import Inches - - 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.""" - from pptx import Presentation - from pptx.util import Inches - - 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"] - - def test_pptx_page_range(): converter = get_converter() pptx_path = Path("./tests/data/pptx/sources/powerpoint_sample.pptx") 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"]