diff --git a/docling/backend/mspowerpoint_backend.py b/docling/backend/mspowerpoint_backend.py index ef888e5953..206f08d277 100644 --- a/docling/backend/mspowerpoint_backend.py +++ b/docling/backend/mspowerpoint_backend.py @@ -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, @@ -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 @@ -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: @@ -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. diff --git a/tests/test_backend_pptx.py b/tests/test_backend_pptx.py index 4bccc0e13c..a4d8ce1ba0 100644 --- a/tests/test_backend_pptx.py +++ b/tests/test_backend_pptx.py @@ -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, @@ -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 @@ -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"), [