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;