This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch branch_3x
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/branch_3x by this push:
     new 260c230aaf TIKA-5017: Extract PPTX slides in presentation order 
(backport of #3468) (#3471)
260c230aaf is described below

commit 260c230aafbf089f01a56d598abeac18dee2f934
Author: Tim Allison <[email protected]>
AuthorDate: Mon Nov 2 12:15:49 2026 -0500

    TIKA-5017: Extract PPTX slides in presentation order (backport of #3468) 
(#3471)
    
    * TIKA-5017: Extract PPTX slides in presentation order (backport of #3468)
    
    * TIKA-5017: add NUM_UNLISTED_SLIDES to metadata-keys-3x.json
---
 CHANGES.txt                                        |   5 +
 .../main/java/org/apache/tika/metadata/Office.java |   2 +
 .../org/apache/tika/metadata/metadata-keys-3x.json |   1 +
 .../ooxml/SXSLFPowerPointExtractorDecorator.java   | 125 ++++++++++++++-------
 .../parser/microsoft/ooxml/SXSLFExtractorTest.java |  91 +++++++++++++++
 5 files changed, 184 insertions(+), 40 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 0183e5c4b1..f9567955c0 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -1,5 +1,10 @@
 Release 3.4.0 - ???
 
+  * PPTX slides are extracted in presentation order (p:sldIdLst in 
presentation.xml)
+    instead of relationship-id order, which put rId10 before rId2; embedded 
resources
+    follow the same order. Slides that are related but missing from the list 
are
+    extracted last and counted in msoffice:ppt:num-unlisted-slides (TIKA-5017).
+
   * The default zip-bomb ratio (AutoDetectParserConfig 
maximumCompressionRatio) is 1000,
     up from 100. The ratio is now measured across the whole embedded tree 
against the
     container's bytes, so nested compression is still caught; at 100, ordinary 
compressed
diff --git a/tika-core/src/main/java/org/apache/tika/metadata/Office.java 
b/tika-core/src/main/java/org/apache/tika/metadata/Office.java
index 6e9a20e70b..82adbf4479 100644
--- a/tika-core/src/main/java/org/apache/tika/metadata/Office.java
+++ b/tika-core/src/main/java/org/apache/tika/metadata/Office.java
@@ -225,6 +225,8 @@ public interface Office {
 
     Property HAS_ANIMATIONS = 
Property.internalBoolean("msoffice:ppt:has-animations");
 
+    Property NUM_UNLISTED_SLIDES = 
Property.internalInteger("msoffice:ppt:num-unlisted-slides");
+
     //w:vanish or isVanish or isFldVanish
     Property HAS_HIDDEN_TEXT = 
Property.internalBoolean("msoffice:doc:has-hidden-text");
 
diff --git 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys-3x.json
 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys-3x.json
index 78de3ef835..d9790f1054 100644
--- 
a/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys-3x.json
+++ 
b/tika-metadata-schema/src/main/resources/org/apache/tika/metadata/metadata-keys-3x.json
@@ -235,6 +235,7 @@
   
{"class":"org.apache.tika.metadata.Office","field":"MAPI_RECIPIENTS_STRING","key":"meta:mapi-recipients-string"},
   
{"class":"org.apache.tika.metadata.Office","field":"MAPI_SENT_BY_SERVER_TYPE","key":"meta:mapi-sent-by-server-type"},
   
{"class":"org.apache.tika.metadata.Office","field":"NUM_HIDDEN_SLIDES","key":"msoffice:ppt:num-hidden-slides"},
+  
{"class":"org.apache.tika.metadata.Office","field":"NUM_UNLISTED_SLIDES","key":"msoffice:ppt:num-unlisted-slides"},
   
{"class":"org.apache.tika.metadata.Office","field":"OBJECT_COUNT","key":"meta:object-count"},
   
{"class":"org.apache.tika.metadata.Office","field":"OCX_NAME","key":"msoffice:ocxName"},
   
{"class":"org.apache.tika.metadata.Office","field":"PAGE_COUNT","key":"meta:page-count"},
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
index 3c5d3f05fc..38f0764cd8 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXSLFPowerPointExtractorDecorator.java
@@ -20,6 +20,7 @@ import java.io.IOException;
 import java.io.InputStream;
 import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
 import java.util.zip.ZipException;
@@ -79,6 +80,7 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
     private final Metadata metadata;
     private final CommentAuthors commentAuthors = new CommentAuthors();
     private PackagePart mainDocument = null;
+    private List<PackageRelationship> slideRelationships = null;
 
     public SXSLFPowerPointExtractorDecorator(Metadata metadata, ParseContext 
context,
                                              XSLFEventBasedPowerPointExtractor 
extractor) {
@@ -105,28 +107,17 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
         addCommentAuthorMetadata();
 
 
-        PackageRelationshipCollection slidesPRC = null;
-        try {
-            slidesPRC = 
mainDocument.getRelationshipsByType(XSLFRelation.SLIDE.getRelation());
-        } catch (InvalidFormatException e) {
-            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
-                    ExceptionUtils.getStackTrace(e));
-        }
-
         int hiddenSlideCount = 0;
-        if (slidesPRC != null && slidesPRC.size() > 0) {
-            for (int i = 0; i < slidesPRC.size(); i++) {
-                try {
-                    PackagePart slidePart =
-                            safeGetRelatedPart(mainDocument, 
slidesPRC.getRelationship(i));
-                    if (slidePart == null) {
-                        continue;
-                    }
-                    hiddenSlideCount += handleSlidePart(slidePart, xhtml);
-                } catch (InvalidFormatException | ZipException | 
IllegalArgumentException e) {
-                    
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
-                            ExceptionUtils.getStackTrace(e));
+        for (PackageRelationship slideRel : getSlideRelationships()) {
+            try {
+                PackagePart slidePart = safeGetRelatedPart(mainDocument, 
slideRel);
+                if (slidePart == null) {
+                    continue;
                 }
+                hiddenSlideCount += handleSlidePart(slidePart, xhtml);
+            } catch (InvalidFormatException | ZipException | 
IllegalArgumentException e) {
+                metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        ExceptionUtils.getStackTrace(e));
             }
         }
         if (hiddenSlideCount > 0) {
@@ -147,6 +138,51 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
         }
     }
 
+    /**
+     * Slide relationships in presentation order ({@code p:sldIdLst}), not in 
the
+     * relationship-id order POI iterates. Slides related but absent from the 
list go last.
+     */
+    private List<PackageRelationship> getSlideRelationships() {
+        if (slideRelationships != null) {
+            return slideRelationships;
+        }
+        slideRelationships = new ArrayList<>();
+        PackageRelationshipCollection slidesPRC = null;
+        try {
+            slidesPRC = 
mainDocument.getRelationshipsByType(XSLFRelation.SLIDE.getRelation());
+        } catch (InvalidFormatException e) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    ExceptionUtils.getStackTrace(e));
+        }
+        if (slidesPRC == null || slidesPRC.size() == 0) {
+            return slideRelationships;
+        }
+        Map<String, PackageRelationship> remaining = new LinkedHashMap<>();
+        for (PackageRelationship rel : slidesPRC) {
+            remaining.put(rel.getId(), rel);
+        }
+        SlideIdListHandler handler = new SlideIdListHandler();
+        boolean parsed = false;
+        try (InputStream stream = mainDocument.getInputStream()) {
+            XMLReaderUtils.parseSAX(stream, handler, context);
+            parsed = true;
+        } catch (TikaException | SAXException | IOException e) {
+            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                    ExceptionUtils.getStackTrace(e));
+        }
+        for (String rId : handler.rIds) {
+            PackageRelationship rel = remaining.remove(rId);
+            if (rel != null) {
+                slideRelationships.add(rel);
+            }
+        }
+        if (parsed && !remaining.isEmpty()) {
+            metadata.set(Office.NUM_UNLISTED_SLIDES, remaining.size());
+        }
+        slideRelationships.addAll(remaining.values());
+        return slideRelationships;
+    }
+
     private void loadCommentAuthors() {
         PackageRelationshipCollection prc = null;
         try {
@@ -272,26 +308,16 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
         List<PackagePart> parts = new ArrayList<>();
         //TODO: consider: 
getPackage().getPartsByName(Pattern.compile("/ppt/embeddings/.*?
         //TODO: consider: 
getPackage().getPartsByName(Pattern.compile("/ppt/media/.*?
-        PackageRelationshipCollection slidePRC = null;
-        try {
-            slidePRC = 
mainDocument.getRelationshipsByType(XSLFRelation.SLIDE.getRelation());
-        } catch (InvalidFormatException e) {
-            metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
-                    ExceptionUtils.getStackTrace(e));
-
-        }
-        if (slidePRC != null) {
-            for (int i = 0; i < slidePRC.size(); i++) {
-                PackagePart slidePart = null;
-                try {
-                    slidePart = 
mainDocument.getRelatedPart(slidePRC.getRelationship(i));
-                } catch (InvalidFormatException | IllegalArgumentException e) {
-                    
metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
-                            ExceptionUtils.getStackTrace(e));
-                }
-                if (slidePart != null) {
-                    addSlideParts(slidePart, parts);
-                }
+        for (PackageRelationship slideRel : getSlideRelationships()) {
+            PackagePart slidePart = null;
+            try {
+                slidePart = mainDocument.getRelatedPart(slideRel);
+            } catch (InvalidFormatException | IllegalArgumentException e) {
+                metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        ExceptionUtils.getStackTrace(e));
+            }
+            if (slidePart != null) {
+                addSlideParts(slidePart, parts);
             }
         }
 
@@ -526,4 +552,23 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
             return initialMap.get(id);
         }
     }
+
+    /** Collects the relationship ids of {@code p:sldIdLst/p:sldId} in 
document order. */
+    private static class SlideIdListHandler extends DefaultHandler {
+        private final List<String> rIds = new ArrayList<>();
+
+        @Override
+        public void startElement(String uri, String localName, String qName, 
Attributes atts) {
+            if (!"sldId".equals(localName)) {
+                return;
+            }
+            // r:id; the unqualified id attribute is the numeric slide id
+            for (int i = 0; i < atts.getLength(); i++) {
+                if ("id".equals(atts.getLocalName(i)) && 
!atts.getURI(i).isEmpty()) {
+                    rIds.add(atts.getValue(i));
+                    return;
+                }
+            }
+        }
+    }
 }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXSLFExtractorTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXSLFExtractorTest.java
index 534413c894..e14bd74fed 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXSLFExtractorTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXSLFExtractorTest.java
@@ -17,14 +17,27 @@
 package org.apache.tika.parser.microsoft.ooxml;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.fail;
 
+import java.io.ByteArrayOutputStream;
 import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 import java.util.Locale;
 import java.util.Map;
+import java.util.function.UnaryOperator;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+
+import org.apache.commons.compress.archivers.zip.ZipArchiveEntry;
+import org.apache.commons.compress.archivers.zip.ZipArchiveInputStream;
+import org.apache.commons.compress.archivers.zip.ZipArchiveOutputStream;
+import org.apache.commons.io.IOUtils;
 
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.Disabled;
@@ -34,6 +47,7 @@ import org.xml.sax.ContentHandler;
 import org.apache.tika.TikaTest;
 import org.apache.tika.config.TikaConfig;
 import org.apache.tika.exception.EncryptedDocumentException;
+import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
@@ -604,4 +618,81 @@ public class SXSLFExtractorTest extends TikaTest {
         assertContains("President", getXML("testPPT_diagramData.pptx", 
parseContext).xml);
     }
 
+
+    @Test
+    public void testSlidesInPresentationOrder() throws Exception {
+        //rId10 and rId11 (slides 9 and 10) sort before rId2 as strings
+        List<Metadata> metadataList = 
getRecursiveMetadata("testPPT_various2.pptx", parseContext);
+        assertInOrder(metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT),
+                "This is some content", "Slide2Header", "Slide3Header", "This 
slide is hidden");
+    }
+
+    @Test
+    public void testSlidesFollowSlideIdList() throws Exception {
+        //reverse p:sldIdLst so presentation order disagrees with every 
relationship ordering
+        byte[] pptx = rewriteEntry("testPPT.pptx", "ppt/presentation.xml", xml 
-> {
+            Matcher m = 
Pattern.compile("(?s)<p:sldIdLst>(.*?)</p:sldIdLst>").matcher(xml);
+            assertTrue(m.find());
+            List<String> ids = new ArrayList<>();
+            Matcher id = Pattern.compile("<p:sldId 
[^>]*/>").matcher(m.group(1));
+            while (id.find()) {
+                ids.add(id.group());
+            }
+            assertEquals(3, ids.size());
+            Collections.reverse(ids);
+            return xml.substring(0, m.start(1)) + String.join("", ids) + 
xml.substring(m.end(1));
+        });
+        Metadata metadata;
+        try (TikaInputStream tis = TikaInputStream.get(pptx)) {
+            metadata = getRecursiveMetadata(tis, new Metadata(), parseContext, 
false).get(0);
+        }
+        assertInOrder(metadata.get(TikaCoreProperties.TIKA_CONTENT),
+                "Different words to test against", "This is a test file data", 
"Attachment Test");
+        assertNull(metadata.get(Office.NUM_UNLISTED_SLIDES));
+    }
+
+    @Test
+    public void testUnlistedSlidesGoLast() throws Exception {
+        //drop the first slide from p:sldIdLst; its relationship and part 
remain
+        byte[] pptx = rewriteEntry("testPPT.pptx", "ppt/presentation.xml",
+                xml -> xml.replaceFirst("<p:sldId [^>]*r:id=\"rId2\"/>", ""));
+        Metadata metadata;
+        try (TikaInputStream tis = TikaInputStream.get(pptx)) {
+            metadata = getRecursiveMetadata(tis, new Metadata(), parseContext, 
false).get(0);
+        }
+        assertInOrder(metadata.get(TikaCoreProperties.TIKA_CONTENT),
+                "This is a test file data", "Different words to test against", 
"Attachment Test");
+        assertEquals(1, metadata.getInt(Office.NUM_UNLISTED_SLIDES));
+    }
+
+    private static void assertInOrder(String content, String... needles) {
+        int body = content.indexOf("<body");
+        int last = body;
+        for (String needle : needles) {
+            int i = content.indexOf(needle, body);
+            assertTrue(i > last, needle + " out of order");
+            last = i;
+        }
+    }
+
+    private byte[] rewriteEntry(String resource, String entryName,
+                                UnaryOperator<String> rewrite) throws 
Exception {
+        ByteArrayOutputStream bos = new ByteArrayOutputStream();
+        try (ZipArchiveInputStream zin =
+                     new 
ZipArchiveInputStream(getResourceAsStream("/test-documents/" + resource));
+             ZipArchiveOutputStream zout = new ZipArchiveOutputStream(bos)) {
+            ZipArchiveEntry entry;
+            while ((entry = zin.getNextEntry()) != null) {
+                byte[] data = IOUtils.toByteArray(zin);
+                if (entry.getName().equals(entryName)) {
+                    data = rewrite.apply(new String(data, 
StandardCharsets.UTF_8))
+                            .getBytes(StandardCharsets.UTF_8);
+                }
+                zout.putArchiveEntry(new ZipArchiveEntry(entry.getName()));
+                zout.write(data);
+                zout.closeArchiveEntry();
+            }
+        }
+        return bos.toByteArray();
+    }
 }

Reply via email to