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

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


The following commit(s) were added to refs/heads/main by this push:
     new 9f8b48425f TIKA-5017: Extract PPTX slides in presentation order (#3468)
9f8b48425f is described below

commit 9f8b48425fea3630020924f6ae96e98eabb9ce12
Author: Tim Allison <[email protected]>
AuthorDate: Sat Oct 31 07:47:56 2026 -0400

    TIKA-5017: Extract PPTX slides in presentation order (#3468)
    
    Read p:sldIdLst from presentation.xml instead of iterating slide
      relationships in rId string order (rId10 before rId2). Embedded-resource
      page numbers follow the same order. Related slides missing from the list
      are extracted last and counted in msoffice:ppt:num-unlisted-slides.
    
      Co-Authored-By: Claude Opus 5.5 <[email protected]>
---
 CHANGES.txt                                        |   5 +
 .../ooxml/SXSLFPowerPointExtractorDecorator.java   | 117 +++++++++++++++------
 .../parser/microsoft/ooxml/OOXMLPptxSAXTest.java   | 102 +++++++++++++++++-
 3 files changed, 188 insertions(+), 36 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 0ed5b18d8c..2a41b9c2fb 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -50,6 +50,11 @@ Release 4.3.0 - ???
 
   EXTRACTION AND FORMATS
 
+   * PPTX slides are extracted in presentation order (p:sldIdLst in 
presentation.xml)
+     instead of relationship-id order, which put rId10 before rId2; 
embedded-resource
+     page numbers 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).
+     
    * Superscript and subscript are kept as <sup>/<sub> in XHTML for docx 
(w:vertAlign,
      directly or through a character style), pptx (a:rPr baseline), doc, RTF 
(\super,
      \sub, \nosupersub) and HTML; they were dropped, so "x<sup>2</sup>" read 
"x2" and
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 b91ba18bf9..783b10a868 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.LinkedHashSet;
 import java.util.List;
 import java.util.Map;
@@ -35,7 +36,9 @@ import org.apache.poi.openxml4j.opc.PackageRelationshipTypes;
 import org.apache.poi.openxml4j.opc.PackagingURIHelper;
 import org.apache.poi.openxml4j.opc.TargetMode;
 import org.apache.poi.xslf.usermodel.XSLFRelation;
+import org.xml.sax.Attributes;
 import org.xml.sax.SAXException;
+import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.exception.WriteLimitReachedException;
@@ -83,6 +86,7 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
      * the second-and-later references.
      */
     private final Map<String, Set<Integer>> picturePages = new HashMap<>();
+    private List<PackageRelationship> slideRelationships = null;
 
     public SXSLFPowerPointExtractorDecorator(Metadata metadata, ParseContext 
context,
                                              XSLFEventBasedPowerPointExtractor 
extractor) {
@@ -105,26 +109,16 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
         loadCommentAuthors();
         addCommentAuthorMetadata();
 
-        PackageRelationshipCollection slidesPRC = null;
-        try {
-            slidesPRC = 
mainDocument.getRelationshipsByType(XSLFRelation.SLIDE.getRelation());
-        } catch (InvalidFormatException e) {
-            EmbeddedDocumentUtil.recordException(e, metadata, context);
-        }
-
         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 e) {
-                    EmbeddedDocumentUtil.recordException(e, metadata, context);
+        for (PackageRelationship slideRel : getSlideRelationships()) {
+            try {
+                PackagePart slidePart = safeGetRelatedPart(mainDocument, 
slideRel);
+                if (slidePart == null) {
+                    continue;
                 }
+                hiddenSlideCount += handleSlidePart(slidePart, xhtml);
+            } catch (InvalidFormatException | ZipException e) {
+                EmbeddedDocumentUtil.recordException(e, metadata, context);
             }
         }
         if (hiddenSlideCount > 0) {
@@ -145,6 +139,49 @@ 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) {
+            EmbeddedDocumentUtil.recordException(e, metadata, context);
+        }
+        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) {
+            EmbeddedDocumentUtil.recordException(e, metadata, context);
+        }
+        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 {
@@ -265,24 +302,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) {
-            EmbeddedDocumentUtil.recordException(e, metadata, context);
-
-        }
-        if (slidePRC != null) {
-            for (int i = 0; i < slidePRC.size(); i++) {
-                PackagePart slidePart = null;
-                try {
-                    slidePart = safeGetRelatedPart(mainDocument, 
slidePRC.getRelationship(i));
-                } catch (InvalidFormatException e) {
-                    EmbeddedDocumentUtil.recordException(e, metadata, context);
-                }
-                recordPicturePageRefs(slidePart, i + 1);
-                addSlideParts(slidePart, parts);
+        List<PackageRelationship> slideRels = getSlideRelationships();
+        for (int i = 0; i < slideRels.size(); i++) {
+            PackagePart slidePart = null;
+            try {
+                slidePart = safeGetRelatedPart(mainDocument, slideRels.get(i));
+            } catch (InvalidFormatException e) {
+                EmbeddedDocumentUtil.recordException(e, metadata, context);
             }
+            recordPicturePageRefs(slidePart, i + 1);
+            addSlideParts(slidePart, parts);
         }
 
         parts.add(mainDocument);
@@ -402,4 +431,22 @@ public class SXSLFPowerPointExtractorDecorator extends 
AbstractOOXMLExtractor {
 
     }
 
+    /** 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/OOXMLPptxSAXTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXTest.java
index f9921d5e03..adc191384d 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPptxSAXTest.java
@@ -17,13 +17,26 @@
 package org.apache.tika.parser.microsoft.ooxml;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+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.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
 import java.util.HashMap;
 import java.util.List;
 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.Disabled;
 import org.junit.jupiter.api.Test;
 import org.xml.sax.ContentHandler;
@@ -36,6 +49,7 @@ import org.apache.tika.metadata.HttpHeaders;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.metadata.Office;
 import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.metadata.TikaPagedText;
 import org.apache.tika.parser.AutoDetectParser;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.PasswordProvider;
@@ -357,6 +371,92 @@ public class OOXMLPptxSAXTest extends TikaTest {
         assertNotContained("Second level", mainContent);
     }
 
+    @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");
+        assertInOrder(metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT),
+                "This is some content", "Slide2Header", "Slide3Header", "This 
slide is hidden");
+        Metadata image = null;
+        for (Metadata m : metadataList) {
+            String path = m.get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH);
+            if (path != null && path.endsWith("image5.jpg")) {
+                image = m;
+            }
+        }
+        assertNotNull(image);
+        assertEquals("5", image.get(TikaPagedText.PAGE_NUMBER));
+    }
+
+    @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, 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, 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();
+    }
+
     @Test
     public void poiBug54916Test() throws Exception {
         String xml = getXML("testPPTX_overlappingRelations.pptx").xml;

Reply via email to