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();
+ }
}