Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
239 changes: 185 additions & 54 deletions docling/backend/mspowerpoint_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Comment on lines +190 to +191

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What are these inline comments referring to? Or they should just be removed?

_ParagraphRun = tuple[
DocItemLabel, str, Optional[Formatting], Optional[Union[AnyUrl, Path]]
]


_SAFE_XML_PARSER: Final = etree.XMLParser(
Expand Down Expand Up @@ -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.
Expand All @@ -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 = ""
Expand Down Expand Up @@ -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
Expand All @@ -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(
Expand All @@ -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,
Expand All @@ -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]:
Expand Down Expand Up @@ -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
Expand Down
17 changes: 10 additions & 7 deletions tests/data/pptx/groundtruth/powerpoint_comments.pptx.itxt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
Loading
Loading