Skip to content
Closed
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
60 changes: 43 additions & 17 deletions docling/backend/mspowerpoint_backend.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,21 @@
from tempfile import mkdtemp
from typing import Any, Callable, Final, Iterable, Iterator, Literal, Optional, Union

from docling.backend.abstract_backend import (
DeclarativeDocumentBackend,
PaginatedDocumentBackend,
)
from docling.backend.docx.drawingml.utils import (
convert_to_modern_format,
crop_whitespace,
get_docx_to_pdf_converter,
)
from docling.backend.docx.latex.omml import oMath2Latex
from docling.backend.utils.image import normalize_image_for_png
from docling.datamodel.backend_options import MsPowerpointBackendOptions
from docling.datamodel.base_models import FormatToMimeType, InputFormat
from docling.datamodel.document import InputDocument
from docling.exceptions import DocumentLoadError
from docling_core.types.doc import (
BoundingBox,
CoordOrigin,
Expand All @@ -38,22 +53,6 @@
from PIL import Image, UnidentifiedImageError
from typing_extensions import override

from docling.backend.abstract_backend import (
DeclarativeDocumentBackend,
PaginatedDocumentBackend,
)
from docling.backend.docx.drawingml.utils import (
convert_to_modern_format,
crop_whitespace,
get_docx_to_pdf_converter,
)
from docling.backend.docx.latex.omml import oMath2Latex
from docling.backend.utils.image import normalize_image_for_png
from docling.datamodel.backend_options import MsPowerpointBackendOptions
from docling.datamodel.base_models import FormatToMimeType, InputFormat
from docling.datamodel.document import InputDocument
from docling.exceptions import DocumentLoadError

_log = logging.getLogger(__name__)

_PPTX_AVAILABLE: bool = False
Expand Down Expand Up @@ -1687,7 +1686,25 @@ def handle_groups(shape, parent_slide, slide_ind, doc, slide_size):

# Loop through each shape in the slide
for shape in self._iter_shapes_by_position(slide.shapes):
handle_shapes(shape, parent_slide, slide_ind, doc, slide_size)
if self._is_hidden_shape(shape):
# Like a hidden slide, keep the content but move it to
# the invisible layer instead of the body layer.
seen = {
id(item)
for item, _ in doc.iterate_items(
root=parent_slide, with_groups=True
)
}
handle_shapes(shape, parent_slide, slide_ind, doc, slide_size)
for item, _ in doc.iterate_items(
root=parent_slide,
with_groups=True,
included_content_layers={ContentLayer.BODY},
):
if id(item) not in seen:
item.content_layer = ContentLayer.INVISIBLE
else:
handle_shapes(shape, parent_slide, slide_ind, doc, slide_size)

# Handle notes slide
if slide.has_notes_slide:
Expand Down Expand Up @@ -1719,6 +1736,15 @@ def handle_groups(shape, parent_slide, slide_ind, doc, slide_size):

return doc

def _is_hidden_shape(self, shape) -> bool:
"""Whether a shape is hidden in PowerPoint (Selection Pane > Hide).

A shape whose ``p:cNvPr`` carries ``hidden="1"`` is never presented,
so its content belongs on the invisible layer, like a hidden slide.
"""
cNvPr = shape._element.find(".//p:cNvPr", namespaces=self.NAMESPACES)
return cNvPr is not None and cNvPr.get("hidden") == "1"

@staticmethod
def _hide_slide(doc: DoclingDocument, parent_slide: GroupItem) -> None:
"""Move the content of a hidden slide to the invisible layer.
Expand Down
52 changes: 44 additions & 8 deletions tests/test_backend_pptx.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@
from types import SimpleNamespace

import pytest
from docling.backend.docx.drawingml.utils import get_libreoffice_cmd
from docling.datamodel.backend_options import MsPowerpointBackendOptions
from docling.datamodel.base_models import InputFormat, ItemAndImageEnrichmentElement
from docling.datamodel.document import ConversionResult, DoclingDocument, InputDocument
from docling.datamodel.pipeline_options import ConvertPipelineOptions
from docling.document_converter import DocumentConverter, PowerpointFormatOption
from docling.models.base_model import BaseItemAndImageEnrichmentModel
from docling.pipeline.simple_pipeline import SimplePipeline
from docling_core.types.doc import (
ContentLayer,
GroupItem,
Expand All @@ -21,20 +29,12 @@
TextItem,
)

from docling.backend.docx.drawingml.utils import get_libreoffice_cmd
from docling.backend.mspowerpoint_backend import (
_MAX_CHART_TABLE_CELLS,
MsPowerpointDocumentBackend,
_is_metafile,
_last_populated_row,
)
from docling.datamodel.backend_options import MsPowerpointBackendOptions
from docling.datamodel.base_models import InputFormat, ItemAndImageEnrichmentElement
from docling.datamodel.document import ConversionResult, DoclingDocument, InputDocument
from docling.datamodel.pipeline_options import ConvertPipelineOptions
from docling.document_converter import DocumentConverter, PowerpointFormatOption
from docling.models.base_model import BaseItemAndImageEnrichmentModel
from docling.pipeline.simple_pipeline import SimplePipeline

from .test_data_gen_flag import GEN_TEST_DATA
from .verify_utils import verify_document, verify_export
Expand Down Expand Up @@ -1345,6 +1345,42 @@ def texts(layer: ContentLayer) -> list[str]:
]


def test_pptx_hidden_shape_goes_to_invisible_layer(tmp_path: Path):
"""A shape hidden in PowerPoint (``p:cNvPr/@hidden="1"``) on a shown slide
is left out of the default export.

Its content stays available in ``ContentLayer.INVISIBLE``, like a hidden
slide.
"""
from pptx import Presentation
from pptx.util import Inches

prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[6])
slide.shapes.add_textbox(
Inches(1), Inches(1), Inches(6), Inches(1)
).text_frame.text = "Shown shape text"
hidden = slide.shapes.add_textbox(Inches(1), Inches(3), Inches(6), Inches(1))
hidden.text_frame.text = "Hidden shape text"
hidden._element.nvSpPr.cNvPr.set("hidden", "1")

pptx_path = tmp_path / "hidden_shape.pptx"
prs.save(pptx_path)

doc = convert_with_pptx_backend(pptx_path)

def texts(layer: ContentLayer) -> list[str]:
return [
item.text
for item, _ in doc.iterate_items(included_content_layers={layer})
if isinstance(item, TextItem)
]

assert "Hidden shape text" not in doc.export_to_markdown()
assert "Shown shape text" in doc.export_to_markdown()
assert texts(ContentLayer.INVISIBLE) == ["Hidden shape text"]


@pytest.mark.parametrize(
("layout_marker", "override_layer", "expected"),
[
Expand Down
Loading