diff --git a/CHANGES.txt b/CHANGES.txt index 17c5276bab1..106fe2a36c0 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,12 @@ Release 3.3.2 - (unreleased) /status endpoints are selected; the server refuses to start otherwise. Previously listing the endpoint was treated as sufficient consent (TIKA-4760). + * Port the 4.x SAX-based OOXML parsers to 3.x. The docx/pptx/xlsx/vsdx SAX parsers + gain field-code hyperlink extraction, inlined footnotes/endnotes/comments, + balanced-XHTML recovery on error, and XMLBeans-free xlsx/xlsb reading. The SAX + docx/pptx parsers stay opt-in via useSAXDocxExtractor/useSAXPptxExtractor, so DOM + remains the default (TIKA-4692, TIKA-4708). + Release 3.3.1 - 5/20/2026 * Dependency upgrades (TIKA-4695). diff --git a/tika-core/src/main/java/org/apache/tika/sax/StrictXHTMLValidator.java b/tika-core/src/main/java/org/apache/tika/sax/StrictXHTMLValidator.java new file mode 100644 index 00000000000..12cf3b629ff --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/sax/StrictXHTMLValidator.java @@ -0,0 +1,229 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.sax; + +import java.util.ArrayDeque; +import java.util.Deque; +import java.util.HashSet; +import java.util.Set; + +import org.xml.sax.Attributes; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; + +/** + * A SAX content handler decorator that enforces XHTML well-formedness on the + * incoming event stream. Any parser that emits an event sequence that would + * produce malformed XHTML triggers a {@link SAXException} synchronously — the + * stack trace points at the parser code that made the offending call, instead + * of surfacing later as a parse error on the serialized output. + *

+ * Invariants enforced: + *

+ * Use as a decorator wrapping the real handler. It passes every event through + * to the downstream handler after validation, so any normal text/XHTML capture + * still works. + */ +public class StrictXHTMLValidator extends ContentHandlerDecorator { + + private final Deque openElements = new ArrayDeque<>(); + private boolean documentStarted; + private boolean documentEnded; + + public StrictXHTMLValidator(ContentHandler handler) { + super(handler); + } + + @Override + public void startDocument() throws SAXException { + if (documentStarted) { + throw new SAXException("StrictXHTMLValidator: startDocument called twice"); + } + if (documentEnded) { + throw new SAXException( + "StrictXHTMLValidator: startDocument after endDocument"); + } + documentStarted = true; + super.startDocument(); + } + + @Override + public void endDocument() throws SAXException { + if (documentEnded) { + throw new SAXException("StrictXHTMLValidator: endDocument called twice"); + } + if (!openElements.isEmpty()) { + throw new SAXException( + "StrictXHTMLValidator: endDocument with " + openElements.size() + + " unclosed element(s); topmost was <" + + openElements.peek().qOrLocal() + ">"); + } + documentEnded = true; + super.endDocument(); + } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attrs) + throws SAXException { + ensureNotEnded("startElement <" + display(qName, localName) + ">"); + checkAttributesUnique(qName, localName, attrs); + openElements.push(new QName(uri, localName, qName)); + super.startElement(uri, localName, qName, attrs); + } + + @Override + public void endElement(String uri, String localName, String qName) throws SAXException { + ensureNotEnded("endElement "); + if (openElements.isEmpty()) { + throw new SAXException( + "StrictXHTMLValidator: endElement with no matching startElement"); + } + QName top = openElements.pop(); + if (!top.matches(uri, localName, qName)) { + throw new SAXException( + "StrictXHTMLValidator: endElement does not match topmost open element <" + + top.qOrLocal() + ">"); + } + super.endElement(uri, localName, qName); + } + + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + ensureNotEnded("characters"); + super.characters(ch, start, length); + } + + @Override + public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException { + ensureNotEnded("ignorableWhitespace"); + super.ignorableWhitespace(ch, start, length); + } + + @Override + public void processingInstruction(String target, String data) throws SAXException { + ensureNotEnded("processingInstruction"); + super.processingInstruction(target, data); + } + + @Override + public void startPrefixMapping(String prefix, String uri) throws SAXException { + ensureNotEnded("startPrefixMapping"); + super.startPrefixMapping(prefix, uri); + } + + @Override + public void endPrefixMapping(String prefix) throws SAXException { + ensureNotEnded("endPrefixMapping"); + super.endPrefixMapping(prefix); + } + + @Override + public void skippedEntity(String name) throws SAXException { + ensureNotEnded("skippedEntity"); + super.skippedEntity(name); + } + + private void ensureNotEnded(String event) throws SAXException { + if (documentEnded) { + throw new SAXException( + "StrictXHTMLValidator: " + event + " arrived after endDocument"); + } + } + + private void checkAttributesUnique(String elementQName, String elementLocalName, + Attributes attrs) throws SAXException { + int n = attrs.getLength(); + if (n < 2) { + return; + } + // (uri, localName) pairs must be unique per the XML namespaces spec. + // We also check raw qnames because Tika's serializers emit by qname and + // duplicate qnames produce malformed XHTML even when localnames differ. + Set seenUriLocal = new HashSet<>(n); + Set seenQNames = new HashSet<>(n); + for (int i = 0; i < n; i++) { + String uri = nullSafe(attrs.getURI(i)); + String local = nullSafe(attrs.getLocalName(i)); + String qn = nullSafe(attrs.getQName(i)); + // U+0001 cannot appear in a valid XML uri/localName, so it joins the + // two unambiguously without risk of a key collision. + String key = uri + "\u0001" + local; + if (!seenUriLocal.add(key)) { + throw new SAXException( + "StrictXHTMLValidator: duplicate attribute on <" + + display(elementQName, elementLocalName) + ">: " + + (uri.isEmpty() ? local : ("{" + uri + "}" + local))); + } + if (!qn.isEmpty() && !seenQNames.add(qn)) { + throw new SAXException( + "StrictXHTMLValidator: duplicate attribute qname on <" + + display(elementQName, elementLocalName) + ">: " + qn); + } + } + } + + private static String nullSafe(String s) { + return s == null ? "" : s; + } + + private static String display(String qName, String localName) { + if (qName != null && !qName.isEmpty()) { + return qName; + } + return localName == null ? "" : localName; + } + + private static final class QName { + final String uri; + final String localName; + final String qName; + + QName(String uri, String localName, String qName) { + this.uri = nullSafe(uri); + this.localName = nullSafe(localName); + this.qName = nullSafe(qName); + } + + boolean matches(String u, String l, String q) { + // SAX parsers can vary in which fields they populate. Accept a + // match on either (uri, localName) or qName, whichever is present. + String otherU = nullSafe(u); + String otherL = nullSafe(l); + String otherQ = nullSafe(q); + boolean uriLocalMatch = uri.equals(otherU) && localName.equals(otherL) + && !localName.isEmpty(); + boolean qNameMatch = !qName.isEmpty() && qName.equals(otherQ); + return uriLocalMatch || qNameMatch; + } + + String qOrLocal() { + return qName.isEmpty() ? localName : qName; + } + } +} diff --git a/tika-core/src/main/java/org/apache/tika/sax/XHTMLBalancingHandler.java b/tika-core/src/main/java/org/apache/tika/sax/XHTMLBalancingHandler.java new file mode 100644 index 00000000000..14a0a9e9bcd --- /dev/null +++ b/tika-core/src/main/java/org/apache/tika/sax/XHTMLBalancingHandler.java @@ -0,0 +1,123 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.sax; + +import java.util.ArrayDeque; +import java.util.Deque; + +import org.xml.sax.Attributes; +import org.xml.sax.ContentHandler; +import org.xml.sax.SAXException; + +/** + * SAX decorator that tracks open elements so a parser can recover well-formed + * XHTML when an exception interrupts the SAX stream mid-element. + *

+ * The decorator is a thin passthrough on the happy path: it pushes and pops an + * internal stack on {@code startElement}/{@code endElement} and otherwise forwards + * every event to the wrapped handler unchanged. It deliberately does NOT mask + * bad event sequences (mismatched or excess endElement, duplicate attributes, + * etc.) -- those remain visible to {@link StrictXHTMLValidator} so parser bugs + * still surface as test failures. + *

+ * The unhappy path -- a per-part SAX parser throwing mid-element after emitting + * one or more start tags -- is handled via {@link #drainOpenElements()}, which + * emits a matching {@code endElement} (with the original uri/localName/qName) + * for every element still on the stack, in reverse open order. The wrapped + * handler is left in a well-formed state with no dangling elements from the + * failed sub-parse. + *

+ * Typical use wraps the handler that receives events from an inner SAX parser, + * inside the catch arm that swallows the inner parser's exception: + *

{@code
+ * XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(contentHandler);
+ * try {
+ *     XMLReaderUtils.parseSAX(stream, new EmbeddedContentHandler(balancer), context);
+ * } catch (SAXException e) {
+ *     balancer.drainOpenElements();
+ *     // ... log and continue ...
+ * }
+ * }
+ * This handler does not touch {@code startDocument}/{@code endDocument}; the + * caller still owns the document lifecycle. + */ +public class XHTMLBalancingHandler extends ContentHandlerDecorator { + + private final Deque openElements = new ArrayDeque<>(); + + public XHTMLBalancingHandler(ContentHandler handler) { + super(handler); + } + + @Override + public void startElement(String uri, String localName, String qName, Attributes attrs) + throws SAXException { + openElements.push(new QName(uri, localName, qName)); + super.startElement(uri, localName, qName, attrs); + } + + @Override + public void endElement(String uri, String localName, String qName) throws SAXException { + // Pop best-effort: an unbalanced endElement (e.g., emitted after the + // matching startElement was swallowed) still forwards downstream so a + // wrapping StrictXHTMLValidator sees the violation. + if (!openElements.isEmpty()) { + openElements.pop(); + } + super.endElement(uri, localName, qName); + } + + /** + * Emits a matching {@code endElement} for every element still on the open + * stack, in reverse open order. After this call the stack is empty. + *

+ * Intended for the catch arm of a caller that swallowed a + * {@link SAXException} from an inner SAX parser: the inner parser may have + * left one or more elements open mid-stream, and downstream serialization + * needs matching closers before any further events. + *

+ * Does NOT emit {@code endDocument} -- document lifecycle stays with the + * caller. + */ + public void drainOpenElements() throws SAXException { + while (!openElements.isEmpty()) { + QName q = openElements.pop(); + super.endElement(q.uri, q.localName, q.qName); + } + } + + /** + * Number of elements currently open through this handler. Exposed for + * tests and for callers that want to know whether + * {@link #drainOpenElements()} would emit anything. + */ + public int openElementCount() { + return openElements.size(); + } + + private static final class QName { + final String uri; + final String localName; + final String qName; + + QName(String uri, String localName, String qName) { + this.uri = uri == null ? "" : uri; + this.localName = localName == null ? "" : localName; + this.qName = qName == null ? "" : qName; + } + } +} diff --git a/tika-core/src/test/java/org/apache/tika/sax/XHTMLBalancingHandlerTest.java b/tika-core/src/test/java/org/apache/tika/sax/XHTMLBalancingHandlerTest.java new file mode 100644 index 00000000000..3688616c421 --- /dev/null +++ b/tika-core/src/test/java/org/apache/tika/sax/XHTMLBalancingHandlerTest.java @@ -0,0 +1,130 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.sax; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import org.junit.jupiter.api.Test; +import org.xml.sax.helpers.AttributesImpl; + +public class XHTMLBalancingHandlerTest { + + private static AttributesImpl noAtts() { + return new AttributesImpl(); + } + + @Test + public void happyPathIsPassthrough() throws Exception { + ToXMLContentHandler out = new ToXMLContentHandler(); + XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(out); + + balancer.startDocument(); + balancer.startElement("", "p", "p", noAtts()); + balancer.characters("hello".toCharArray(), 0, 5); + balancer.endElement("", "p", "p"); + balancer.endDocument(); + + assertEquals(0, balancer.openElementCount()); + // No drain needed -- stack should already be empty. + balancer.drainOpenElements(); + // ToXMLContentHandler.toString() returns the serialized form. + String xml = out.toString(); + assertEquals(true, xml.contains("

hello

"), xml); + } + + @Test + public void drainClosesElementsInReverseOpenOrder() throws Exception { + ToXMLContentHandler out = new ToXMLContentHandler(); + XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(out); + + balancer.startDocument(); + balancer.startElement("", "div", "div", noAtts()); + balancer.startElement("", "p", "p", noAtts()); + balancer.startElement("", "span", "span", noAtts()); + balancer.characters("oops".toCharArray(), 0, 4); + + // Simulate exception mid-element: caller drains. + assertEquals(3, balancer.openElementCount()); + balancer.drainOpenElements(); + assertEquals(0, balancer.openElementCount()); + + balancer.endDocument(); + + String xml = out.toString(); + // Expect

in that order. + int spanIdx = xml.indexOf(""); + int pIdx = xml.indexOf("

"); + int divIdx = xml.indexOf(""); + assertEquals(true, spanIdx >= 0 && pIdx > spanIdx && divIdx > pIdx, + "expected

order, got: " + xml); + } + + @Test + public void drainEmitsMatchingUriAndQName() throws Exception { + // Verifies the Copilot review point: endElement must carry the same + // (uri, localName, qName) tuple as the matching startElement. + ToXMLContentHandler out = new ToXMLContentHandler(); + XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(out); + + balancer.startDocument(); + balancer.startPrefixMapping("h", "http://example.com/ns"); + balancer.startElement("http://example.com/ns", "wrap", "h:wrap", noAtts()); + // Emit content so the serializer can't collapse to a self-closing tag, + // forcing the close form to be explicit -- proves drainOpen used the + // matching qName ("h:wrap") rather than just the local name. + balancer.characters("x".toCharArray(), 0, 1); + balancer.drainOpenElements(); + balancer.endPrefixMapping("h"); + balancer.endDocument(); + + String xml = out.toString(); + assertEquals(true, xml.contains(""), + "expected qualified close , got: " + xml); + } + + @Test + public void drainIsIdempotent() throws Exception { + ToXMLContentHandler out = new ToXMLContentHandler(); + XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(out); + + balancer.startDocument(); + balancer.startElement("", "p", "p", noAtts()); + balancer.drainOpenElements(); + balancer.drainOpenElements(); // second call: no-op + balancer.endDocument(); + assertEquals(0, balancer.openElementCount()); + } + + @Test + public void downstreamValidatorStillCatchesMismatchedEndElement() throws Exception { + // Balancer must NOT silently fix bad happy-path sequences -- the + // StrictXHTMLValidator wrapping the real handler must still see (and + // reject) excess endElement events. + ToXMLContentHandler out = new ToXMLContentHandler(); + StrictXHTMLValidator validator = new StrictXHTMLValidator(out); + XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(validator); + + balancer.startDocument(); + balancer.startElement("", "p", "p", noAtts()); + balancer.endElement("", "p", "p"); + // Extra endElement: stack is empty so balancer pops nothing, but the + // event still flows downstream to the validator, which must throw. + assertThrows(org.xml.sax.SAXException.class, + () -> balancer.endElement("", "p", "p")); + } +} diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractOfficeParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractOfficeParser.java index ea5179552de..5c7b78c2f7a 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractOfficeParser.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/AbstractOfficeParser.java @@ -112,6 +112,15 @@ public boolean isUseSAXPptxExtractor() { return defaultOfficeParserConfig.isUseSAXPptxExtractor(); } + @Field + public void setPreferAlternateContentChoice(boolean preferAlternateContentChoice) { + defaultOfficeParserConfig.setPreferAlternateContentChoice(preferAlternateContentChoice); + } + + public boolean isPreferAlternateContentChoice() { + return defaultOfficeParserConfig.isPreferAlternateContentChoice(); + } + @Field public void setConcatenatePhoneticRuns(boolean concatenatePhoneticRuns) { defaultOfficeParserConfig.setConcatenatePhoneticRuns(concatenatePhoneticRuns); diff --git a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java index 5972d548cda..feeaae1b512 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/OfficeParserConfig.java @@ -35,6 +35,8 @@ public class OfficeParserConfig implements Serializable { private boolean useSAXDocxExtractor = false; private boolean useSAXPptxExtractor = false; + private boolean preferAlternateContentChoice = true; + private boolean extractAllAlternativesFromMSG = false; //we'll stop doing this in 4.x private boolean writeSelectHeadersInBody = true; @@ -169,6 +171,34 @@ public void setUseSAXPptxExtractor(boolean useSAXPptxExtractor) { this.useSAXPptxExtractor = useSAXPptxExtractor; } + /** + * In OOXML, {@code mc:AlternateContent} wraps {@code mc:Choice} (newer/richer + * rendering, e.g. DrawingML text boxes) and {@code mc:Fallback} (degraded VML + * for older consumers). When {@code true} (default), the SAX parser processes + * the Choice branch and skips Fallback. When {@code false}, it processes + * Fallback and skips Choice (legacy behavior prior to Tika 4.x). + *

+ * For text extraction, Choice typically contains equal or more content than + * Fallback. + *

+ * Only consulted by the SAX-based (streaming) OOXML extractors. + *

+ * Default: {@code true} + * + * @return whether to prefer mc:Choice over mc:Fallback + */ + public boolean isPreferAlternateContentChoice() { + return preferAlternateContentChoice; + } + + /** + * @param preferAlternateContentChoice whether to prefer mc:Choice over mc:Fallback + * @see #isPreferAlternateContentChoice() + */ + public void setPreferAlternateContentChoice(boolean preferAlternateContentChoice) { + this.preferAlternateContentChoice = preferAlternateContentChoice; + } + public boolean isConcatenatePhoneticRuns() { return concatenatePhoneticRuns; } 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/AbstractOOXMLExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java index a8d65cd8957..d19aaae4a1d 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/AbstractOOXMLExtractor.java @@ -105,10 +105,14 @@ public abstract class AbstractOOXMLExtractor implements OOXMLExtractor { private final ParseContext context; protected OfficeParserConfig config; protected POIXMLTextExtractor extractor; + //derived from the extractor's OPCPackage; used by the SAX-based extractors and + //SAXBasedMetadataExtractor, which read directly from the package rather than via POI. + protected OPCPackage opcPackage; public AbstractOOXMLExtractor(ParseContext context, POIXMLTextExtractor extractor) { this.context = context; this.extractor = extractor; + this.opcPackage = (extractor == null) ? null : extractor.getPackage(); embeddedExtractor = EmbeddedDocumentUtil.getEmbeddedDocumentExtractor(context); // This has already been set by OOXMLParser's call to configure() @@ -116,6 +120,14 @@ public AbstractOOXMLExtractor(ParseContext context, POIXMLTextExtractor extracto this.config = context.get(OfficeParserConfig.class); } + /** + * @return the {@link ParseContext} this extractor was constructed with. Used by SAX-based + * subclasses (e.g. to build a {@link SAXBasedMetadataExtractor}). + */ + protected ParseContext getParseContext() { + return context; + } + /** * @see org.apache.tika.parser.microsoft.ooxml.OOXMLExtractor#getDocument() */ 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/EditType.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/EditType.java new file mode 100644 index 00000000000..cbc9d1ae86a --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/EditType.java @@ -0,0 +1,21 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +public enum EditType { + NONE, INSERT, DELETE, MOVE_TO, MOVE_FROM +} 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/FieldCodeParser.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FieldCodeParser.java new file mode 100644 index 00000000000..d71cbdc7f9c --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FieldCodeParser.java @@ -0,0 +1,109 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Parses OOXML field codes (instrText) to extract URLs from HYPERLINK, + * INCLUDEPICTURE, INCLUDETEXT, IMPORT, and LINK fields. + *

+ * This class has no Tika dependencies and could be contributed to POI. + */ +public class FieldCodeParser { + + private static final Pattern HYPERLINK_PATTERN = + Pattern.compile("HYPERLINK\\s{1,100}\"([^\"]{1,10000})\"", + Pattern.CASE_INSENSITIVE); + private static final Pattern INCLUDEPICTURE_PATTERN = + Pattern.compile("INCLUDEPICTURE\\s{1,100}\"([^\"]{1,10000})\"", + Pattern.CASE_INSENSITIVE); + private static final Pattern INCLUDETEXT_PATTERN = + Pattern.compile("INCLUDETEXT\\s{1,100}\"([^\"]{1,10000})\"", + Pattern.CASE_INSENSITIVE); + private static final Pattern IMPORT_PATTERN = + Pattern.compile("IMPORT\\s{1,100}\"([^\"]{1,10000})\"", + Pattern.CASE_INSENSITIVE); + private static final Pattern LINK_PATTERN = + Pattern.compile( + "LINK\\s{1,100}[\\w.]{1,50}\\s{1,100}\"([^\"]{1,10000})\"", + Pattern.CASE_INSENSITIVE); + + private FieldCodeParser() { + } + + /** + * Parses a HYPERLINK URL from instrText field code content. + * Field codes like: {@code HYPERLINK "https://example.com"} + * + * @param instrText the accumulated instrText content + * @return the URL if found, or null + */ + public static String parseHyperlinkFromInstrText(String instrText) { + if (instrText == null || instrText.isEmpty()) { + return null; + } + Matcher m = HYPERLINK_PATTERN.matcher(instrText.trim()); + if (m.find()) { + return m.group(1); + } + return null; + } + + /** + * Parses URLs from instrText field codes that reference external resources. + * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, and LINK fields. + * + * @param instrText the accumulated instrText content + * @param fieldType output parameter - will contain the field type if found + * @return the URL if found, or null + */ + public static String parseExternalRefFromInstrText(String instrText, + StringBuilder fieldType) { + if (instrText == null || instrText.isEmpty()) { + return null; + } + String trimmed = instrText.trim(); + + Matcher m = INCLUDEPICTURE_PATTERN.matcher(trimmed); + if (m.find()) { + fieldType.append("INCLUDEPICTURE"); + return m.group(1); + } + + m = INCLUDETEXT_PATTERN.matcher(trimmed); + if (m.find()) { + fieldType.append("INCLUDETEXT"); + return m.group(1); + } + + m = IMPORT_PATTERN.matcher(trimmed); + if (m.find()) { + fieldType.append("IMPORT"); + return m.group(1); + } + + m = LINK_PATTERN.matcher(trimmed); + if (m.find()) { + fieldType.append("LINK"); + return m.group(1); + } + + return null; + } +} 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/FormattingTagManager.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java new file mode 100644 index 00000000000..3e8ae2f0de8 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java @@ -0,0 +1,211 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.util.Objects; + +import org.apache.poi.xwpf.usermodel.UnderlinePatterns; +import org.xml.sax.SAXException; + +import org.apache.tika.sax.XHTMLContentHandler; + +/** + * Single owner of all run-scoped XHTML wrapper tags, ensuring proper nesting. + * Nesting order from outermost to innermost: + * {@code text}. + *

+ * Hyperlinks come from two OOXML sources with different lifecycles: + *

    + *
  • Wrapper hyperlinks (DOCX {@code }, field-code HYPERLINK): + * opened/closed explicitly via {@link #openHyperlink}/{@link #closeHyperlink}, + * span multiple runs.
  • + *
  • Run-property hyperlinks (PPTX {@code }): + * set on {@link RunProperties#setHlinkClickUrl}, managed automatically + * by {@link #applyFormatting} per-run.
  • + *
+ * Both emit the same {@code } XHTML. Wrapper hyperlinks take + * precedence — run properties cannot override an active wrapper. + */ +class FormattingTagManager { + + private final XHTMLContentHandler xhtml; + + // Outermost to innermost: hyperlink > bold > italic > strike > underline + private String currentHyperlink = null; + private boolean wrapperHyperlinkActive = false; + private boolean isBold = false; + private boolean isItalics = false; + private boolean isStrikeThrough = false; + private boolean isUnderline = false; + + FormattingTagManager(XHTMLContentHandler xhtml) { + this.xhtml = xhtml; + } + + /** + * Opens a wrapper-style hyperlink (DOCX {@code } or field-code). + * Closes any open formatting tags first to maintain nesting. + * No-op if url is null. + */ + void openHyperlink(String url) throws SAXException { + if (url == null) { + return; + } + closeFormattingTags(); + if (currentHyperlink != null) { + xhtml.endElement("a"); + } + xhtml.startElement("a", "href", url); + currentHyperlink = url; + wrapperHyperlinkActive = true; + } + + /** + * Closes the active wrapper-style hyperlink. No-op if none was opened. + */ + void closeHyperlink() throws SAXException { + if (currentHyperlink != null && wrapperHyperlinkActive) { + closeFormattingTags(); + xhtml.endElement("a"); + currentHyperlink = null; + wrapperHyperlinkActive = false; + } + } + + /** + * Returns true if any hyperlink (wrapper or run-property) is currently open. + */ + boolean isHyperlinkActive() { + return currentHyperlink != null; + } + + /** + * Reconciles the current formatting state with the given run properties, + * opening and closing XHTML tags as needed to maintain proper nesting. + */ + void applyFormatting(RunProperties runProperties) throws SAXException { + // Run-property hyperlinks only when no wrapper is active + if (!wrapperHyperlinkActive) { + String newHyperlink = runProperties.getHlinkClickUrl(); + if (!Objects.equals(newHyperlink, currentHyperlink)) { + closeFormattingTags(); + if (currentHyperlink != null) { + xhtml.endElement("a"); + } + if (newHyperlink != null) { + xhtml.startElement("a", "href", newHyperlink); + } + currentHyperlink = newHyperlink; + } + } + + if (runProperties.isBold() != isBold) { + // Close inner tags before flipping . Nesting is + // (outermost to innermost), so close innermost first: u, s, i. + if (isUnderline) { + xhtml.endElement("u"); + isUnderline = false; + } + if (isStrikeThrough) { + xhtml.endElement("s"); + isStrikeThrough = false; + } + if (isItalics) { + xhtml.endElement("i"); + isItalics = false; + } + if (runProperties.isBold()) { + xhtml.startElement("b"); + } else { + xhtml.endElement("b"); + } + isBold = runProperties.isBold(); + } + + if (runProperties.isItalics() != isItalics) { + // Close inner tags before flipping : u then s (u is innermost). + if (isUnderline) { + xhtml.endElement("u"); + isUnderline = false; + } + if (isStrikeThrough) { + xhtml.endElement("s"); + isStrikeThrough = false; + } + if (runProperties.isItalics()) { + xhtml.startElement("i"); + } else { + xhtml.endElement("i"); + } + isItalics = runProperties.isItalics(); + } + + if (runProperties.isStrikeThrough() != isStrikeThrough) { + if (isUnderline) { + xhtml.endElement("u"); + isUnderline = false; + } + if (runProperties.isStrikeThrough()) { + xhtml.startElement("s"); + } else { + xhtml.endElement("s"); + } + isStrikeThrough = runProperties.isStrikeThrough(); + } + + boolean runIsUnderlined = runProperties.getUnderline() != UnderlinePatterns.NONE; + if (runIsUnderlined != isUnderline) { + if (runIsUnderlined) { + xhtml.startElement("u"); + } else { + xhtml.endElement("u"); + } + isUnderline = runIsUnderlined; + } + } + + /** + * Closes all currently open tags in proper nesting order. + */ + void closeAll() throws SAXException { + closeFormattingTags(); + if (currentHyperlink != null) { + xhtml.endElement("a"); + currentHyperlink = null; + wrapperHyperlinkActive = false; + } + } + + private void closeFormattingTags() throws SAXException { + if (isUnderline) { + xhtml.endElement("u"); + isUnderline = false; + } + if (isStrikeThrough) { + xhtml.endElement("s"); + isStrikeThrough = false; + } + if (isItalics) { + xhtml.endElement("i"); + isItalics = false; + } + if (isBold) { + xhtml.endElement("b"); + isBold = false; + } + } +} 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/MetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/MetadataExtractor.java index 0a4426cc5fb..8298e84095e 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/MetadataExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/MetadataExtractor.java @@ -87,6 +87,15 @@ public MetadataExtractor(POIXMLTextExtractor extractor) { this.extractor = extractor; } + /** + * For subclasses (e.g. {@link SAXBasedMetadataExtractor}) that read metadata directly from + * the OPC package rather than through a {@link POIXMLTextExtractor}. Such subclasses must + * override {@link #extract(Metadata)}; the extractor-based path here is never invoked for them. + */ + protected MetadataExtractor() { + this.extractor = null; + } + public void extract(Metadata metadata) throws TikaException { if (extractor.getDocument() != null || ((extractor instanceof XSSFEventBasedExcelExtractor || 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/OOXMLExtractorFactory.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLExtractorFactory.java index 7e1e3c0fc6d..4ddccbe1582 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLExtractorFactory.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLExtractorFactory.java @@ -21,6 +21,7 @@ import java.io.InputStream; import java.nio.file.Files; import java.util.Locale; +import java.util.Set; import org.apache.poi.extractor.ExtractorFactory; import org.apache.poi.ooxml.POIXMLDocument; @@ -182,6 +183,10 @@ public static void parse(InputStream stream, ContentHandler baseHandler, Metadat XSLFEventBasedPowerPointExtractor.class.getCanonicalName()); } else if (poiExtractor instanceof XPSTextExtractor) { extractor = new XPSExtractorDecorator(context, poiExtractor); + } else if (isVisioType(type)) { + //SAX-based .vsdx extractor; the XDGF-based poiExtractor is used only for its + //OPCPackage (VSDXExtractorDecorator reads the package directly) + extractor = new VSDXExtractorDecorator(context, poiExtractor); } else if (document == null) { throw new TikaException( "Expecting UserModel based POI OOXML extractor with a document, but none" + @@ -295,5 +300,17 @@ private static POIXMLTextExtractor tryXSLF(OPCPackage pkg, boolean eventBased) return null; } + private static final Set VISIO_SUBTYPES = Set.of( + "vnd.ms-visio.drawing", + "vnd.ms-visio.drawing.macroenabled.12", + "vnd.ms-visio.stencil", + "vnd.ms-visio.stencil.macroenabled.12", + "vnd.ms-visio.template", + "vnd.ms-visio.template.macroenabled.12" + ); + + private static boolean isVisioType(MediaType type) { + return type != null && VISIO_SUBTYPES.contains(type.getSubtype()); + } } 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/OOXMLInlineBodyPartMap.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLInlineBodyPartMap.java new file mode 100644 index 00000000000..0738e9939bb --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLInlineBodyPartMap.java @@ -0,0 +1,82 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.util.Collections; +import java.util.Map; + +/** + * Holds pre-parsed XML content fragments for OOXML document parts that are + * referenced inline from the main document body. Each map stores + * ID → raw XML bytes for a specific part type. + *

+ * Used for footnotes, endnotes, and comments so that their content can be + * inlined at the point of reference rather than dumped at the end. + */ +class OOXMLInlineBodyPartMap { + + static final OOXMLInlineBodyPartMap EMPTY = new OOXMLInlineBodyPartMap( + Collections.emptyMap(), Collections.emptyMap(), Collections.emptyMap(), + Collections.emptyMap()); + + private final Map footnotes; + private final Map endnotes; + private final Map comments; + private final Map linkedRelationships; + + OOXMLInlineBodyPartMap(Map footnotes, + Map endnotes, + Map comments, + Map linkedRelationships) { + this.footnotes = footnotes; + this.endnotes = endnotes; + this.comments = comments; + this.linkedRelationships = linkedRelationships; + } + + Map getLinkedRelationships() { + return linkedRelationships; + } + + byte[] getFootnote(String id) { + return footnotes.get(id); + } + + byte[] getEndnote(String id) { + return endnotes.get(id); + } + + byte[] getComment(String id) { + return comments.get(id); + } + + boolean hasFootnotes() { + return !footnotes.isEmpty(); + } + + boolean hasEndnotes() { + return !endnotes.isEmpty(); + } + + boolean hasComments() { + return !comments.isEmpty(); + } + + Iterable> getCommentEntries() { + return comments.entrySet(); + } +} 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/OOXMLPartContentCollector.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPartContentCollector.java new file mode 100644 index 00000000000..6cece158e89 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPartContentCollector.java @@ -0,0 +1,227 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.ByteArrayOutputStream; +import java.nio.charset.StandardCharsets; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; + +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +/** + * Generic SAX handler that collects raw XML content by ID from OOXML part files. + * Works with any part that contains wrapper elements with {@code w:id} attributes + * containing body content (paragraphs, tables, formatting, etc.). + *

+ * Used for: + *

    + *
  • footnotes.xml — wrapper element "footnote"
  • + *
  • endnotes.xml — wrapper element "endnote"
  • + *
  • comments.xml — wrapper element "comment"
  • + *
+ *

+ * IDs "0" and "-1" are skipped (these are separator/continuation elements in + * footnotes/endnotes). + */ +class OOXMLPartContentCollector extends DefaultHandler { + + private static final String W_NS = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + + private final Set wrapperElementNames; + private final Set skipIds; + private final Map contentMap = new HashMap<>(); + private final Map namespaceMappings = new HashMap<>(); + + private String currentId = null; + private ByteArrayOutputStream buffer = null; + private int depth = 0; + + /** + * @param wrapperElementNames local names of wrapper elements to collect + * (e.g., "footnote", "endnote", "comment") + */ + OOXMLPartContentCollector(Set wrapperElementNames) { + this(wrapperElementNames, Set.of("0", "-1")); + } + + /** + * @param wrapperElementNames local names of wrapper elements to collect + * @param skipIds IDs to skip (e.g., "0", "-1" for footnote + * separator/continuation elements) + */ + OOXMLPartContentCollector(Set wrapperElementNames, Set skipIds) { + this.wrapperElementNames = wrapperElementNames; + this.skipIds = skipIds; + } + + @Override + public void startPrefixMapping(String prefix, String uri) { + namespaceMappings.put(prefix, uri); + } + + Map getContentMap() { + return contentMap; + } + + @Override + public void startElement(String uri, String localName, String qName, + Attributes atts) throws SAXException { + if (currentId != null) { + depth++; + appendStartTag(localName, qName, atts); + return; + } + + if (wrapperElementNames.contains(localName)) { + String id = atts.getValue(W_NS, "id"); + if (id != null && !skipIds.contains(id)) { + currentId = id; + buffer = new ByteArrayOutputStream(); + // Don't write wrapper open tag yet — inline xmlns declarations + // (e.g., xmlns:a on nested elements) haven't been captured via + // startPrefixMapping. Defer to endElement when all are known. + depth = 0; + } + } + } + + @Override + public void endElement(String uri, String localName, String qName) + throws SAXException { + if (currentId == null) { + return; + } + + if (depth == 0) { + // Build the wrapper now — all startPrefixMapping calls from nested + // elements have been captured, so inline xmlns declarations are included. + byte[] wrapperOpen = buildWrapperOpenTag().getBytes(StandardCharsets.UTF_8); + byte[] content = buffer.toByteArray(); + ByteArrayOutputStream combined = + new ByteArrayOutputStream(wrapperOpen.length + content.length + 16); + combined.write(wrapperOpen, 0, wrapperOpen.length); + combined.write(content, 0, content.length); + writeString(combined, ""); + contentMap.put(currentId, combined.toByteArray()); + currentId = null; + buffer = null; + return; + } + + depth--; + if (qName != null && !qName.isEmpty()) { + writeString(""); + } else { + writeString(""); + } + } + + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + if (currentId != null) { + writeString(escape(new String(ch, start, length))); + } + } + + private String buildWrapperOpenTag() { + StringBuilder sb = new StringBuilder(" entry : namespaceMappings.entrySet()) { + String prefix = entry.getKey(); + String nsUri = entry.getValue(); + if (prefix == null || prefix.isEmpty()) { + sb.append(" xmlns=\"").append(escape(nsUri)).append("\""); + } else { + sb.append(" xmlns:").append(prefix).append("=\"") + .append(escape(nsUri)).append("\""); + } + } + // ensure w namespace is present + if (!namespaceMappings.containsKey("w")) { + sb.append(" xmlns:w=\"").append(W_NS).append("\""); + } + sb.append(">"); + return sb.toString(); + } + + private void appendStartTag(String localName, String qName, Attributes atts) { + String tagName = (qName != null && !qName.isEmpty()) ? qName : localName; + StringBuilder sb = new StringBuilder(); + sb.append('<').append(tagName); + for (int i = 0; i < atts.getLength(); i++) { + String attName = atts.getQName(i); + if (attName == null || attName.isEmpty()) { + attName = atts.getLocalName(i); + } + sb.append(' ').append(attName).append("=\""); + sb.append(escape(atts.getValue(i))); + sb.append('"'); + } + sb.append('>'); + writeString(sb.toString()); + } + + private void writeString(String s) { + writeString(buffer, s); + } + + private static void writeString(ByteArrayOutputStream target, String s) { + byte[] bytes = s.getBytes(StandardCharsets.UTF_8); + target.write(bytes, 0, bytes.length); + } + + static String escape(String s) { + if (s == null) { + return ""; + } + StringBuilder sb = null; + for (int i = 0; i < s.length(); i++) { + char c = s.charAt(i); + String replacement = null; + switch (c) { + case '&': + replacement = "&"; + break; + case '<': + replacement = "<"; + break; + case '>': + replacement = ">"; + break; + case '"': + replacement = """; + break; + default: + if (sb != null) { + sb.append(c); + } + continue; + } + if (sb == null) { + sb = new StringBuilder(s.length() + 16); + sb.append(s, 0, i); + } + sb.append(replacement); + } + return sb != null ? sb.toString() : s; + } +} 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/OOXMLPictureTracker.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPictureTracker.java new file mode 100644 index 00000000000..f6a98ecacf7 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLPictureTracker.java @@ -0,0 +1,99 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.util.Map; + +import org.xml.sax.SAXException; + +/** + * Tracks the lifecycle of picture elements (PIC, PICT, BLIP, IMAGEDATA, cNvPr) + * during OOXML SAX parsing and emits embeddedPicRef callbacks when the picture + * scope closes. + *

+ * This class has no Tika dependencies and could be contributed to POI. + */ +class OOXMLPictureTracker { + + private final Map linkedRelationships; + private final XWPFBodyContentsHandler bodyContentsHandler; + + private boolean inPic = false; + private boolean inPict = false; + private String picDescription = null; + private String picRId = null; + private String lastImageDataRId = null; + + OOXMLPictureTracker(Map linkedRelationships, + XWPFBodyContentsHandler bodyContentsHandler) { + this.linkedRelationships = linkedRelationships; + this.bodyContentsHandler = bodyContentsHandler; + } + + boolean isInPic() { + return inPic; + } + + boolean isInPict() { + return inPict; + } + + void startPic() { + inPic = true; + } + + void startPict() { + inPict = true; + } + + void setBlipRId(String rId) { + picRId = rId; + } + + void setDescription(String description) { + picDescription = description; + } + + void setImageDataRId(String rId) { + picRId = rId; + lastImageDataRId = rId; + } + + String getImageDataRId() { + return lastImageDataRId; + } + + void setImageDataDescription(String description) { + picDescription = description; + } + + /** + * Called at end of PIC or PICT element. Resolves the filename from + * the relationship map and emits the embeddedPicRef callback. + */ + void endPicture() throws SAXException { + String picFileName = null; + if (picRId != null) { + picFileName = linkedRelationships.get(picRId); + } + bodyContentsHandler.embeddedPicRef(picFileName, picDescription); + picDescription = null; + picRId = null; + inPic = false; + inPict = false; + } +} 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/OOXMLTikaBodyPartHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java index 4bc445fb5ea..0366ab96700 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java @@ -17,20 +17,29 @@ package org.apache.tika.parser.microsoft.ooxml; +import java.io.ByteArrayInputStream; +import java.io.IOException; import java.math.BigInteger; import java.util.Date; +import java.util.HashMap; +import java.util.Map; -import org.apache.poi.xwpf.usermodel.UnderlinePatterns; import org.xml.sax.SAXException; import org.xml.sax.helpers.AttributesImpl; +import org.apache.tika.exception.TikaException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Office; +import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.microsoft.OfficeParserConfig; import org.apache.tika.parser.microsoft.WordExtractor; import org.apache.tika.parser.microsoft.ooxml.xwpf.XWPFStylesShim; +import org.apache.tika.sax.EmbeddedContentHandler; import org.apache.tika.sax.XHTMLContentHandler; +import org.apache.tika.utils.XMLReaderUtils; public class OOXMLTikaBodyPartHandler - implements OOXMLWordAndPowerPointTextHandler.XWPFBodyContentsHandler { + implements XWPFBodyContentsHandler { private static final String P = "p"; @@ -41,15 +50,12 @@ public class OOXMLTikaBodyPartHandler private final boolean includeDeletedText; private final boolean includeMoveFromText; private final XWPFStylesShim styles; + private final Metadata metadata; private int pDepth = 0; //paragraph depth private int tableDepth = 0;//table depth private int sdtDepth = 0;// - private boolean isItalics = false; - private boolean isBold = false; - private boolean isUnderline = false; - private boolean isStrikeThrough = false; - private boolean wroteHyperlinkStart = false; + private FormattingTagManager formattingTags; //TODO: fix this //pWithinCell should be an array/stack of given cell depths @@ -58,13 +64,33 @@ public class OOXMLTikaBodyPartHandler //

... private int tableCellDepth = 0; private int pWithinCell = 0; + // Stack of structural elements (paragraphs, tables, rows, cells) this + // handler has emitted to the xhtml stream and not yet closed. Used by + // closeAnyPending() to drain the stack in reverse order so the captured + // XHTML stays balanced when a caller's parseSAX call throws part-way. + // Tags emitted by FormattingTagManager (////
) are not + // tracked here -- closeAnyPending closes them via formattingTags.closeAll() + // before draining this stack. + private final java.util.Deque openStructuralTags = new java.util.ArrayDeque<>(); //will need to replace this with a stack //if we're marking more that the first level

element private String paragraphTag = null; + private OOXMLInlineBodyPartMap inlinePartMap = OOXMLInlineBodyPartMap.EMPTY; + private ParseContext parseContext = null; + private final java.util.List pendingCommentIds = new java.util.ArrayList<>(); + private final java.util.Set emittedCommentIds = new java.util.HashSet<>(); + private final Map embeddedPartMetadataMap = new HashMap<>(); + public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml) { + this(xhtml, null); + } + + public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, Metadata metadata) { this.xhtml = xhtml; + this.metadata = metadata; + this.formattingTags = new FormattingTagManager(xhtml); this.styles = XWPFStylesShim.EMPTY_STYLES; this.listManager = XWPFListManager.EMPTY_LIST; this.includeDeletedText = false; @@ -72,98 +98,48 @@ public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml) { } public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, XWPFStylesShim styles, - XWPFListManager listManager, OfficeParserConfig parserConfig) { + XWPFListManager listManager, + OfficeParserConfig parserConfig) { + this(xhtml, styles, listManager, parserConfig, null); + } + + public OOXMLTikaBodyPartHandler(XHTMLContentHandler xhtml, XWPFStylesShim styles, + XWPFListManager listManager, + OfficeParserConfig parserConfig, Metadata metadata) { this.xhtml = xhtml; + this.metadata = metadata; + this.formattingTags = new FormattingTagManager(xhtml); this.styles = styles; this.listManager = listManager; this.includeDeletedText = parserConfig.isIncludeDeletedContent(); this.includeMoveFromText = parserConfig.isIncludeMoveFromContent(); } + /** + * Sets pre-parsed inline body part content (footnotes, endnotes, comments) + * so that references encountered during main document parsing can be + * resolved inline. + */ + public void setInlineBodyPartMap(OOXMLInlineBodyPartMap inlinePartMap, + ParseContext parseContext) { + this.inlinePartMap = inlinePartMap != null ? inlinePartMap : OOXMLInlineBodyPartMap.EMPTY; + this.parseContext = parseContext; + } + @Override public void run(RunProperties runProperties, String contents) throws SAXException { - - // True if we are currently in the named style tag: - if (runProperties.isBold() != isBold) { - if (isStrikeThrough) { - xhtml.endElement("strike"); - isStrikeThrough = false; - } - if (isUnderline) { - xhtml.endElement("u"); - isUnderline = false; - } - if (isItalics) { - xhtml.endElement("i"); - isItalics = false; - } - if (runProperties.isBold()) { - xhtml.startElement("b"); - } else { - xhtml.endElement("b"); - } - isBold = runProperties.isBold(); - } - - if (runProperties.isItalics() != isItalics) { - if (isStrikeThrough) { - xhtml.endElement("strike"); - isStrikeThrough = false; - } - if (isUnderline) { - xhtml.endElement("u"); - isUnderline = false; - } - if (runProperties.isItalics()) { - xhtml.startElement("i"); - } else { - xhtml.endElement("i"); - } - isItalics = runProperties.isItalics(); - } - - if (runProperties.isStrikeThrough() != isStrikeThrough) { - if (isUnderline) { - xhtml.endElement("u"); - isUnderline = false; - } - if (runProperties.isStrikeThrough()) { - xhtml.startElement("strike"); - } else { - xhtml.endElement("strike"); - } - isStrikeThrough = runProperties.isStrikeThrough(); - } - - boolean runIsUnderlined = runProperties.getUnderline() != UnderlinePatterns.NONE; - if (runIsUnderlined != isUnderline) { - if (runIsUnderlined) { - xhtml.startElement("u"); - } else { - xhtml.endElement("u"); - } - isUnderline = runIsUnderlined; - } - + formattingTags.applyFormatting(runProperties); xhtml.characters(contents); - } @Override public void hyperlinkStart(String link) throws SAXException { - if (link != null) { - xhtml.startElement("a", "href", link); - wroteHyperlinkStart = true; - } + formattingTags.openHyperlink(link); } @Override public void hyperlinkEnd() throws SAXException { - if (wroteHyperlinkStart) { - closeStyleTags(); - wroteHyperlinkStart = false; - xhtml.endElement("a"); - } + formattingTags.closeHyperlink(); } @Override @@ -195,6 +171,7 @@ public void startParagraph(ParagraphProperties paragraphProperties) throws SAXEx } else { xhtml.startElement(paragraphTag, "class", styleClass); } + openStructuralTags.push(paragraphTag); } writeParagraphNumber(paragraphProperties.getNumId(), paragraphProperties.getIlvl(), @@ -205,25 +182,101 @@ public void startParagraph(ParagraphProperties paragraphProperties) throws SAXEx @Override public void endParagraph() throws SAXException { - closeStyleTags(); + formattingTags.closeAll(); if (pDepth == 1 && tableDepth == 0) { xhtml.endElement(paragraphTag); + popExpected(paragraphTag); } else if (tableCellDepth > 0 && pWithinCell > 0) { xhtml.characters(NEWLINE, 0, 1); } else if (tableCellDepth == 0) { xhtml.characters(NEWLINE, 0, 1); } + // Emit any pending comment content after the paragraph closes + // (matching the DOM parser's behavior of appending comments after paragraphs) + emitPendingComments(); + if (tableCellDepth > 0) { pWithinCell++; } pDepth--; } + private void emitPendingComments() throws SAXException { + if (pendingCommentIds.isEmpty()) { + return; + } + for (String id : pendingCommentIds) { + byte[] xml = inlinePartMap.getComment(id); + if (xml != null) { + inlineNoteContent(xml, "comment"); + emittedCommentIds.add(id); + } + } + pendingCommentIds.clear(); + } + + /** + * Returns the set of comment IDs that were inlined during parsing. + * Used by the decorator to skip these when dumping remaining comments. + */ + public java.util.Set getEmittedCommentIds() { + return emittedCommentIds; + } + + /** + * Closes any XHTML elements this handler opened but didn't get a chance to + * close, in the proper nesting order. Intended ONLY for the catch arm of a + * caller that swallowed a {@link SAXException} from the inner SAX parser; + * the normal happy-path flow keeps the trackers in sync via endParagraph + * / endTableCell / endTableRow / endTable / FormattingTagManager.closeAll. + * Without this, swallowed exceptions leave dangling {@code

}, {@code }, + * {@code }, {@code }, or formatting tags on the wire that + * collide with the outer {@code }. + */ + public void closeAnyPending() throws SAXException { + formattingTags.closeAll(); + // Drain the structural-element stack in reverse open order. This + // handles nested tables correctly (multiple cells/rows/tables + // interleaved), unlike per-element counters which lose nesting info. + while (!openStructuralTags.isEmpty()) { + String tag = openStructuralTags.pop(); + xhtml.endElement(tag); + } + // Reset internal depth/state so subsequent emits start clean. + tableDepth = 0; + tableCellDepth = 0; + pDepth = 0; + pWithinCell = 0; + } + + /** + * Pops {@code openStructuralTags} expecting the given tag on top. + * If the stack is empty or the top differs, this is a no-op rather than a + * throw -- the stack is best-effort tracking for closeAnyPending(), and + * the existing happy-path tests (which don't trigger closeAnyPending) must + * not be perturbed by stack tracking bugs. + */ + private void popExpected(String tag) { + if (!openStructuralTags.isEmpty() && tag.equals(openStructuralTags.peek())) { + openStructuralTags.pop(); + } + } + @Override public void startTable() throws SAXException { - + // A can appear nested inside an outer -- corrupt-ish but + // present in the corpus (e.g., ... + // ...). At that point a run-level //// may be on + // the SAX stack just above where the
is about to land. When a + // later paragraph inside a cell ends, formattingTags.closeAll() tries + // to emit for the outer-paragraph state, but /
is topmost -- + // strict validator rejects the mismatch. Close pending formatting now + // so the table opens at a clean layer and the outer style is forgotten. + // Mirrors startSDT()'s same-shape guard. + formattingTags.closeAll(); xhtml.startElement("table"); + openStructuralTags.push("table"); tableDepth++; } @@ -232,6 +285,7 @@ public void startTable() throws SAXException { public void endTable() throws SAXException { xhtml.endElement("table"); + popExpected("table"); tableDepth--; } @@ -239,29 +293,33 @@ public void endTable() throws SAXException { @Override public void startTableRow() throws SAXException { xhtml.startElement("tr"); + openStructuralTags.push("tr"); } @Override public void endTableRow() throws SAXException { xhtml.endElement("tr"); + popExpected("tr"); } @Override public void startTableCell() throws SAXException { xhtml.startElement("td"); + openStructuralTags.push("td"); tableCellDepth++; } @Override public void endTableCell() throws SAXException { xhtml.endElement("td"); + popExpected("td"); pWithinCell = 0; tableCellDepth--; } @Override public void startSDT() throws SAXException { - closeStyleTags(); + formattingTags.closeAll(); sdtDepth++; } @@ -272,7 +330,7 @@ public void endSDT() { @Override public void startEditedSection(String editor, Date date, - OOXMLWordAndPowerPointTextHandler.EditType editType) { + EditType editType) { //no-op } @@ -288,7 +346,13 @@ public boolean isIncludeDeletedText() { @Override public void footnoteReference(String id) throws SAXException { - if (id != null) { + if (id == null) { + return; + } + byte[] xml = inlinePartMap.getFootnote(id); + if (xml != null) { + inlineNoteContent(xml, "footnote"); + } else { xhtml.characters("["); xhtml.characters(id); xhtml.characters("]"); @@ -297,23 +361,70 @@ public void footnoteReference(String id) throws SAXException { @Override public void endnoteReference(String id) throws SAXException { - if (id != null) { + if (id == null) { + return; + } + byte[] xml = inlinePartMap.getEndnote(id); + if (xml != null) { + inlineNoteContent(xml, "endnote"); + } else { xhtml.characters("["); xhtml.characters(id); xhtml.characters("]"); } } + @Override + public void commentReference(String id) throws SAXException { + if (id != null) { + pendingCommentIds.add(id); + } + } + + private void inlineNoteContent(byte[] xml, String cssClass) throws SAXException { + // Use the inline part map's relationship map which includes relationships + // from the footnote/endnote parts (needed for picture resolution) + Map noteRelationships = inlinePartMap.getLinkedRelationships(); + xhtml.startElement("div", "class", cssClass); + // Track the inner handler so we can call its closeAnyPending() if + // the inline-note parseSAX aborts mid-element. Without the drain + // the surrounding mismatches whatever the inner handler + // left on the SAX stack (

/

/etc.) and StrictXHTMLValidator + // propagates a misleading error. + OOXMLTikaBodyPartHandler innerHandler = new OOXMLTikaBodyPartHandler(xhtml); + try { + XMLReaderUtils.parseSAX(new ByteArrayInputStream(xml), + new EmbeddedContentHandler( + new OOXMLWordAndPowerPointTextHandler( + innerHandler, + noteRelationships)), + parseContext); + } catch (TikaException | IOException | SAXException e) { + innerHandler.closeAnyPending(); + xhtml.characters("[" + cssClass + " parse error]"); + } + xhtml.endElement("div"); + } + @Override public boolean isIncludeMoveFromText() { return includeMoveFromText; } @Override - public void embeddedOLERef(String relId) throws SAXException { + public void embeddedOLERef(String relId, String progId, String emfImageRId) + throws SAXException { if (relId == null) { return; } + if ((progId != null && !progId.isEmpty()) || + (emfImageRId != null && !emfImageRId.isEmpty())) { + EmbeddedPartMetadata epm = new EmbeddedPartMetadata(emfImageRId); + if (progId != null && !progId.isEmpty()) { + epm.setProgId(progId); + } + embeddedPartMetadataMap.put(relId, epm); + } AttributesImpl attributes = new AttributesImpl(); attributes.addAttribute("", "class", "class", "CDATA", "embedded"); attributes.addAttribute("", "id", "id", "CDATA", relId); @@ -321,11 +432,18 @@ public void embeddedOLERef(String relId) throws SAXException { xhtml.endElement("div"); } + public Map getEmbeddedPartMetadataMap() { + return embeddedPartMetadataMap; + } + @Override public void linkedOLERef(String relId) throws SAXException { if (relId == null) { return; } + if (metadata != null) { + metadata.set(Office.HAS_LINKED_OLE_OBJECTS, true); + } // Emit as an external reference anchor - linked OLE objects reference external files AttributesImpl attributes = new AttributesImpl(); attributes.addAttribute("", "class", "class", "CDATA", "external-ref-linkedOle"); @@ -351,11 +469,28 @@ public void embeddedPicRef(String picFileName, String picDescription) throws SAX } + @Override + public void fieldCodeHyperlinkStart(String link) throws SAXException { + if (metadata != null) { + metadata.set(Office.HAS_FIELD_HYPERLINKS, true); + } + hyperlinkStart(link); + } + @Override public void externalRef(String fieldType, String url) throws SAXException { if (url == null || url.isEmpty()) { return; } + if (metadata != null) { + if ("hlinkHover".equals(fieldType)) { + metadata.set(Office.HAS_HOVER_HYPERLINKS, true); + } else if ("vml-shape-href".equals(fieldType)) { + metadata.set(Office.HAS_VML_HYPERLINKS, true); + } else { + metadata.set(Office.HAS_FIELD_HYPERLINKS, true); + } + } AttributesImpl attr = new AttributesImpl(); attr.addAttribute("", "class", "class", "CDATA", "external-ref-" + fieldType); attr.addAttribute("", "href", "href", "CDATA", url); @@ -366,7 +501,7 @@ public void externalRef(String fieldType, String url) throws SAXException { @Override public void startBookmark(String id, String name) throws SAXException { //skip bookmarks within hyperlinks - if (name != null && !wroteHyperlinkStart) { + if (name != null && !formattingTags.isHyperlinkActive()) { xhtml.startElement("a", "name", name); xhtml.endElement("a"); } @@ -377,29 +512,6 @@ public void endBookmark(String id) { //no-op } - private void closeStyleTags() throws SAXException { - - if (isStrikeThrough) { - xhtml.endElement("strike"); - isStrikeThrough = false; - } - - if (isUnderline) { - xhtml.endElement("u"); - isUnderline = false; - } - - if (isItalics) { - xhtml.endElement("i"); - isItalics = false; - } - - if (isBold) { - xhtml.endElement("b"); - isBold = false; - } - } - private void writeParagraphNumber(int numId, int ilvl, XWPFListManager listManager, XHTMLContentHandler xhtml) throws SAXException { 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/OOXMLWordAndPowerPointTextHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java index 9e7110f7734..4f591ae4c63 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLWordAndPowerPointTextHandler.java @@ -19,16 +19,12 @@ import java.util.Date; import java.util.Map; -import java.util.regex.Matcher; -import java.util.regex.Pattern; import org.apache.poi.xwpf.usermodel.UnderlinePatterns; import org.xml.sax.Attributes; import org.xml.sax.SAXException; import org.xml.sax.helpers.DefaultHandler; -import org.apache.tika.metadata.Metadata; -import org.apache.tika.metadata.Office; import org.apache.tika.utils.DateUtils; /** @@ -73,6 +69,8 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { private final static String STRIKE = "strike"; private final static String NUM_PR = "numPr"; private final static String BR = "br"; + private final static String NO_BREAK_HYPHEN = "noBreakHyphen"; + private final static String SOFT_HYPHEN = "softHyphen"; private final static String HYPERLINK = "hyperlink"; private final static String HLINK_CLICK = "hlinkClick"; //pptx hlink private final static String TBL = "tbl"; @@ -88,6 +86,9 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { private final static String RUBY = "ruby"; //phonetic section private final static String RT = "rt"; //phonetic run private static final String VAL = "val"; + private static final String SLIDE = "sld"; + private static final String SHOW = "show"; + private static final String TIMING = "timing"; // p:timing — slide animations private final static String MC_NS = "http://schemas.openxmlformats.org/markup-compatibility/2006"; private final static String O_NS = "urn:schemas-microsoft-com:office:office"; @@ -109,7 +110,9 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { private final static String MOVE_FROM = "moveFrom"; private final static String MOVE_TO = "moveTo"; private final static String ENDNOTE_REFERENCE = "endnoteReference"; + private final static String COMMENT_REFERENCE = "commentReference"; private static final String TEXTBOX = "textbox"; + private static final String TXBX = "txbx"; // DrawingML text box (wps:txbx in mc:Choice) private final static String FLD_CHAR = "fldChar"; private final static String INSTR_TEXT = "instrText"; private final static String FLD_CHAR_TYPE = "fldCharType"; @@ -120,24 +123,14 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { private final static String SHAPE = "shape"; private final static String HREF = "href"; - // Patterns for extracting URLs from field codes - private static final Pattern HYPERLINK_PATTERN = - Pattern.compile("HYPERLINK\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE); - private static final Pattern INCLUDEPICTURE_PATTERN = - Pattern.compile("INCLUDEPICTURE\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE); - private static final Pattern INCLUDETEXT_PATTERN = - Pattern.compile("INCLUDETEXT\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE); - private static final Pattern IMPORT_PATTERN = - Pattern.compile("IMPORT\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE); - private static final Pattern LINK_PATTERN = - Pattern.compile("LINK\\s{1,100}[\\w.]{1,50}\\s{1,100}\"([^\"]{1,10000})\"", Pattern.CASE_INSENSITIVE); private final XWPFBodyContentsHandler bodyContentsHandler; private final Map linkedRelationships; + private final OOXMLPictureTracker pictureTracker; private final RunProperties currRunProperties = new RunProperties(); private final ParagraphProperties currPProperties = new ParagraphProperties(); private final boolean includeTextBox; private final boolean concatenatePhoneticRuns; - private final Metadata metadata; + private final boolean preferACChoice; private final StringBuilder runBuffer = new StringBuilder(); private final StringBuilder rubyBuffer = new StringBuilder(); private boolean inR = false; @@ -146,11 +139,6 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { private boolean inRPr = false; private boolean inNumPr = false; private boolean inRt = false; - private boolean inPic = false; - private boolean inPict = false; - private String picDescription = null; - private String picRId = null; - private String picFilename = null; //mechanism used to determine when to //signal the start of the p, and still //handle p with pPr and those without @@ -158,45 +146,60 @@ public class OOXMLWordAndPowerPointTextHandler extends DefaultHandler { //have we signaled the start of a p? //pPr can happen multiple times within a p //

text

- private boolean pStarted = false; + // + //Stack rather than a single boolean: nested (e.g., inside + //) must not clobber the outer paragraph's "started" marker, + //or the outer will skip its endParagraph and leave

open. + private final java.util.Deque pStartedStack = new java.util.ArrayDeque<>(); //alternate content can be embedded in itself. //need to track depth. - //if in alternate, choose fallback, maybe make this configurable? + //preferACChoice controls which branch is processed: + // true -> process Choice, skip Fallback (richer content) + // false -> process Fallback, skip Choice (legacy behavior) private int inACChoiceDepth = 0; private int inACFallbackDepth = 0; private boolean inDelText = false; //buffers rt in ruby sections (see 17.3.3.25) - private boolean inHlinkClick = false; private boolean inTextBox = false; private boolean inV = false; //in c:v in chart file + // True when we're inside a that was a direct child of

(the first child). + // Only those pPr elements should trigger startParagraph on close. + // pPr elements nested inside other elements (e.g., inside ) + // must not be treated as paragraph-level properties. + private boolean inParagraphLevelPPr = false; // Field code tracking for instrText-based hyperlinks private boolean inField = false; private boolean inInstrText = false; private boolean inFieldHyperlink = false; private final StringBuilder instrTextBuffer = new StringBuilder(); - private OOXMLWordAndPowerPointTextHandler.EditType editType = - OOXMLWordAndPowerPointTextHandler.EditType.NONE; + private EditType editType = + EditType.NONE; private DateUtils dateUtils = new DateUtils(); + private boolean hiddenSlide = false; + private boolean hasAnimations = false; + public OOXMLWordAndPowerPointTextHandler(XWPFBodyContentsHandler bodyContentsHandler, Map hyperlinks) { - this(bodyContentsHandler, hyperlinks, true, true, null); + this(bodyContentsHandler, hyperlinks, true, true, true); } public OOXMLWordAndPowerPointTextHandler(XWPFBodyContentsHandler bodyContentsHandler, Map hyperlinks, boolean includeTextBox, boolean concatenatePhoneticRuns) { - this(bodyContentsHandler, hyperlinks, includeTextBox, concatenatePhoneticRuns, null); + this(bodyContentsHandler, hyperlinks, includeTextBox, concatenatePhoneticRuns, true); } public OOXMLWordAndPowerPointTextHandler(XWPFBodyContentsHandler bodyContentsHandler, Map hyperlinks, boolean includeTextBox, - boolean concatenatePhoneticRuns, Metadata metadata) { + boolean concatenatePhoneticRuns, + boolean preferACChoice) { this.bodyContentsHandler = bodyContentsHandler; this.linkedRelationships = hyperlinks; + this.pictureTracker = new OOXMLPictureTracker(hyperlinks, bodyContentsHandler); this.includeTextBox = includeTextBox; this.concatenatePhoneticRuns = concatenatePhoneticRuns; - this.metadata = metadata; + this.preferACChoice = preferACChoice; } @Override @@ -215,13 +218,31 @@ public void startPrefixMapping(String prefix, String uri) throws SAXException { public void endPrefixMapping(String prefix) throws SAXException { } + /** + * Returns true if content should be skipped due to AlternateContent handling. + * When preferACChoice is true, skip Fallback; when false, skip Choice. + */ + private boolean inSkippedAlternateContent() { + if (preferACChoice) { + return inACFallbackDepth > 0; + } else { + return inACChoiceDepth > 0; + } + } + @Override public void startElement(String uri, String localName, String qName, Attributes atts) throws SAXException { //TODO: checkBox, textBox, sym, headerReference, footerReference, commentRangeEnd - if (lastStartElementWasP && !PPR.equals(localName)) { + if (lastStartElementWasP && PPR.equals(localName)) { + // pPr is the first child of

— this is a paragraph-level pPr. + // Defer startParagraph until so properties (style, numbering) are set first. + inParagraphLevelPPr = true; + } else if (lastStartElementWasP) { + // First child of

is not pPr — start paragraph immediately with defaults. bodyContentsHandler.startParagraph(currPProperties); + markCurrentParagraphStarted(); } lastStartElementWasP = false; @@ -234,11 +255,11 @@ public void startElement(String uri, String localName, String qName, Attributes } } - if (inACChoiceDepth > 0) { + if (inSkippedAlternateContent()) { return; } - if (!includeTextBox && localName.equals(TEXTBOX)) { + if (!includeTextBox && (localName.equals(TEXTBOX) || localName.equals(TXBX))) { inTextBox = true; return; } @@ -255,9 +276,15 @@ public void startElement(String uri, String localName, String qName, Attributes runBuffer.append(TAB_CHAR); } else if (P.equals(localName)) { lastStartElementWasP = true; + // Push a fresh frame for this . A nested (e.g., inside + // ) must not share the outer paragraph's started-flag, + // or the outer 's endParagraph either fires twice (older bug) + // or gets skipped (after the pStarted guard fix), and the XHTML + //

/

stream desyncs either way. + pStartedStack.push(Boolean.FALSE); } else if (B.equals(localName)) { //TODO: add bCs if (inR && inRPr) { - currRunProperties.setBold(true); + currRunProperties.setBold(getOnOff(atts, true)); } } else if (TC.equals(localName)) { bodyContentsHandler.startTableCell(); @@ -267,11 +294,11 @@ public void startElement(String uri, String localName, String qName, Attributes } else if (I.equals(localName)) { //TODO: add iCs //rprs don't have to be inR; ignore those that aren't if (inR && inRPr) { - currRunProperties.setItalics(true); + currRunProperties.setItalics(getOnOff(atts, true)); } } else if (STRIKE.equals(localName)) { if (inR && inRPr) { - currRunProperties.setStrike(true); + currRunProperties.setStrike(getOnOff(atts, true)); } } else if (U.equals(localName)) { if (inR && inRPr) { @@ -291,6 +318,12 @@ public void startElement(String uri, String localName, String qName, Attributes } } else if (BR.equals(localName)) { runBuffer.append(NEWLINE); + } else if (NO_BREAK_HYPHEN.equals(localName)) { + // — emit U+2011 NON-BREAKING HYPHEN + runBuffer.append('\u2011'); + } else if (SOFT_HYPHEN.equals(localName)) { + // — emit U+00AD SOFT HYPHEN (invisible hyphenation hint) + runBuffer.append('\u00AD'); } else if (BOOKMARK_START.equals(localName)) { String name = atts.getValue(W_NS, "name"); String id = atts.getValue(W_NS, "id"); @@ -316,24 +349,30 @@ public void startElement(String uri, String localName, String qName, Attributes String hyperlink = null; if (hyperlinkId != null) { hyperlink = linkedRelationships.get(hyperlinkId); - bodyContentsHandler.hyperlinkStart(hyperlink); - inHlinkClick = true; + if (inR) { + // hlinkClick inside a run — treat as run property. + // FormattingTagManager opens/closes with the run lifecycle. + currRunProperties.setHlinkClickUrl(hyperlink); + } else if (hyperlink != null) { + // hlinkClick on a shape/picture (not in a run) — emit as self-closing ref + bodyContentsHandler.externalRef("hlinkClick", hyperlink); + } } } else if (TBL.equals(localName)) { bodyContentsHandler.startTable(); } else if (BLIP.equals(localName)) { //check for DRAWING_NS - picRId = atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "embed"); + pictureTracker.setBlipRId(atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "embed")); } else if ("cNvPr".equals(localName)) { //check for PIC_NS? - picDescription = atts.getValue("", "descr"); + pictureTracker.setDescription(atts.getValue("", "descr")); } else if (PIC.equals(localName)) { - inPic = true; //check for PIC_NS? + pictureTracker.startPic(); //check for PIC_NS? } //TODO: add sdt, sdtPr, sdtContent goes here statistically else if (FOOTNOTE_REFERENCE.equals(localName)) { String id = atts.getValue(W_NS, "id"); bodyContentsHandler.footnoteReference(id); } else if (IMAGEDATA.equals(localName)) { - picRId = atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "id"); - picDescription = atts.getValue(O_NS, "title"); + pictureTracker.setImageDataRId(atts.getValue(OFFICE_DOC_RELATIONSHIP_NS, "id")); + pictureTracker.setImageDataDescription(atts.getValue(O_NS, "title")); } else if (INS.equals(localName)) { startEditedSection(editType.INSERT, atts); } else if (DEL_TEXT.equals(localName)) { @@ -347,7 +386,7 @@ else if (FOOTNOTE_REFERENCE.equals(localName)) { } else if (OLE_OBJECT.equals(localName)) { //check for O_NS? String type = null; String refId = null; - //TODO: clean this up and ...want to get ProgID? + String progId = null; for (int i = 0; i < atts.getLength(); i++) { String attLocalName = atts.getLocalName(i); String attValue = atts.getValue(i); @@ -356,26 +395,35 @@ else if (FOOTNOTE_REFERENCE.equals(localName)) { } else if (OFFICE_DOC_RELATIONSHIP_NS.equals(atts.getURI(i)) && attLocalName.equals("id")) { refId = attValue; + } else if ("ProgID".equals(attLocalName)) { + progId = attValue; } } if ("Embed".equals(type)) { - bodyContentsHandler.embeddedOLERef(refId); + String emfRId = pictureTracker.getImageDataRId(); + bodyContentsHandler.embeddedOLERef(refId, progId, emfRId); } else if ("Link".equals(type)) { - // Linked OLE object - references external file bodyContentsHandler.linkedOLERef(refId); - if (metadata != null) { - metadata.set(Office.HAS_LINKED_OLE_OBJECTS, true); - } } } else if (CR.equals(localName)) { runBuffer.append(NEWLINE); } else if (ENDNOTE_REFERENCE.equals(localName)) { String id = atts.getValue(W_NS, "id"); bodyContentsHandler.endnoteReference(id); + } else if (COMMENT_REFERENCE.equals(localName)) { + String id = atts.getValue(W_NS, "id"); + bodyContentsHandler.commentReference(id); } else if (V.equals(localName) && C_NS.equals(uri)) { // in value in a chart inV = true; } else if (RT.equals(localName)) { inRt = true; + } else if (SLIDE.equals(localName)) { + String val = atts.getValue("show"); + if ("0".equals(val) || "false".equals(val)) { + hiddenSlide = true; + } + } else if (TIMING.equals(localName)) { + hasAnimations = true; } else if (FLD_CHAR.equals(localName)) { String fldCharType = atts.getValue(W_NS, FLD_CHAR_TYPE); if ("begin".equals(fldCharType)) { @@ -383,22 +431,17 @@ else if (FOOTNOTE_REFERENCE.equals(localName)) { instrTextBuffer.setLength(0); } else if ("separate".equals(fldCharType)) { // Parse instrText for HYPERLINK - String url = parseHyperlinkFromInstrText(instrTextBuffer.toString()); + String url = FieldCodeParser.parseHyperlinkFromInstrText(instrTextBuffer.toString()); if (url != null) { - bodyContentsHandler.hyperlinkStart(url); + bodyContentsHandler.fieldCodeHyperlinkStart(url); inFieldHyperlink = true; - if (metadata != null) { - metadata.set(Office.HAS_FIELD_HYPERLINKS, true); - } } else { // Check for external reference fields (INCLUDEPICTURE, INCLUDETEXT, etc.) StringBuilder fieldType = new StringBuilder(); - String extUrl = parseExternalRefFromInstrText(instrTextBuffer.toString(), fieldType); + String extUrl = FieldCodeParser.parseExternalRefFromInstrText( + instrTextBuffer.toString(), fieldType); if (extUrl != null) { bodyContentsHandler.externalRef(fieldType.toString(), extUrl); - if (metadata != null) { - metadata.set(Office.HAS_FIELD_HYPERLINKS, true); - } } } } else if ("end".equals(fldCharType)) { @@ -418,9 +461,6 @@ else if (FOOTNOTE_REFERENCE.equals(localName)) { String hyperlink = linkedRelationships.get(hyperlinkId); if (hyperlink != null) { bodyContentsHandler.externalRef("hlinkHover", hyperlink); - if (metadata != null) { - metadata.set(Office.HAS_HOVER_HYPERLINKS, true); - } } } } else if (SHAPE.equals(localName) && V_NS.equals(uri)) { @@ -431,9 +471,6 @@ else if (FOOTNOTE_REFERENCE.equals(localName)) { } if (href != null && !href.isEmpty()) { bodyContentsHandler.externalRef("vml-shape-href", href); - if (metadata != null) { - metadata.set(Office.HAS_VML_HYPERLINKS, true); - } } } @@ -458,6 +495,33 @@ private String getStringVal(Attributes atts) { return ""; } + /** + * Reads a {@code ST_OnOff} {@code w:val} attribute: {@code "0"}/{@code "false"}/ + * {@code "off"} are off, anything else (including absent) follows the supplied + * default. The toggle elements (<w:b/>, <w:i/>, <w:strike/>) + * default to on when {@code w:val} is absent, but must respect an explicit + * {@code w:val="0"} that turns the toggle off (overriding a style-inherited on). + */ + private boolean getOnOff(Attributes atts, boolean defaultValue) { + String v = atts.getValue(W_NS, VAL); + if (v == null) { + return defaultValue; + } + return !("0".equals(v) || "false".equals(v) || "off".equals(v)); + } + + private boolean isCurrentParagraphStarted() { + Boolean top = pStartedStack.peek(); + return top != null && top; + } + + private void markCurrentParagraphStarted() { + if (!pStartedStack.isEmpty()) { + pStartedStack.pop(); + } + pStartedStack.push(Boolean.TRUE); + } + private int getIntVal(Attributes atts) { String valString = atts.getValue(W_NS, VAL); if (valString != null) { @@ -470,65 +534,6 @@ private int getIntVal(Attributes atts) { return -1; } - /** - * Parses a HYPERLINK URL from instrText field code content. - * Field codes like: HYPERLINK "https://example.com" - * - * @param instrText the accumulated instrText content - * @return the URL if found, or null - */ - private String parseHyperlinkFromInstrText(String instrText) { - if (instrText == null || instrText.isEmpty()) { - return null; - } - Matcher m = HYPERLINK_PATTERN.matcher(instrText.trim()); - if (m.find()) { - return m.group(1); - } - return null; - } - - /** - * Parses URLs from instrText field codes that reference external resources. - * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, and LINK fields. - * - * @param instrText the accumulated instrText content - * @param fieldType output parameter - will contain the field type if found - * @return the URL if found, or null - */ - private String parseExternalRefFromInstrText(String instrText, StringBuilder fieldType) { - if (instrText == null || instrText.isEmpty()) { - return null; - } - String trimmed = instrText.trim(); - - Matcher m = INCLUDEPICTURE_PATTERN.matcher(trimmed); - if (m.find()) { - fieldType.append("INCLUDEPICTURE"); - return m.group(1); - } - - m = INCLUDETEXT_PATTERN.matcher(trimmed); - if (m.find()) { - fieldType.append("INCLUDETEXT"); - return m.group(1); - } - - m = IMPORT_PATTERN.matcher(trimmed); - if (m.find()) { - fieldType.append("IMPORT"); - return m.group(1); - } - - m = LINK_PATTERN.matcher(trimmed); - if (m.find()) { - fieldType.append("LINK"); - return m.group(1); - } - - return null; - } - @Override public void endElement(String uri, String localName, String qName) throws SAXException { @@ -537,17 +542,16 @@ public void endElement(String uri, String localName, String qName) throws SAXExc } else if (FALLBACK.equals(localName)) { inACFallbackDepth--; } - if (inACChoiceDepth > 0) { + if (inSkippedAlternateContent()) { return; } - if (!includeTextBox && localName.equals(TEXTBOX)) { + if (!includeTextBox && (localName.equals(TEXTBOX) || localName.equals(TXBX))) { inTextBox = false; return; } if (PIC.equals(localName)) { //PIC_NS - handlePict(); - inPic = false; + pictureTracker.endPicture(); return; } else if (RPR.equals(localName)) { inRPr = false; @@ -555,12 +559,15 @@ public void endElement(String uri, String localName, String qName) throws SAXExc handleEndOfRun(); } else if (T.equals(localName)) { inT = false; - } else if (PPR.equals(localName)) { - if (!pStarted) { + } else if (PPR.equals(localName) && inParagraphLevelPPr) { + // Only process as paragraph properties if this pPr was a direct child of

. + // pPr inside other elements (e.g., fields) must be ignored. + if (!isCurrentParagraphStarted()) { bodyContentsHandler.startParagraph(currPProperties); - pStarted = true; + markCurrentParagraphStarted(); } currPProperties.reset(); + inParagraphLevelPPr = false; } else if (P.equals(localName)) { if (runBuffer.length() > 0) { //

...this will treat that as if it were @@ -568,8 +575,19 @@ public void endElement(String uri, String localName, String qName) throws SAXExc bodyContentsHandler.run(currRunProperties, runBuffer.toString()); runBuffer.setLength(0); } - pStarted = false; - bodyContentsHandler.endParagraph(); + // Only fire endParagraph if startParagraph was actually called for this . + // A self-closing (e.g., inside ) has no children, so + // neither the branch nor the lastStartElementWasP branch fires + // startParagraph -- but endElement(p) still runs. Without this guard the + // body handler's pDepth counter desyncs and the outer paragraph's

gets + // emitted prematurely, leaving the XHTML stack mismatched at endDocument. + boolean started = pStartedStack.isEmpty() ? false : pStartedStack.pop(); + if (started) { + bodyContentsHandler.endParagraph(); + } + // Clear the "first child of p" trigger so the next outer-level startElement + // doesn't spuriously fire startParagraph for this already-closed . + lastStartElementWasP = false; } else if (TC.equals(localName)) { bodyContentsHandler.endTableCell(); } else if (TR.equals(localName)) { @@ -586,7 +604,7 @@ public void endElement(String uri, String localName, String qName) throws SAXExc } else if (HYPERLINK.equals(localName)) { bodyContentsHandler.hyperlinkEnd(); } else if (PICT.equals(localName)) { - handlePict(); + pictureTracker.endPicture(); } else if (V.equals(localName) && C_NS.equals(uri)) { // in value in a chart inV = false; handleEndOfRun(); @@ -610,33 +628,19 @@ private void handleEndOfRuby() throws SAXException { private void handleEndOfRun() throws SAXException { bodyContentsHandler.run(currRunProperties, runBuffer.toString()); - if (inHlinkClick) { - bodyContentsHandler.hyperlinkEnd(); - inHlinkClick = false; - } inR = false; runBuffer.setLength(0); currRunProperties.setBold(false); currRunProperties.setItalics(false); currRunProperties.setStrike(false); currRunProperties.setUnderline(UnderlinePatterns.NONE.name()); - } - - private void handlePict() throws SAXException { - String picFileName = null; - if (picRId != null) { - picFileName = linkedRelationships.get(picRId); - } - bodyContentsHandler.embeddedPicRef(picFileName, picDescription); - picDescription = null; - picRId = null; - inPic = false; + currRunProperties.setHlinkClickUrl(null); } @Override public void characters(char[] ch, int start, int length) throws SAXException { - if (inACChoiceDepth > 0) { + if (inSkippedAlternateContent()) { return; } else if (!includeTextBox && inTextBox) { return; @@ -661,7 +665,7 @@ public void characters(char[] ch, int start, int length) throws SAXException { @Override public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException { - if (inACChoiceDepth > 0) { + if (inSkippedAlternateContent()) { return; } else if (!includeTextBox && inTextBox) { return; @@ -682,77 +686,11 @@ private void appendToBuffer(char[] ch, int start, int length) throws SAXExceptio } } - public enum EditType { - NONE, INSERT, DELETE, MOVE_TO, MOVE_FROM + public boolean isHiddenSlide() { + return hiddenSlide; } - public interface XWPFBodyContentsHandler { - - void run(RunProperties runProperties, String contents) throws SAXException; - - /** - * @param link the link; can be null - */ - void hyperlinkStart(String link) throws SAXException; - - void hyperlinkEnd() throws SAXException; - - void startParagraph(ParagraphProperties paragraphProperties) throws SAXException; - - void endParagraph() throws SAXException; - - void startTable() throws SAXException; - - void endTable() throws SAXException; - - void startTableRow() throws SAXException; - - void endTableRow() throws SAXException; - - void startTableCell() throws SAXException; - - void endTableCell() throws SAXException; - - void startSDT() throws SAXException; - - void endSDT() throws SAXException; - - void startEditedSection(String editor, Date date, EditType editType) throws SAXException; - - void endEditedSection() throws SAXException; - - boolean isIncludeDeletedText() throws SAXException; - - void footnoteReference(String id) throws SAXException; - - void endnoteReference(String id) throws SAXException; - - boolean isIncludeMoveFromText() throws SAXException; - - void embeddedOLERef(String refId) throws SAXException; - - /** - * Called when a linked (vs embedded) OLE object is found. - * These reference external files and are a security concern. - */ - void linkedOLERef(String refId) throws SAXException; - - void embeddedPicRef(String picFileName, String picDescription) throws SAXException; - - void startBookmark(String id, String name) throws SAXException; - - void endBookmark(String id) throws SAXException; - - /** - * Called when an external reference URL is found in a field code. - * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, LINK fields, - * and DrawingML/VML hyperlinks on shapes. - * - * @param fieldType the type of field (e.g., "INCLUDEPICTURE", "hlinkHover", "vml-href") - * @param url the external URL - */ - default void externalRef(String fieldType, String url) throws SAXException { - // Default no-op implementation for backward compatibility - } + public boolean hasAnimations() { + return hasAnimations; } } 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/ParagraphProperties.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java index 80c004bea27..45fe1dfd05e 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/ParagraphProperties.java @@ -23,6 +23,15 @@ public class ParagraphProperties { private int ilvl = -1; private int numId = -1; + public ParagraphProperties() { + } + + public ParagraphProperties(ParagraphProperties other) { + this.styleId = other.styleId; + this.ilvl = other.ilvl; + this.numId = other.numId; + } + public String getStyleID() { return styleId; } 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/RunProperties.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java index 54d149f333f..efed9c13482 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/RunProperties.java @@ -30,6 +30,9 @@ public class RunProperties { UnderlinePatterns underline = UnderlinePatterns.NONE; + // PPTX hlinkClick hyperlink URL — set from inside + String hlinkClickUrl = null; + public boolean isItalics() { return italics; } @@ -68,4 +71,12 @@ public void setUnderline(String underlineString) { underline = UnderlinePatterns.SINGLE; } } + + public String getHlinkClickUrl() { + return hlinkClickUrl; + } + + public void setHlinkClickUrl(String url) { + this.hlinkClickUrl = url; + } } 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/SAXBasedMetadataExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SAXBasedMetadataExtractor.java new file mode 100644 index 00000000000..941ad129060 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SAXBasedMetadataExtractor.java @@ -0,0 +1,519 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.InputStream; +import java.math.BigDecimal; +import java.util.Date; +import java.util.Optional; + +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.openxml4j.opc.PackagePart; +import org.apache.poi.openxml4j.opc.PackageProperties; +import org.apache.poi.openxml4j.opc.PackageRelationship; +import org.apache.poi.openxml4j.opc.PackageRelationshipCollection; +import org.xml.sax.Attributes; +import org.xml.sax.helpers.DefaultHandler; + +import org.apache.tika.exception.TikaException; +import org.apache.tika.metadata.DublinCore; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Office; +import org.apache.tika.metadata.OfficeOpenXMLCore; +import org.apache.tika.metadata.OfficeOpenXMLExtended; +import org.apache.tika.metadata.PagedText; +import org.apache.tika.metadata.Property; +import org.apache.tika.metadata.TikaCoreProperties; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.microsoft.SummaryExtractor; +import org.apache.tika.utils.XMLReaderUtils; + +/** + * SAX-based metadata extractor for OOXML documents that reads document properties + * directly from the OPC package without needing POIXMLProperties or ooxml-lite schemas. + *

+ * Core properties are read from {@link PackageProperties} (OPC level). + * Extended properties (app.xml) and custom properties (custom.xml) are parsed with SAX. + */ +class SAXBasedMetadataExtractor extends MetadataExtractor { + + private static final String EXTENDED_PROPERTIES_REL = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"; + private static final String CUSTOM_PROPERTIES_REL = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; + + /** + * Hard cap on the accumulated text-content of a single property element. + * Real OOXML property values are at most a few hundred bytes; anything beyond + * this is either corruption or an attacker trying to drive memory or CPU + * pressure (cf. the {@code } BigDecimal DoS where a 1M-digit + * literal compresses ~1000:1 in deflate). 64 KB leaves headroom for any + * legitimate value while bounding the slow-path inputs decisively. + */ + static final int MAX_TEXT_BUFFER_LENGTH = 64 * 1024; + + /** + * Hard cap on the {@code } text length passed to + * {@link BigDecimal#BigDecimal(String)}. JDK 17's parser is O(n²) in the + * digit count, so even a 64 KB string costs noticeable CPU. Real-world + * decimal values fit in well under 50 digits; 256 is generous. + */ + static final int MAX_DECIMAL_LENGTH = 256; + + private final OPCPackage opcPackage; + private final ParseContext parseContext; + + SAXBasedMetadataExtractor(OPCPackage opcPackage, ParseContext parseContext) { + this.opcPackage = opcPackage; + this.parseContext = parseContext; + } + + @Override + public void extract(Metadata metadata) throws TikaException { + extractCoreProperties(metadata); + extractExtendedProperties(metadata); + extractCustomProperties(metadata); + } + + private void extractCoreProperties(Metadata metadata) { + try { + PackageProperties props = opcPackage.getPackageProperties(); + if (props == null) { + return; + } + setProperty(metadata, OfficeOpenXMLCore.CATEGORY, props.getCategoryProperty()); + setProperty(metadata, OfficeOpenXMLCore.CONTENT_STATUS, + props.getContentStatusProperty()); + setProperty(metadata, TikaCoreProperties.CREATED, props.getCreatedProperty()); + addMultiProperty(metadata, TikaCoreProperties.CREATOR, props.getCreatorProperty()); + setProperty(metadata, TikaCoreProperties.DESCRIPTION, + props.getDescriptionProperty()); + setProperty(metadata, TikaCoreProperties.IDENTIFIER, props.getIdentifierProperty()); + addProperty(metadata, DublinCore.SUBJECT, props.getSubjectProperty()); + addProperty(metadata, Office.KEYWORDS, props.getKeywordsProperty()); + setProperty(metadata, TikaCoreProperties.LANGUAGE, props.getLanguageProperty()); + setProperty(metadata, TikaCoreProperties.MODIFIER, + props.getLastModifiedByProperty()); + setProperty(metadata, TikaCoreProperties.PRINT_DATE, + props.getLastPrintedProperty()); + setProperty(metadata, TikaCoreProperties.MODIFIED, props.getModifiedProperty()); + setProperty(metadata, OfficeOpenXMLCore.REVISION, props.getRevisionProperty()); + setProperty(metadata, TikaCoreProperties.TITLE, props.getTitleProperty()); + setProperty(metadata, OfficeOpenXMLCore.VERSION, props.getVersionProperty()); + } catch (Exception e) { + //swallow + } + } + + private void extractExtendedProperties(Metadata metadata) { + try { + PackagePart extPart = getRelatedPart(EXTENDED_PROPERTIES_REL); + if (extPart == null) { + return; + } + ExtendedPropertiesHandler handler = new ExtendedPropertiesHandler(); + try (InputStream is = extPart.getInputStream()) { + XMLReaderUtils.parseSAX(is, handler, parseContext); + } + handler.applyTo(metadata); + } catch (Exception e) { + //swallow + } + } + + private void extractCustomProperties(Metadata metadata) { + try { + PackagePart custPart = getRelatedPart(CUSTOM_PROPERTIES_REL); + if (custPart == null) { + return; + } + CustomPropertiesHandler handler = new CustomPropertiesHandler(); + try (InputStream is = custPart.getInputStream()) { + XMLReaderUtils.parseSAX(is, handler, parseContext); + } + handler.applyTo(metadata); + } catch (Exception e) { + //swallow + } + } + + private PackagePart getRelatedPart(String relationshipType) { + try { + PackageRelationshipCollection rels = + opcPackage.getRelationshipsByType(relationshipType); + if (rels == null || rels.size() == 0) { + return null; + } + PackageRelationship rel = rels.getRelationship(0); + if (rel == null) { + return null; + } + return opcPackage.getPart(rel); + } catch (Exception e) { + return null; + } + } + + private void setProperty(Metadata metadata, Property property, + Optional optionalValue) { + if (!optionalValue.isPresent()) { + return; + } + T value = optionalValue.get(); + if (value instanceof Date) { + metadata.set(property, (Date) value); + } else if (value instanceof String) { + metadata.set(property, (String) value); + } else if (value instanceof Integer) { + metadata.set(property, (Integer) value); + } else if (value instanceof Double) { + metadata.set(property, (Double) value); + } + } + + private void addProperty(Metadata metadata, Property property, + Optional optionalValue) { + if (!optionalValue.isPresent()) { + return; + } + T value = optionalValue.get(); + if (value instanceof String) { + metadata.add(property, (String) value); + } + } + + private void addMultiProperty(Metadata metadata, Property property, + Optional value) { + if (!value.isPresent()) { + return; + } + SummaryExtractor.addMulti(metadata, property, value.get()); + } + + /** + * Append SAX {@code characters()} content to {@code buf}, but stop accepting + * once {@link #MAX_TEXT_BUFFER_LENGTH} is reached. Excess characters are + * silently dropped; truncated values still flow through downstream parsing + * (which will either accept the prefix or reject it as a NumberFormatException). + */ + static void appendCapped(StringBuilder buf, char[] ch, int start, int length) { + if (buf.length() >= MAX_TEXT_BUFFER_LENGTH) { + return; + } + int remaining = MAX_TEXT_BUFFER_LENGTH - buf.length(); + buf.append(ch, start, Math.min(length, remaining)); + } + + /** + * SAX handler for docProps/app.xml (extended properties). + */ + static class ExtendedPropertiesHandler extends DefaultHandler { + + private String application; + private String appVersion; + private String company; + private String manager; + private String notes; + private String presentationFormat; + private String template; + private int totalTime; + private int docSecurity; + private int pages; + private int slides; + private int paragraphs; + private int lines; + private int words; + private int characters; + private int charactersWithSpaces; + + private String currentElement; + private final StringBuilder textBuffer = new StringBuilder(); + + @Override + public void startElement(String uri, String localName, String qName, Attributes atts) { + currentElement = localName; + textBuffer.setLength(0); + } + + @Override + public void characters(char[] ch, int start, int length) { + appendCapped(textBuffer, ch, start, length); + } + + @Override + public void endElement(String uri, String localName, String qName) { + if (!localName.equals(currentElement)) { + return; + } + String val = textBuffer.toString().trim(); + if (val.isEmpty()) { + currentElement = null; + return; + } + switch (localName) { + case "Application": + application = val; + break; + case "AppVersion": + appVersion = val; + break; + case "Company": + company = val; + break; + case "Manager": + manager = val; + break; + case "Notes": + notes = val; + break; + case "PresentationFormat": + presentationFormat = val; + break; + case "Template": + template = val; + break; + case "TotalTime": + totalTime = safeParseInt(val); + break; + case "DocSecurity": + docSecurity = safeParseInt(val); + break; + case "Pages": + pages = safeParseInt(val); + break; + case "Slides": + slides = safeParseInt(val); + break; + case "Paragraphs": + paragraphs = safeParseInt(val); + break; + case "Lines": + lines = safeParseInt(val); + break; + case "Words": + words = safeParseInt(val); + break; + case "Characters": + characters = safeParseInt(val); + break; + case "CharactersWithSpaces": + charactersWithSpaces = safeParseInt(val); + break; + default: + break; + } + currentElement = null; + } + + private int safeParseInt(String val) { + try { + // Handle unsigned int overflow (TIKA-2055) + long l = Long.parseLong(val); + if (l > Integer.MAX_VALUE || l < 0) { + return 0; + } + return (int) l; + } catch (NumberFormatException e) { + return 0; + } + } + + void applyTo(Metadata metadata) { + setIfNotNull(metadata, OfficeOpenXMLExtended.APPLICATION, application); + setIfNotNull(metadata, OfficeOpenXMLExtended.APP_VERSION, appVersion); + setIfNotNull(metadata, TikaCoreProperties.PUBLISHER, company); + setIfNotNull(metadata, OfficeOpenXMLExtended.COMPANY, company); + if (manager != null) { + SummaryExtractor.addMulti(metadata, OfficeOpenXMLExtended.MANAGER, manager); + } + setIfNotNull(metadata, OfficeOpenXMLExtended.NOTES, notes); + setIfNotNull(metadata, OfficeOpenXMLExtended.PRESENTATION_FORMAT, presentationFormat); + setIfNotNull(metadata, OfficeOpenXMLExtended.TEMPLATE, template); + setIfPositive(metadata, OfficeOpenXMLExtended.TOTAL_TIME, totalTime); + setIfPositive(metadata, OfficeOpenXMLExtended.DOC_SECURITY, docSecurity); + metadata.set(OfficeOpenXMLExtended.DOC_SECURITY_STRING, + getDocSecurityString(docSecurity)); + + if (pages > 0) { + metadata.set(PagedText.N_PAGES, pages); + } else if (slides > 0) { + metadata.set(PagedText.N_PAGES, slides); + } + + setIfPositive(metadata, Office.PAGE_COUNT, pages); + setIfPositive(metadata, Office.SLIDE_COUNT, slides); + setIfPositive(metadata, Office.PARAGRAPH_COUNT, paragraphs); + setIfPositive(metadata, Office.LINE_COUNT, lines); + setIfPositive(metadata, Office.WORD_COUNT, words); + setIfPositive(metadata, Office.CHARACTER_COUNT, characters); + setIfPositive(metadata, Office.CHARACTER_COUNT_WITH_SPACES, charactersWithSpaces); + } + + private void setIfNotNull(Metadata metadata, Property property, String value) { + if (value != null) { + metadata.set(property, value); + } + } + + private void setIfPositive(Metadata metadata, Property property, int value) { + if (value > 0) { + metadata.set(property, value); + } + } + + private String getDocSecurityString(int flag) { + switch (flag) { + case 0: + return OfficeOpenXMLExtended.SECURITY_NONE; + case 1: + return OfficeOpenXMLExtended.SECURITY_PASSWORD_PROTECTED; + case 2: + return OfficeOpenXMLExtended.SECURITY_READ_ONLY_RECOMMENDED; + case 4: + return OfficeOpenXMLExtended.SECURITY_READ_ONLY_ENFORCED; + case 8: + return OfficeOpenXMLExtended.SECURITY_LOCKED_FOR_ANNOTATIONS; + default: + return OfficeOpenXMLExtended.SECURITY_UNKNOWN; + } + } + } + + /** + * SAX handler for docProps/custom.xml (custom properties). + */ + static class CustomPropertiesHandler extends DefaultHandler { + + private static final String VT_NS = + "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; + + private final Metadata customMetadata = new Metadata(); + private String currentPropertyName; + private String currentValueType; + private final StringBuilder textBuffer = new StringBuilder(); + + @Override + public void startElement(String uri, String localName, String qName, Attributes atts) { + if ("property".equals(localName)) { + currentPropertyName = atts.getValue("name"); + currentValueType = null; + } else if (VT_NS.equals(uri) && currentPropertyName != null + && currentValueType == null) { + // First vt: child under wins. The == null guard keeps + // / containers latched as the type so their + // inner children (vt:lpstr, vt:i4, ...) don't get re-emitted as + // a scalar custom property. The container itself falls through + // the endElement switch's default branch (no emit), matching the + // prior POI path that explicitly skipped vector/array. + currentValueType = localName; + textBuffer.setLength(0); + } + } + + @Override + public void characters(char[] ch, int start, int length) { + appendCapped(textBuffer, ch, start, length); + } + + @Override + public void endElement(String uri, String localName, String qName) { + if (VT_NS.equals(uri) && currentValueType != null && + localName.equals(currentValueType) && currentPropertyName != null) { + // Legacy POI's typed accessors (getLpwstr/getLpstr/getBstr) returned + // the raw element text, while numeric/date/bool accessors yielded + // already-normalized forms. Mirror that here: keep whitespace in + // strings, work with the trimmed form everywhere else. + String raw = textBuffer.toString(); + String trimmed = raw.trim(); + String propName = "custom:" + currentPropertyName; + switch (currentValueType) { + case "lpwstr": + case "lpstr": + case "bstr": + customMetadata.set(propName, raw); + break; + case "filetime": + case "date": + Property tikaProp = Property.externalDate(propName); + customMetadata.set(tikaProp, trimmed); + break; + case "bool": + // xs:boolean lexical space is {true,false,1,0}. Legacy POI + // routed through Boolean.toString(getBool()) so consumers + // doing "true".equals(...) never saw the 1/0 form. Anything + // outside the lexical space is dropped, not stored verbatim. + if ("1".equals(trimmed) || "true".equals(trimmed)) { + customMetadata.set(propName, "true"); + } else if ("0".equals(trimmed) || "false".equals(trimmed)) { + customMetadata.set(propName, "false"); + } + break; + case "i1": + case "i2": + case "i4": + case "int": + case "ui1": + case "ui2": + customMetadata.set(propName, trimmed); + break; + case "i8": + case "ui4": + case "ui8": + case "uint": + customMetadata.set(propName, trimmed); + break; + case "r4": + case "r8": + customMetadata.set(propName, trimmed); + break; + case "decimal": + // BigDecimal(String) is O(n²) on JDK 17; cap the input + // length to keep an attacker-controlled + // from burning CPU. Real values are < 50 chars; 256 is + // generous. See ooxml-bigdecimal-dos. + if (trimmed.length() > MAX_DECIMAL_LENGTH) { + break; + } + try { + BigDecimal d = new BigDecimal(trimmed); + customMetadata.set(propName, d.toPlainString()); + } catch (NumberFormatException e) { + //swallow + } + break; + default: + break; + } + currentValueType = null; + } else if ("property".equals(localName)) { + currentPropertyName = null; + // Defensive: if a malformed custom.xml left a vt: container open + // (e.g. without a matching close before ), + // make sure the next property doesn't inherit it. + currentValueType = null; + } + } + + void applyTo(Metadata metadata) { + for (String name : customMetadata.names()) { + for (String value : customMetadata.getValues(name)) { + metadata.add(name, value); + } + } + } + } +} 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 c036f086f04..ee5d7050c00 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 @@ -41,6 +41,7 @@ import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Office; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.parser.microsoft.ooxml.xslf.XSLFEventBasedPowerPointExtractor; @@ -177,11 +178,17 @@ private void handleSlidePart(PackagePart slidePart, XHTMLContentHandler xhtml) // Map hyperlinks = loadHyperlinkRelationships(packagePart); xhtml.startElement("div", "class", "slide-content"); + //pass metadata so the body handler can emit HAS_* signals for the slide, and keep a + //reference so we can flag hidden slides after the parse + OOXMLWordAndPowerPointTextHandler wordAndPPTHandler = + new OOXMLWordAndPowerPointTextHandler( + new OOXMLTikaBodyPartHandler(xhtml, metadata), linkedRelationships); try (InputStream stream = slidePart.getInputStream()) { XMLReaderUtils.parseSAX(CloseShieldInputStream.wrap(stream), - new EmbeddedContentHandler(new OOXMLWordAndPowerPointTextHandler( - new OOXMLTikaBodyPartHandler(xhtml), linkedRelationships)), context); - + new EmbeddedContentHandler(wordAndPPTHandler), context); + if (wordAndPPTHandler.isHiddenSlide()) { + metadata.set(Office.HAS_HIDDEN_SLIDES, true); + } } catch (TikaException | IOException e) { metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, ExceptionUtils.getStackTrace(e)); 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/SXWPFWordExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXWPFWordExtractorDecorator.java index fbe16d51a2c..38426a12799 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXWPFWordExtractorDecorator.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/SXWPFWordExtractorDecorator.java @@ -16,11 +16,14 @@ */ package org.apache.tika.parser.microsoft.ooxml; +import java.io.ByteArrayInputStream; import java.io.IOException; import java.io.InputStream; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Set; import java.util.zip.ZipException; import org.apache.commons.io.input.CloseShieldInputStream; @@ -40,6 +43,7 @@ import org.xml.sax.helpers.DefaultHandler; import org.apache.tika.exception.TikaException; +import org.apache.tika.exception.WriteLimitReachedException; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Office; import org.apache.tika.metadata.TikaCoreProperties; @@ -105,6 +109,15 @@ public SXWPFWordExtractorDecorator(Metadata metadata, ParseContext context, this.opcPackage = extractor.getPackage(); } + /** + * The SAX docx path reads metadata directly from the OPC package (no POI/XMLBeans), + * matching the 4.x behavior. The DOM path keeps the concrete {@link MetadataExtractor}. + */ + @Override + public MetadataExtractor getMetadataExtractor() { + return new SAXBasedMetadataExtractor(opcPackage, getParseContext()); + } + @Override protected void buildXHTML(XHTMLContentHandler xhtml) @@ -247,6 +260,10 @@ private void handleDocumentPart(PackagePart documentPart, XHTMLContentHandler xh ExceptionUtils.getStackTrace(e)); } + //pre-collect footnotes, endnotes, and comments so they can be inlined at their + //reference points in the main document (matching the DOM parser's behavior) + OOXMLInlineBodyPartMap inlinePartMap = collectInlineParts(documentPart); + if (config.isIncludeHeadersAndFooters()) { //headers try { @@ -256,7 +273,8 @@ private void handleDocumentPart(PackagePart documentPart, XHTMLContentHandler xh for (int i = 0; i < headersPRC.size(); i++) { PackagePart header = documentPart.getRelatedPart(headersPRC.getRelationship(i)); - handlePart(header, styles, listManager, xhtml); + handlePart(header, styles, listManager, xhtml, + OOXMLInlineBodyPartMap.EMPTY); } } } catch (InvalidFormatException | ZipException e) { @@ -265,18 +283,18 @@ private void handleDocumentPart(PackagePart documentPart, XHTMLContentHandler xh } } - //main document + //main document -- footnotes/endnotes/comments are inlined via inlinePartMap + OOXMLTikaBodyPartHandler mainHandler = null; try { - handlePart(documentPart, styles, listManager, xhtml); + mainHandler = handlePart(documentPart, styles, listManager, xhtml, inlinePartMap); } catch (ZipException e) { metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, ExceptionUtils.getStackTrace(e)); } - //for now, just dump other components at end + //dump remaining components at end; footnotes/endnotes/comments are now inlined + //above, so they are no longer dumped here for (String rel : new String[]{AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA, - XSSFRelation.CHART.getRelation(), XWPFRelation.FOOTNOTE.getRelation(), - XWPFRelation.COMMENT.getRelation(), XWPFRelation.FOOTER.getRelation(), - XWPFRelation.ENDNOTE.getRelation(),}) { + XSSFRelation.CHART.getRelation(), XWPFRelation.FOOTER.getRelation(),}) { //skip footers if we shouldn't extract them if (!config.isIncludeHeadersAndFooters() && rel.equals(XWPFRelation.FOOTER.getRelation())) { @@ -288,7 +306,8 @@ private void handleDocumentPart(PackagePart documentPart, XHTMLContentHandler xh for (int i = 0; i < prc.size(); i++) { PackagePart packagePart = documentPart.getRelatedPart(prc.getRelationship(i)); - handlePart(packagePart, styles, listManager, xhtml); + handlePart(packagePart, styles, listManager, xhtml, + OOXMLInlineBodyPartMap.EMPTY); } } } catch (InvalidFormatException | ZipException e) { @@ -296,25 +315,136 @@ private void handleDocumentPart(PackagePart documentPart, XHTMLContentHandler xh ExceptionUtils.getStackTrace(e)); } } + + //dump any comments that were NOT inlined via commentReference + if (mainHandler != null) { + handleUnreferencedComments(xhtml, inlinePartMap, mainHandler.getEmittedCommentIds()); + } } - private void handlePart(PackagePart packagePart, XWPFStylesShim styles, - XWPFListManager listManager, XHTMLContentHandler xhtml) + private OOXMLTikaBodyPartHandler handlePart(PackagePart packagePart, XWPFStylesShim styles, + XWPFListManager listManager, XHTMLContentHandler xhtml, + OOXMLInlineBodyPartMap inlinePartMap) throws IOException, SAXException { Map linkedRelationships = loadLinkedRelationships(packagePart, true, metadata); + OOXMLTikaBodyPartHandler bodyHandler = + new OOXMLTikaBodyPartHandler(xhtml, styles, listManager, config, metadata); + bodyHandler.setInlineBodyPartMap(inlinePartMap, context); try (InputStream stream = packagePart.getInputStream()) { XMLReaderUtils.parseSAX(CloseShieldInputStream.wrap(stream), new EmbeddedContentHandler(new OOXMLWordAndPowerPointTextHandler( - new OOXMLTikaBodyPartHandler(xhtml, styles, listManager, config), + bodyHandler, linkedRelationships, config.isIncludeShapeBasedContent(), - config.isConcatenatePhoneticRuns(), metadata)), context); + config.isConcatenatePhoneticRuns(), + config.isPreferAlternateContentChoice())), context); + } catch (SAXException e) { + WriteLimitReachedException.throwIfWriteLimitReached(e); + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + //the partial parse may have left

/

/
or formatting tags open; + //close them so subsequent parts and the outer land balanced + bodyHandler.closeAnyPending(); } catch (TikaException | IOException e) { metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, ExceptionUtils.getStackTrace(e)); + bodyHandler.closeAnyPending(); } + return bodyHandler; + } + /** + * Dumps comments that were not inlined at a {@code commentReference} in the main document + * (matching the DOM parser, which appends orphaned comments at the end). + */ + private void handleUnreferencedComments(XHTMLContentHandler xhtml, + OOXMLInlineBodyPartMap inlinePartMap, Set emittedCommentIds) { + if (!inlinePartMap.hasComments()) { + return; + } + Map linkedRelationships = inlinePartMap.getLinkedRelationships(); + for (Map.Entry entry : inlinePartMap.getCommentEntries()) { + if (emittedCommentIds.contains(entry.getKey())) { + continue; + } + try { + xhtml.startElement("div", "class", "comment"); + XMLReaderUtils.parseSAX(new ByteArrayInputStream(entry.getValue()), + new EmbeddedContentHandler(new OOXMLWordAndPowerPointTextHandler( + new OOXMLTikaBodyPartHandler(xhtml), linkedRelationships)), + context); + xhtml.endElement("div"); + } catch (TikaException | IOException | SAXException e) { + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + } + } + } + + /** + * Pre-parses the footnote, endnote, and comment parts into raw XML fragments keyed by their + * {@code w:id}, so {@link OOXMLTikaBodyPartHandler} can inline each at its reference point. + */ + private OOXMLInlineBodyPartMap collectInlineParts(PackagePart documentPart) { + Map allRelationships = new java.util.HashMap<>(); + Map footnoteMap = collectPartContent(documentPart, + XWPFRelation.FOOTNOTE.getRelation(), Set.of("footnote"), allRelationships); + String endnoteRel = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/endnotes"; + Map endnoteMap = collectPartContent(documentPart, + endnoteRel, Set.of("endnote"), allRelationships); + String commentsRel = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; + Map commentMap = collectPartContent(documentPart, + commentsRel, Set.of("comment"), allRelationships, Collections.emptySet()); + return new OOXMLInlineBodyPartMap(footnoteMap, endnoteMap, commentMap, allRelationships); + } + + private Map collectPartContent(PackagePart documentPart, + String relationshipType, Set wrapperElements, + Map allRelationships) { + //footnotes/endnotes reserve ids 0 and -1 for separator/continuation-separator + return collectPartContent(documentPart, relationshipType, wrapperElements, + allRelationships, Set.of("0", "-1")); + } + + private Map collectPartContent(PackagePart documentPart, + String relationshipType, Set wrapperElements, + Map allRelationships, Set skipIds) { + try { + PackageRelationshipCollection prc = + documentPart.getRelationshipsByType(relationshipType); + if (prc == null || prc.size() == 0) { + return Collections.emptyMap(); + } + OOXMLPartContentCollector collector = + new OOXMLPartContentCollector(wrapperElements, skipIds); + for (int i = 0; i < prc.size(); i++) { + PackagePart part = safeGetRelatedPart(documentPart, prc.getRelationship(i)); + if (part == null) { + continue; + } + //collect the part's linked relationships (for picture/hyperlink resolution) + allRelationships.putAll(loadLinkedRelationships(part, true, metadata)); + try (InputStream stream = part.getInputStream()) { + XMLReaderUtils.parseSAX(stream, collector, context); + } + } + return collector.getContentMap(); + } catch (InvalidFormatException | IOException | TikaException | SAXException e) { + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + return Collections.emptyMap(); + } + } + + private PackagePart safeGetRelatedPart(PackagePart parent, PackageRelationship rel) { + try { + return parent.getRelatedPart(rel); + } catch (Exception e) { + return null; + } } 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/TikaSheetContentsHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaSheetContentsHandler.java new file mode 100644 index 00000000000..44173ec3222 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaSheetContentsHandler.java @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +/** + * Sheet contents handler that uses {@link XSSFCommentsShim.CommentData} + * instead of POI's XMLBeans-dependent {@code XSSFComment}. + */ +interface TikaSheetContentsHandler { + + void startRow(int rowNum); + + void endRow(int rowNum); + + void cell(String cellRef, String formattedValue, XSSFCommentsShim.CommentData comment); + + default void headerFooter(String text, boolean isHeader, String tagName) { + } + + default void endSheet() { + } +} 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/TikaSheetXMLHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaSheetXMLHandler.java new file mode 100644 index 00000000000..e95506be5d0 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaSheetXMLHandler.java @@ -0,0 +1,398 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.util.Iterator; +import java.util.LinkedList; +import java.util.Queue; + +import org.apache.poi.ss.usermodel.BuiltinFormats; +import org.apache.poi.ss.usermodel.DataFormatter; +import org.apache.poi.ss.util.CellAddress; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.xml.sax.Attributes; +import org.xml.sax.SAXException; +import org.xml.sax.helpers.DefaultHandler; + +/** + * Sheet XML handler for XLSX event-based parsing that uses {@link XSSFStylesShim} + * and {@link XSSFCommentsShim} instead of POI's XMLBeans-dependent + * {@code StylesTable} and {@code CommentsTable}. + *

+ * Adapted from Apache POI's {@code XSSFSheetXMLHandler} (Apache 2.0 license). + */ +class TikaSheetXMLHandler extends DefaultHandler { + + private static final Logger LOG = LoggerFactory.getLogger(TikaSheetXMLHandler.class); + + private static final String NS_SPREADSHEETML = + "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + + enum XssfDataType { + BOOLEAN, + ERROR, + FORMULA, + INLINE_STRING, + SST_STRING, + NUMBER, + } + + private final XSSFStylesShim stylesShim; + private final XSSFCommentsShim commentsShim; + private final XSSFSharedStringsShim sharedStringsShim; + private final TikaSheetContentsHandler output; + private final DataFormatter formatter; + private final boolean formulasNotResults; + + private boolean vIsOpen; + private boolean fIsOpen; + private boolean isIsOpen; + private boolean hfIsOpen; + + private XssfDataType nextDataType; + private short formatIndex; + private String formatString; + + private int rowNum; + private int nextRowNum; + private String cellRef; + + private final StringBuilder value = new StringBuilder(64); + private final StringBuilder formula = new StringBuilder(64); + private final StringBuilder headerFooter = new StringBuilder(64); + + private Queue commentCellRefs; + + TikaSheetXMLHandler(XSSFStylesShim stylesShim, + XSSFCommentsShim commentsShim, + XSSFSharedStringsShim sharedStringsShim, + TikaSheetContentsHandler sheetContentsHandler, + DataFormatter dataFormatter, + boolean formulasNotResults) { + this.stylesShim = stylesShim; + this.commentsShim = commentsShim; + this.sharedStringsShim = sharedStringsShim; + this.output = sheetContentsHandler; + this.formatter = dataFormatter; + this.formulasNotResults = formulasNotResults; + this.nextDataType = XssfDataType.NUMBER; + initComments(commentsShim); + } + + TikaSheetXMLHandler(XSSFStylesShim stylesShim, + XSSFSharedStringsShim sharedStringsShim, + TikaSheetContentsHandler sheetContentsHandler, + DataFormatter dataFormatter, + boolean formulasNotResults) { + this(stylesShim, null, sharedStringsShim, sheetContentsHandler, dataFormatter, + formulasNotResults); + } + + private void initComments(XSSFCommentsShim commentsShim) { + if (commentsShim != null) { + commentCellRefs = new LinkedList<>(); + for (Iterator iter = commentsShim.getCellAddresses(); + iter.hasNext(); ) { + commentCellRefs.add(iter.next()); + } + } + } + + private boolean isTextTag(String name) { + if ("v".equals(name)) { + return true; + } + if ("inlineStr".equals(name)) { + return true; + } + return "t".equals(name) && isIsOpen; + } + + @Override + public void startElement(String uri, String localName, String qName, + Attributes attributes) throws SAXException { + if (uri != null && !uri.equals(NS_SPREADSHEETML)) { + return; + } + + if (isTextTag(localName)) { + vIsOpen = true; + if (!isIsOpen) { + value.setLength(0); + } + } else if ("is".equals(localName)) { + isIsOpen = true; + } else if ("f".equals(localName)) { + formula.setLength(0); + if (this.nextDataType == XssfDataType.NUMBER) { + this.nextDataType = XssfDataType.FORMULA; + } + String type = attributes.getValue("t"); + if (type != null && type.equals("shared")) { + String ref = attributes.getValue("ref"); + if (ref != null) { + fIsOpen = true; + } + // shared-formula reference without a `ref` attribute is not yet supported + } else { + fIsOpen = true; + } + } else if ("oddHeader".equals(localName) || "evenHeader".equals(localName) || + "firstHeader".equals(localName) || "firstFooter".equals(localName) || + "oddFooter".equals(localName) || "evenFooter".equals(localName)) { + hfIsOpen = true; + headerFooter.setLength(0); + } else if ("row".equals(localName)) { + String rowNumStr = attributes.getValue("r"); + if (rowNumStr != null) { + rowNum = Integer.parseInt(rowNumStr.trim()) - 1; + } else { + rowNum = nextRowNum; + } + output.startRow(rowNum); + } else if ("c".equals(localName)) { + // Cell element — resolve style to format index/string + this.formula.setLength(0); + this.nextDataType = XssfDataType.NUMBER; + this.formatIndex = -1; + this.formatString = null; + cellRef = attributes.getValue("r"); + String cellType = attributes.getValue("t"); + String cellStyleStr = attributes.getValue("s"); + + if ("b".equals(cellType)) { + nextDataType = XssfDataType.BOOLEAN; + } else if ("e".equals(cellType)) { + nextDataType = XssfDataType.ERROR; + } else if ("inlineStr".equals(cellType)) { + nextDataType = XssfDataType.INLINE_STRING; + } else if ("s".equals(cellType)) { + nextDataType = XssfDataType.SST_STRING; + } else if ("str".equals(cellType)) { + nextDataType = XssfDataType.FORMULA; + } else { + // Number — resolve format via our styles shim + if (stylesShim != null) { + int styleIndex; + if (cellStyleStr != null) { + styleIndex = Integer.parseInt(cellStyleStr.trim()); + } else if (stylesShim.getNumCellStyles() > 0) { + styleIndex = 0; + } else { + styleIndex = -1; + } + if (styleIndex >= 0) { + this.formatIndex = stylesShim.getFormatIndex(styleIndex); + this.formatString = stylesShim.getFormatString(styleIndex); + if (this.formatString == null) { + this.formatString = + BuiltinFormats.getBuiltinFormat(this.formatIndex); + } + } + } + } + } + } + + @Override + public void endElement(String uri, String localName, String qName) + throws SAXException { + if (uri != null && !uri.equals(NS_SPREADSHEETML)) { + return; + } + + if (isTextTag(localName)) { + vIsOpen = false; + if (!isIsOpen) { + outputCell(); + value.setLength(0); + } + } else if ("f".equals(localName)) { + fIsOpen = false; + } else if ("is".equals(localName)) { + isIsOpen = false; + outputCell(); + value.setLength(0); + } else if ("row".equals(localName)) { + checkForEmptyCellComments(EmptyCellCommentsCheckType.END_OF_ROW); + output.endRow(rowNum); + nextRowNum = rowNum + 1; + } else if ("sheetData".equals(localName)) { + checkForEmptyCellComments(EmptyCellCommentsCheckType.END_OF_SHEET_DATA); + output.endSheet(); + } else if ("oddHeader".equals(localName) || "evenHeader".equals(localName) || + "firstHeader".equals(localName)) { + hfIsOpen = false; + output.headerFooter(headerFooter.toString(), true, localName); + } else if ("oddFooter".equals(localName) || "evenFooter".equals(localName) || + "firstFooter".equals(localName)) { + hfIsOpen = false; + output.headerFooter(headerFooter.toString(), false, localName); + } + } + + @Override + public void characters(char[] ch, int start, int length) throws SAXException { + if (vIsOpen) { + value.append(ch, start, length); + } + if (fIsOpen) { + formula.append(ch, start, length); + } + if (hfIsOpen) { + headerFooter.append(ch, start, length); + } + } + + private void outputCell() { + String thisStr = null; + + if (formulasNotResults && formula.length() > 0) { + thisStr = formula.toString(); + } else { + switch (nextDataType) { + case BOOLEAN: + char first = value.charAt(0); + thisStr = first == '0' ? "FALSE" : "TRUE"; + break; + case ERROR: + thisStr = "ERROR:" + value; + break; + case FORMULA: + if (formulasNotResults) { + thisStr = formula.toString(); + } else { + String fv = value.toString(); + if (this.formatString != null) { + try { + double d = Double.parseDouble(fv.trim()); + thisStr = formatter.formatRawCellContents( + d, this.formatIndex, this.formatString); + } catch (Exception e) { + thisStr = fv; + } + } else { + thisStr = fv; + } + } + break; + case INLINE_STRING: + thisStr = value.toString(); + break; + case SST_STRING: + String sstIndex = value.toString().trim(); + if (!sstIndex.isEmpty() && sharedStringsShim != null) { + try { + int idx = Integer.parseInt(sstIndex); + thisStr = sharedStringsShim.getItemAt(idx); + } catch (NumberFormatException ex) { + LOG.error("Failed to parse SST index '{}'", sstIndex, ex); + } + } + break; + case NUMBER: + String n = value.toString(); + if (this.formatString != null && !n.isEmpty()) { + try { + thisStr = formatter.formatRawCellContents( + Double.parseDouble(n.trim()), + this.formatIndex, this.formatString); + } catch (Exception e) { + thisStr = n; + } + } else { + thisStr = n; + } + break; + default: + thisStr = "(TODO: Unexpected type: " + nextDataType + ")"; + break; + } + } + + checkForEmptyCellComments(EmptyCellCommentsCheckType.CELL); + XSSFCommentsShim.CommentData comment = commentsShim != null ? + commentsShim.findCellComment(new CellAddress(cellRef)) : null; + output.cell(cellRef, thisStr, comment); + } + + private void checkForEmptyCellComments(EmptyCellCommentsCheckType type) { + if (commentCellRefs != null && !commentCellRefs.isEmpty()) { + if (type == EmptyCellCommentsCheckType.END_OF_SHEET_DATA) { + while (!commentCellRefs.isEmpty()) { + outputEmptyCellComment(commentCellRefs.remove()); + } + return; + } + + if (this.cellRef == null) { + if (type == EmptyCellCommentsCheckType.END_OF_ROW) { + while (!commentCellRefs.isEmpty()) { + if (commentCellRefs.peek().getRow() == rowNum) { + outputEmptyCellComment(commentCellRefs.remove()); + } else { + return; + } + } + return; + } else { + throw new IllegalStateException( + "Cell ref should be null only if there are only empty " + + "cells in the row; rowNum: " + rowNum); + } + } + + CellAddress nextCommentCellRef; + do { + CellAddress cellAddr = new CellAddress(this.cellRef); + CellAddress peekCellRef = commentCellRefs.peek(); + if (type == EmptyCellCommentsCheckType.CELL && + cellAddr.equals(peekCellRef)) { + commentCellRefs.remove(); + return; + } else { + int comparison = peekCellRef.compareTo(cellAddr); + if (comparison > 0 && + type == EmptyCellCommentsCheckType.END_OF_ROW && + peekCellRef.getRow() <= rowNum) { + nextCommentCellRef = commentCellRefs.remove(); + outputEmptyCellComment(nextCommentCellRef); + } else if (comparison < 0 && + type == EmptyCellCommentsCheckType.CELL && + peekCellRef.getRow() <= rowNum) { + nextCommentCellRef = commentCellRefs.remove(); + outputEmptyCellComment(nextCommentCellRef); + } else { + nextCommentCellRef = null; + } + } + } while (nextCommentCellRef != null && !commentCellRefs.isEmpty()); + } + } + + private void outputEmptyCellComment(CellAddress cellRef) { + XSSFCommentsShim.CommentData comment = commentsShim.findCellComment(cellRef); + output.cell(cellRef.formatAsString(), null, comment); + } + + private enum EmptyCellCommentsCheckType { + CELL, + END_OF_ROW, + END_OF_SHEET_DATA + } +} 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/TikaXSSFBCommentsTable.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaXSSFBCommentsTable.java new file mode 100644 index 00000000000..79a968a0bab --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaXSSFBCommentsTable.java @@ -0,0 +1,137 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +import org.apache.poi.ss.util.CellAddress; +import org.apache.poi.util.LittleEndian; +import org.apache.poi.util.LittleEndianConsts; +import org.apache.poi.xssf.binary.XSSFBParser; +import org.apache.poi.xssf.binary.XSSFBRecordType; +import org.apache.poi.xssf.binary.XSSFBUtils; +import org.xml.sax.SAXException; + +import org.apache.tika.sax.XHTMLContentHandler; + +/** + * Replacement for POI's {@code XSSFBCommentsTable} that does not depend on + * {@code XSSFBComment}/{@code XSSFBRichTextString}/{@code XSSFRichTextString} + * (which pull in poi-ooxml-lite / xmlbeans via {@code CTRst}). + *

+ * Stores comments as plain author + text strings. + */ +class TikaXSSFBCommentsTable extends XSSFBParser { + + private final Map comments = new TreeMap<>(); + private final List authors = new ArrayList<>(); + + private int authorId = -1; + private int cellRow = -1; + private int cellCol = -1; + private String commentText; + private final StringBuilder buffer = new StringBuilder(); + + TikaXSSFBCommentsTable(InputStream is) throws IOException { + super(is); + parse(); + } + + @Override + public void handleRecord(int id, byte[] data) { + XSSFBRecordType recordType = XSSFBRecordType.lookup(id); + switch (recordType) { + case BrtBeginComment: + authorId = (int) LittleEndian.getUInt(data, 0); + // cell range: firstRow at offset 4, firstCol at offset 12 + cellRow = (int) LittleEndian.getUInt(data, LittleEndianConsts.INT_SIZE); + cellCol = (int) LittleEndian.getUInt(data, + LittleEndianConsts.INT_SIZE + 2 * LittleEndianConsts.INT_SIZE); + break; + case BrtCommentText: + buffer.setLength(0); + XSSFBUtils.readXLWideString(data, 1, buffer); + commentText = buffer.toString(); + break; + case BrtEndComment: + CellAddress addr = new CellAddress(cellRow, cellCol); + String author = (authorId >= 0 && authorId < authors.size()) + ? authors.get(authorId) : ""; + comments.put(addr, new CommentEntry(author, commentText)); + authorId = -1; + cellRow = -1; + cellCol = -1; + commentText = null; + break; + case BrtCommentAuthor: + buffer.setLength(0); + XSSFBUtils.readXLWideString(data, 0, buffer); + authors.add(buffer.toString()); + break; + default: + break; + } + } + + CommentEntry get(CellAddress cellAddress) { + return cellAddress == null ? null : comments.get(cellAddress); + } + + boolean hasComments() { + return !comments.isEmpty(); + } + + /** + * Emits all comments as cell content. Called after sheet processing + * since we bypass POI's built-in comment handling. + */ + void emitAllComments(XHTMLContentHandler xhtml) throws SAXException { + for (Map.Entry entry : comments.entrySet()) { + CommentEntry comment = entry.getValue(); + xhtml.startElement("p", "class", "cell-comment"); + String author = comment.getAuthor(); + if (author != null && !author.isEmpty()) { + xhtml.characters(author + ": "); + } + xhtml.characters(comment.getText()); + xhtml.endElement("p"); + } + } + + static class CommentEntry { + private final String author; + private final String text; + + CommentEntry(String author, String text) { + this.author = author; + this.text = text; + } + + String getAuthor() { + return author; + } + + String getText() { + return text; + } + } +} 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/TikaXSSFBSharedStringsTable.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaXSSFBSharedStringsTable.java new file mode 100644 index 00000000000..6e1ec853d50 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/TikaXSSFBSharedStringsTable.java @@ -0,0 +1,159 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.openxml4j.opc.PackagePart; +import org.apache.poi.ss.usermodel.Font; +import org.apache.poi.ss.usermodel.RichTextString; +import org.apache.poi.util.LittleEndian; +import org.apache.poi.xssf.binary.XSSFBParser; +import org.apache.poi.xssf.binary.XSSFBRecordType; +import org.apache.poi.xssf.binary.XSSFBUtils; +import org.apache.poi.xssf.model.SharedStrings; + +/** + * Replacement for POI's {@code XSSFBSharedStringsTable} that does not depend on + * {@code XSSFRichTextString} (which pulls in poi-ooxml-lite / xmlbeans via {@code CTRst}). + *

+ * The binary parsing logic is identical to POI's implementation; only + * {@link #getItemAt(int)} is changed to return a lightweight {@link RichTextString} + * wrapper instead of {@code XSSFRichTextString}. + */ +class TikaXSSFBSharedStringsTable implements SharedStrings { + + private static final String SHARED_STRINGS_BINARY_CT = + "application/vnd.ms-excel.sharedStrings"; + + private int count; + private int uniqueCount; + private final List strings = new ArrayList<>(); + + TikaXSSFBSharedStringsTable(OPCPackage pkg) throws IOException { + ArrayList parts = + pkg.getPartsByContentType(SHARED_STRINGS_BINARY_CT); + if (!parts.isEmpty()) { + PackagePart sstPart = parts.get(0); + try (InputStream stream = sstPart.getInputStream()) { + readFrom(stream); + } + } + } + + private void readFrom(InputStream inputStream) throws IOException { + new SSTBinaryReader(inputStream).parse(); + } + + @Override + public RichTextString getItemAt(int idx) { + return new PlainRichTextString(strings.get(idx)); + } + + @Override + public int getCount() { + return count; + } + + @Override + public int getUniqueCount() { + return uniqueCount; + } + + private class SSTBinaryReader extends XSSFBParser { + + SSTBinaryReader(InputStream is) { + super(is); + } + + @Override + public void handleRecord(int recordType, byte[] data) { + XSSFBRecordType type = XSSFBRecordType.lookup(recordType); + switch (type) { + case BrtSstItem: + // Inline XSSFBRichStr.build() logic — that class is package-private in POI + StringBuilder sb = new StringBuilder(); + XSSFBUtils.readXLWideString(data, 1, sb); + strings.add(sb.toString()); + break; + case BrtBeginSst: + count = (int) LittleEndian.getUInt(data, 0); + uniqueCount = (int) LittleEndian.getUInt(data, 4); + break; + default: + break; + } + } + } + + /** + * Minimal {@link RichTextString} that just wraps a plain string, + * avoiding the xmlbeans dependency in {@code XSSFRichTextString}. + */ + private static class PlainRichTextString implements RichTextString { + + private final String text; + + PlainRichTextString(String text) { + this.text = text; + } + + @Override + public String getString() { + return text; + } + + @Override + public int length() { + return text == null ? 0 : text.length(); + } + + @Override + public int numFormattingRuns() { + return 0; + } + + @Override + public int getIndexOfFormattingRun(int index) { + return 0; + } + + @Override + public void applyFont(int startIndex, int endIndex, short fontIndex) { + } + + @Override + public void applyFont(int startIndex, int endIndex, Font font) { + } + + @Override + public void applyFont(Font font) { + } + + @Override + public void clearFormatting() { + } + + @Override + public void applyFont(short fontIndex) { + } + } +} 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/VSDXExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/VSDXExtractorDecorator.java new file mode 100644 index 00000000000..197ccf6eda3 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/VSDXExtractorDecorator.java @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + +import org.apache.poi.ooxml.extractor.POIXMLTextExtractor; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; +import org.apache.poi.openxml4j.opc.OPCPackage; +import org.apache.poi.openxml4j.opc.PackagePart; +import org.apache.poi.openxml4j.opc.PackageRelationship; +import org.apache.poi.openxml4j.opc.PackageRelationshipCollection; +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.parser.ParseContext; +import org.apache.tika.sax.XHTMLContentHandler; +import org.apache.tika.utils.XMLReaderUtils; + +/** + * SAX-based extractor for Visio OOXML (.vsdx) files. + * Extracts text from {@code } elements inside shapes on each page. + */ +public class VSDXExtractorDecorator extends AbstractOOXMLExtractor { + + private static final String VISIO_DOCUMENT_REL = + "http://schemas.microsoft.com/visio/2010/relationships/document"; + private static final String VISIO_PAGES_REL = + "http://schemas.microsoft.com/visio/2010/relationships/pages"; + private static final String VISIO_PAGE_REL = + "http://schemas.microsoft.com/visio/2010/relationships/page"; + + private final ParseContext context; + + public VSDXExtractorDecorator(ParseContext context, POIXMLTextExtractor extractor) { + //keep the 3x extractor-based ctor (factory + AbstractOOXMLExtractor unchanged); + //the body reads from the base opcPackage, derived from the extractor + super(context, extractor); + this.context = context; + } + + @Override + protected void buildXHTML(XHTMLContentHandler xhtml) + throws SAXException, IOException { + try { + List pageParts = getPageParts(); + for (PackagePart pagePart : pageParts) { + xhtml.startElement("div", "class", "page"); + try (InputStream is = pagePart.getInputStream()) { + XMLReaderUtils.parseSAX(is, new VisioPageHandler(xhtml), context); + } catch (TikaException e) { + throw new SAXException(e); + } + xhtml.endElement("div"); + } + } catch (InvalidFormatException e) { + throw new SAXException("Error reading VSDX pages", e); + } + } + + private List getPageParts() throws InvalidFormatException { + // Root -> visio/document.xml + PackagePart documentPart = getRelatedPart(opcPackage, VISIO_DOCUMENT_REL); + if (documentPart == null) { + return Collections.emptyList(); + } + + // document.xml -> pages/pages.xml + PackagePart pagesPart = getRelatedPart(documentPart, VISIO_PAGES_REL); + if (pagesPart == null) { + return Collections.emptyList(); + } + + // pages.xml -> page1.xml, page2.xml, ... + List pageParts = new ArrayList<>(); + PackageRelationshipCollection pageRels = + pagesPart.getRelationshipsByType(VISIO_PAGE_REL); + for (PackageRelationship rel : pageRels) { + PackagePart pagePart = pagesPart.getRelatedPart(rel); + if (pagePart != null) { + pageParts.add(pagePart); + } + } + return pageParts; + } + + private PackagePart getRelatedPart(OPCPackage pkg, String relType) + throws InvalidFormatException { + PackageRelationshipCollection rels = pkg.getRelationshipsByType(relType); + if (rels.isEmpty()) { + return null; + } + return pkg.getPart(rels.getRelationship(0)); + } + + private PackagePart getRelatedPart(PackagePart part, String relType) + throws InvalidFormatException { + PackageRelationshipCollection rels = part.getRelationshipsByType(relType); + if (rels.isEmpty()) { + return null; + } + return part.getRelatedPart(rels.getRelationship(0)); + } + + @Override + protected List getMainDocumentParts() { + return Collections.emptyList(); + } + + /** + * SAX handler for Visio page XML. Extracts text from {@code } + * elements inside {@code } elements. + */ + private static class VisioPageHandler extends DefaultHandler { + + private static final String VISIO_NS = + "http://schemas.microsoft.com/office/visio/2012/main"; + + private final XHTMLContentHandler xhtml; + private boolean inText; + private final StringBuilder textBuffer = new StringBuilder(); + + VisioPageHandler(XHTMLContentHandler xhtml) { + this.xhtml = xhtml; + } + + @Override + public void startElement(String uri, String localName, String qName, + Attributes atts) { + if ("Text".equals(localName) && VISIO_NS.equals(uri)) { + inText = true; + textBuffer.setLength(0); + } + } + + @Override + public void endElement(String uri, String localName, String qName) + throws SAXException { + if ("Text".equals(localName) && VISIO_NS.equals(uri)) { + inText = false; + String text = textBuffer.toString().trim(); + if (!text.isEmpty()) { + xhtml.startElement("p"); + xhtml.characters(text); + xhtml.endElement("p"); + } + } + } + + @Override + public void characters(char[] ch, int start, int length) { + if (inText) { + textBuffer.append(ch, start, length); + } + } + } +} 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/XSSFBExcelExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFBExcelExtractorDecorator.java index 77000b9a9ab..f9e87301d8d 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFBExcelExtractorDecorator.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFBExcelExtractorDecorator.java @@ -18,27 +18,27 @@ import java.io.IOException; import java.io.InputStream; -import java.util.List; import java.util.Locale; import org.apache.poi.ooxml.extractor.POIXMLTextExtractor; +import org.apache.poi.openxml4j.exceptions.InvalidFormatException; import org.apache.poi.openxml4j.exceptions.OpenXML4JException; import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.poi.openxml4j.opc.PackagePart; -import org.apache.poi.xssf.binary.XSSFBCommentsTable; -import org.apache.poi.xssf.binary.XSSFBSharedStringsTable; +import org.apache.poi.openxml4j.opc.PackagePartName; +import org.apache.poi.openxml4j.opc.PackageRelationship; +import org.apache.poi.openxml4j.opc.PackageRelationshipCollection; +import org.apache.poi.openxml4j.opc.PackagingURIHelper; import org.apache.poi.xssf.binary.XSSFBSheetHandler; import org.apache.poi.xssf.binary.XSSFBStylesTable; import org.apache.poi.xssf.eventusermodel.XSSFBReader; -import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler.SheetContentsHandler; -import org.apache.poi.xssf.extractor.XSSFBEventBasedExcelExtractor; -import org.apache.poi.xssf.usermodel.XSSFShape; import org.apache.xmlbeans.XmlException; import org.xml.sax.ContentHandler; import org.xml.sax.SAXException; import org.apache.tika.exception.TikaException; import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.Office; import org.apache.tika.metadata.TikaCoreProperties; import org.apache.tika.parser.ParseContext; import org.apache.tika.sax.XHTMLContentHandler; @@ -50,17 +50,9 @@ public XSSFBExcelExtractorDecorator(ParseContext context, POIXMLTextExtractor ex super(context, extractor, locale); } - @Override - protected void configureExtractor(POIXMLTextExtractor extractor, Locale locale) { - //need to override this because setFormulasNotResults is not yet available - //for xlsb - //((XSSFBEventBasedExcelExtractor)extractor).setFormulasNotResults(false); - ((XSSFBEventBasedExcelExtractor) extractor).setLocale(locale); - } - @Override public void getXHTML(ContentHandler handler, Metadata metadata, ParseContext context) - throws SAXException, XmlException, IOException, TikaException { + throws SAXException, IOException, TikaException, XmlException { this.metadata = metadata; this.parseContext = context; @@ -69,15 +61,12 @@ public void getXHTML(ContentHandler handler, Metadata metadata, ParseContext con super.getXHTML(handler, metadata, context); } - /** - * @see org.apache.poi.xssf.extractor.XSSFBEventBasedExcelExtractor#getText() - */ @Override protected void buildXHTML(XHTMLContentHandler xhtml) - throws SAXException, XmlException, IOException { - OPCPackage container = extractor.getPackage(); + throws SAXException, IOException { + OPCPackage container = opcPackage; - XSSFBSharedStringsTable strings; + TikaXSSFBSharedStringsTable strings; XSSFBReader.SheetIterator iter; XSSFBReader xssfReader; XSSFBStylesTable styles; @@ -89,9 +78,9 @@ protected void buildXHTML(XHTMLContentHandler xhtml) } styles = xssfReader.getXSSFBStylesTable(); iter = (XSSFBReader.SheetIterator) xssfReader.getSheetsData(); - strings = new XSSFBSharedStringsTable(container); + strings = new TikaXSSFBSharedStringsTable(container); } catch (OpenXML4JException e) { - throw new XmlException(e); + throw new IOException(e); } while (iter.hasNext()) { @@ -101,44 +90,71 @@ protected void buildXHTML(XHTMLContentHandler xhtml) sheetParts.add(sheetPart); SheetTextAsHTML sheetExtractor = new SheetTextAsHTML(config, xhtml); - XSSFBCommentsTable comments = iter.getXSSFBSheetComments(); - // Start, and output the sheet name + // Parse comments with our own binary parser that avoids xmlbeans + TikaXSSFBCommentsTable tikaComments = parseBinaryComments(sheetPart); + if (tikaComments != null && tikaComments.hasComments()) { + metadata.set(Office.HAS_COMMENTS, true); + } + xhtml.startElement("div"); xhtml.element("h1", iter.getSheetName()); - // Extract the main sheet contents xhtml.startElement("table"); xhtml.startElement("tbody"); - processSheet(sheetExtractor, comments, styles, strings, stream); + // Pass null for POI's comments table to avoid xmlbeans dependency. + // Comments are emitted separately after sheet processing. + XSSFBSheetHandler xssfbSheetHandler = + new XSSFBSheetHandler(stream, styles, null, strings, + sheetExtractor, formatter, false); + xssfbSheetHandler.parse(); xhtml.endElement("tbody"); xhtml.endElement("table"); - // Output any headers and footers - // (Need to process the sheet to get them, so we can't - // do the headers before the contents) + // Emit comments after the table (since we bypass POI's inline + // comment handling to avoid xmlbeans dependency) + if (tikaComments != null) { + tikaComments.emitAllComments(xhtml); + } + for (String header : sheetExtractor.headers) { extractHeaderFooter(header, xhtml); } for (String footer : sheetExtractor.footers) { extractHeaderFooter(footer, xhtml); } - List shapes = iter.getShapes(); - - processShapes(shapes, xhtml); - - //for now dump sheet hyperlinks at bottom of page - //consider a double-pass of the inputstream to reunite hyperlinks with cells/textboxes - //step 1: extract hyperlink info from bottom of page - //step 2: process as we do now, but with cached hyperlink relationship info + processDrawings(sheetPart, xhtml); extractHyperLinks(sheetPart, xhtml); - // All done with this sheet xhtml.endElement("div"); } } + private static final String RELATION_COMMENTS = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; + + private TikaXSSFBCommentsTable parseBinaryComments(PackagePart sheetPart) { + try { + PackageRelationshipCollection rels = + sheetPart.getRelationshipsByType(RELATION_COMMENTS); + if (rels.isEmpty()) { + return null; + } + PackageRelationship rel = rels.getRelationship(0); + PackagePartName partName = + PackagingURIHelper.createPartName(rel.getTargetURI()); + PackagePart commentsPart = rel.getPackage().getPart(partName); + if (commentsPart == null) { + return null; + } + try (InputStream is = commentsPart.getInputStream()) { + return new TikaXSSFBCommentsTable(is); + } + } catch (InvalidFormatException | IOException e) { + return null; + } + } @Override protected void extractHeaderFooter(String hf, XHTMLContentHandler xhtml) throws SAXException { @@ -146,16 +162,4 @@ protected void extractHeaderFooter(String hf, XHTMLContentHandler xhtml) throws xhtml.element("p", hf); } } - - - private void processSheet(SheetContentsHandler sheetContentsExtractor, - XSSFBCommentsTable comments, XSSFBStylesTable styles, - XSSFBSharedStringsTable strings, InputStream sheetInputStream) - throws IOException, SAXException { - - XSSFBSheetHandler xssfbSheetHandler = - new XSSFBSheetHandler(sheetInputStream, styles, comments, strings, - sheetContentsExtractor, formatter, false); - xssfbSheetHandler.parse(); - } } 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/XSSFCommentsShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFCommentsShim.java new file mode 100644 index 00000000000..f3293a0d3c5 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFCommentsShim.java @@ -0,0 +1,187 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.apache.poi.ss.util.CellAddress; +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.parser.ParseContext; +import org.apache.tika.utils.XMLReaderUtils; + +/** + * SAX-based shim that parses {@code xl/commentsN.xml} without XMLBeans. + * Replaces POI's {@code CommentsTable} (which depends on poi-ooxml-lite) + * for Tika's text extraction needs. + * + *

Only extracts what Tika needs: cell reference → (author, text) mapping.

+ */ +class XSSFCommentsShim { + + private final Map commentsByCell; + + /** + * Simple holder for comment data needed by Tika. + */ + static class CommentData { + private final String author; + private final String text; + + CommentData(String author, String text) { + this.author = author; + this.text = text; + } + + public String getAuthor() { + return author; + } + + public String getText() { + return text; + } + } + + /** + * Parse a comments XML stream. + * + * @param is the {@code xl/commentsN.xml} stream (may be null) + * @param parseContext parse context for SAX parser configuration + */ + XSSFCommentsShim(InputStream is, ParseContext parseContext) + throws IOException, TikaException, SAXException { + commentsByCell = new LinkedHashMap<>(); + if (is != null) { + CommentsHandler handler = new CommentsHandler(); + XMLReaderUtils.parseSAX(is, handler, parseContext); + } + } + + /** + * @return the number of comments parsed + */ + int getNumberOfComments() { + return commentsByCell.size(); + } + + /** + * Find comment data for a given cell address. + * + * @return CommentData or null if no comment at that cell + */ + CommentData findCellComment(CellAddress cellAddress) { + return commentsByCell.get(cellAddress); + } + + /** + * @return iterator over all cell addresses that have comments, in document order + */ + Iterator getCellAddresses() { + return commentsByCell.keySet().iterator(); + } + + /** + * SAX handler for comments XML. Structure: + *
+     * <comments>
+     *   <authors>
+     *     <author>Name</author>
+     *   </authors>
+     *   <commentList>
+     *     <comment ref="A1" authorId="0">
+     *       <text>
+     *         <r><t>Comment text</t></r>
+     *         or plain <t>Comment text</t>
+     *       </text>
+     *     </comment>
+     *   </commentList>
+     * </comments>
+     * 
+ */ + private class CommentsHandler extends DefaultHandler { + + private final List authors = new ArrayList<>(); + private final StringBuilder textBuffer = new StringBuilder(); + + private boolean inAuthor; + private boolean inT; + private boolean inText; + + private String currentRef; + private int currentAuthorId; + private final StringBuilder commentText = new StringBuilder(); + + @Override + public void startElement(String uri, String localName, String qName, + Attributes atts) { + if ("author".equals(localName)) { + inAuthor = true; + textBuffer.setLength(0); + } else if ("comment".equals(localName)) { + currentRef = atts.getValue("ref"); + String authorIdStr = atts.getValue("authorId"); + currentAuthorId = authorIdStr != null ? Integer.parseInt(authorIdStr) : -1; + commentText.setLength(0); + } else if ("text".equals(localName)) { + inText = true; + } else if ("t".equals(localName) && inText) { + inT = true; + textBuffer.setLength(0); + } + } + + @Override + public void endElement(String uri, String localName, String qName) { + if ("author".equals(localName)) { + inAuthor = false; + authors.add(textBuffer.toString()); + } else if ("t".equals(localName) && inT) { + inT = false; + if (commentText.length() > 0) { + commentText.append(' '); + } + commentText.append(textBuffer); + } else if ("text".equals(localName)) { + inText = false; + } else if ("comment".equals(localName)) { + if (currentRef != null) { + String author = (currentAuthorId >= 0 && currentAuthorId < authors.size()) + ? authors.get(currentAuthorId) : ""; + commentsByCell.put(new CellAddress(currentRef), + new CommentData(author, commentText.toString())); + } + currentRef = null; + } + } + + @Override + public void characters(char[] ch, int start, int length) { + if (inAuthor || inT) { + textBuffer.append(ch, start, length); + } + } + } +} 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/XSSFExcelExtractorDecorator.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFExcelExtractorDecorator.java index fe63fe156d3..194d206a0e2 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFExcelExtractorDecorator.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFExcelExtractorDecorator.java @@ -21,11 +21,9 @@ import java.io.InputStream; import java.util.ArrayList; import java.util.HashMap; -import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; -import java.util.Set; import org.apache.poi.hssf.extractor.ExcelExtractor; import org.apache.poi.ooxml.extractor.POIXMLTextExtractor; @@ -42,24 +40,11 @@ import org.apache.poi.ss.usermodel.DataFormatter; import org.apache.poi.ss.usermodel.HeaderFooter; import org.apache.poi.ss.util.CellReference; -import org.apache.poi.xssf.eventusermodel.ReadOnlySharedStringsTable; import org.apache.poi.xssf.eventusermodel.XSSFReader; -import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler; import org.apache.poi.xssf.eventusermodel.XSSFSheetXMLHandler.SheetContentsHandler; -import org.apache.poi.xssf.extractor.XSSFEventBasedExcelExtractor; -import org.apache.poi.xssf.model.Comments; -import org.apache.poi.xssf.model.StylesTable; import org.apache.poi.xssf.usermodel.XSSFComment; -import org.apache.poi.xssf.usermodel.XSSFDrawing; -import org.apache.poi.xssf.usermodel.XSSFRelation; -import org.apache.poi.xssf.usermodel.XSSFShape; -import org.apache.poi.xssf.usermodel.XSSFSimpleShape; import org.apache.poi.xssf.usermodel.helpers.HeaderFooterHelper; import org.apache.xmlbeans.XmlException; -import org.openxmlformats.schemas.drawingml.x2006.main.CTHyperlink; -import org.openxmlformats.schemas.drawingml.x2006.main.CTNonVisualDrawingProps; -import org.openxmlformats.schemas.drawingml.x2006.spreadsheetDrawing.CTShape; -import org.openxmlformats.schemas.drawingml.x2006.spreadsheetDrawing.CTShapeNonVisual; import org.xml.sax.Attributes; import org.xml.sax.ContentHandler; import org.xml.sax.Locator; @@ -68,6 +53,7 @@ import org.apache.tika.exception.RuntimeSAXException; import org.apache.tika.exception.TikaException; +import org.apache.tika.exception.WriteLimitReachedException; import org.apache.tika.metadata.Metadata; import org.apache.tika.metadata.Office; import org.apache.tika.metadata.TikaCoreProperties; @@ -75,6 +61,7 @@ import org.apache.tika.parser.microsoft.OfficeParserConfig; import org.apache.tika.parser.microsoft.TikaExcelDataFormatter; import org.apache.tika.sax.XHTMLContentHandler; +import org.apache.tika.utils.ExceptionUtils; import org.apache.tika.utils.StringUtils; import org.apache.tika.utils.XMLReaderUtils; @@ -92,6 +79,20 @@ public class XSSFExcelExtractorDecorator extends AbstractOOXMLExtractor { // Power Query stores data in customData parts private static final String POWER_QUERY_CONTENT_TYPE = "application/vnd.ms-excel.customDataProperties+xml"; + private static final String RELATION_DRAWING = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/drawing"; + private static final String RELATION_CHART = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/chart"; + private static final String RELATION_HYPERLINK = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"; + private static final String NS_DRAWING_ML = + "http://schemas.openxmlformats.org/drawingml/2006/main"; + private static final String NS_RELATIONSHIPS = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships"; + private static final String RELATION_VML_DRAWING = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing"; + private static final String RELATION_COMMENTS = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"; /** * Allows access to headers/footers from raw xml strings @@ -105,11 +106,11 @@ public class XSSFExcelExtractorDecorator extends AbstractOOXMLExtractor { public XSSFExcelExtractorDecorator(ParseContext context, POIXMLTextExtractor extractor, Locale locale) { + //keep the 3x extractor-based ctor so the factory and AbstractOOXMLExtractor are + //unchanged; the base derives opcPackage from the extractor (used by the body below) super(context, extractor); this.parseContext = context; - this.extractor = (XSSFEventBasedExcelExtractor) extractor; - configureExtractor(this.extractor, locale); if (locale == null) { formatter = new TikaExcelDataFormatter(); @@ -123,19 +124,14 @@ public XSSFExcelExtractorDecorator(ParseContext context, POIXMLTextExtractor ext } } - protected void configureExtractor(POIXMLTextExtractor extractor, Locale locale) { - ((XSSFEventBasedExcelExtractor) extractor) - .setIncludeTextBoxes(config.isIncludeShapeBasedContent()); - ((XSSFEventBasedExcelExtractor) extractor).setFormulasNotResults(false); - ((XSSFEventBasedExcelExtractor) extractor).setLocale(locale); - //given that we load our own shared strings table, setting: - //((XSSFEventBasedExcelExtractor)extractor).setConcatenatePhoneticRuns(); - //does no good here. + @Override + public MetadataExtractor getMetadataExtractor() { + return new SAXBasedMetadataExtractor(opcPackage, parseContext); } @Override public void getXHTML(ContentHandler handler, Metadata metadata, ParseContext context) - throws SAXException, XmlException, IOException, TikaException { + throws SAXException, IOException, TikaException, XmlException { this.metadata = metadata; this.parseContext = context; @@ -149,34 +145,66 @@ public void getXHTML(ContentHandler handler, Metadata metadata, ParseContext con */ @Override protected void buildXHTML(XHTMLContentHandler xhtml) - throws SAXException, XmlException, IOException { - OPCPackage container = extractor.getPackage(); + throws SAXException, IOException { + OPCPackage container = opcPackage; - ReadOnlySharedStringsTable strings; + XSSFSharedStringsShim stringsShim = null; XSSFReader.SheetIterator iter; XSSFReader xssfReader; - StylesTable styles; + XSSFStylesShim stylesShim = null; try { xssfReader = new XSSFReader(container); - styles = xssfReader.getStylesTable(); - iter = (XSSFReader.SheetIterator) xssfReader.getSheetsData(); - strings = new ReadOnlySharedStringsTable(container, config.isConcatenatePhoneticRuns()); - } catch (OpenXML4JException e) { - throw new XmlException(e); + } catch (OpenXML4JException | RuntimeException e) { + throw new IOException(e); } - - while (iter.hasNext()) { + // Styles and shared strings are optional — if either part is missing or + // unreadable, log to metadata and continue with degraded extraction. + try { + stylesShim = new XSSFStylesShim(xssfReader.getStylesData(), parseContext); + } catch (Exception e) { + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + } + try { + stringsShim = new XSSFSharedStringsShim(xssfReader.getSharedStringsData(), + config.isConcatenatePhoneticRuns(), parseContext); + } catch (Exception e) { + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + } + while (true) { + try { + if (!iter.hasNext()) { + break; + } + } catch (RuntimeException e) { + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + break; + } SheetTextAsHTML sheetExtractor = new SheetTextAsHTML(config, xhtml); PackagePart sheetPart = null; - try (InputStream stream = iter.next()) { + InputStream nextStream; + try { + nextStream = iter.next(); + } catch (RuntimeException e) { + // POI can throw POIXMLException for missing sheet parts (e.g., + // truncated workbook references a sheet that isn't in the zip). + // Break rather than continue — POI's iterator state may not have + // advanced, which would cause an infinite loop. + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + break; + } + try (InputStream stream = nextStream) { sheetPart = iter.getSheetPart(); addDrawingHyperLinks(sheetPart); sheetParts.add(sheetPart); - Comments comments = iter.getSheetComments(); - if (comments != null && comments.getNumberOfComments() > 0) { + XSSFCommentsShim commentsShim = parseSheetComments(sheetPart); + if (commentsShim != null && commentsShim.getNumberOfComments() > 0) { metadata.set(Office.HAS_COMMENTS, true); } @@ -188,7 +216,26 @@ protected void buildXHTML(XHTMLContentHandler xhtml) xhtml.startElement("table"); xhtml.startElement("tbody"); - processSheet(sheetExtractor, comments, styles, strings, stream); + try { + processSheet(sheetExtractor, commentsShim, stylesShim, stringsShim, stream); + } catch (SAXException e) { + // Truncated/malformed sheet XML — keep prior sheets and + // record the failure as a warning. + WriteLimitReachedException.throwIfWriteLimitReached(e); + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + // Balance any
/
left open by the partial parse so + // the
emitted below land in the + // right place. + sheetExtractor.closeAnyPending(); + } catch (IOException e) { + // Truncated stream — same risk: partial / still + // open. Close them so the surrounding + // stays balanced, record the failure, and keep going. + metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING, + ExceptionUtils.getStackTrace(e)); + sheetExtractor.closeAnyPending(); + } try { getThreadedComments(container, sheetPart, xhtml); } catch (InvalidFormatException | TikaException | IOException e) { @@ -210,8 +257,7 @@ protected void buildXHTML(XHTMLContentHandler xhtml) // Do text held in shapes, if required if (config.isIncludeShapeBasedContent()) { - List shapes = iter.getShapes(); - processShapes(shapes, xhtml); + processDrawings(sheetPart, xhtml); } //for now dump sheet hyperlinks at bottom of page @@ -670,7 +716,7 @@ private void getPersons(OPCPackage container, Metadata metadata) throws TikaExce protected void addDrawingHyperLinks(PackagePart sheetPart) { try { for (PackageRelationship rel : sheetPart - .getRelationshipsByType(XSSFRelation.DRAWINGS.getRelation())) { + .getRelationshipsByType(RELATION_DRAWING)) { if (rel.getTargetMode() == TargetMode.INTERNAL) { PackagePartName relName = PackagingURIHelper.createPartName(rel.getTargetURI()); PackagePart part = rel.getPackage().getPart(relName); @@ -679,7 +725,7 @@ protected void addDrawingHyperLinks(PackagePart sheetPart) { continue; } for (PackageRelationship drawRel : part - .getRelationshipsByType(XSSFRelation.SHEET_HYPERLINKS.getRelation())) { + .getRelationshipsByType(RELATION_HYPERLINK)) { drawingHyperlinks.put(drawRel.getId(), drawRel.getTargetURI().toString()); } } @@ -696,8 +742,13 @@ protected void addDrawingHyperLinks(PackagePart sheetPart) { protected void extractHyperLinks(PackagePart sheetPart, XHTMLContentHandler xhtml) throws SAXException { try { + boolean first = true; for (PackageRelationship rel : sheetPart - .getRelationshipsByType(XSSFRelation.SHEET_HYPERLINKS.getRelation())) { + .getRelationshipsByType(RELATION_HYPERLINK)) { + if (!first) { + xhtml.characters(" "); + } + first = false; xhtml.startElement("a", "href", rel.getTargetURI().toString()); xhtml.characters(rel.getTargetURI().toString()); xhtml.endElement("a"); @@ -714,101 +765,125 @@ protected void extractHeaderFooter(String hf, XHTMLContentHandler xhtml) throws } } - protected void processShapes(List shapes, XHTMLContentHandler xhtml) + protected void processDrawings(PackagePart sheetPart, XHTMLContentHandler xhtml) throws SAXException { - if (shapes == null) { - return; - } - //We don't currently have an obvious way to get drawings - //directly from sheetIter. Therefore, we grab the shapes and process those. - //To get the diagrams and charts, we need to get the parent drawing for each - //shape, and we need to make sure that we only process each parent shape once! - //SEE TIKA-2703 TODO: add unit test - Set seenParentDrawings = new HashSet<>(); - for (XSSFShape shape : shapes) { - if (shape instanceof XSSFSimpleShape) { - String sText = ((XSSFSimpleShape) shape).getText(); - if (sText != null && sText.length() > 0) { - xhtml.element("p", sText); + try { + for (PackageRelationship rel : sheetPart + .getRelationshipsByType(RELATION_DRAWING)) { + if (rel.getTargetMode() != TargetMode.INTERNAL) { + continue; } - extractHyperLinksFromShape(((XSSFSimpleShape) shape).getCTShape(), xhtml); - } - - XSSFDrawing parentDrawing = shape.getDrawing(); - if (parentDrawing != null) { - if (!seenParentDrawings - .contains(parentDrawing.getPackagePart().getPartName().toString())) { - //dump diagram data - handleGeneralTextContainingPart(AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA, - "diagram-data", parentDrawing.getPackagePart(), metadata, - new OOXMLWordAndPowerPointTextHandler( - new OOXMLTikaBodyPartHandler(xhtml), - new HashMap<>()//empty - )); - //dump chart data - handleGeneralTextContainingPart(XSSFRelation.CHART.getRelation(), "chart", - parentDrawing.getPackagePart(), metadata, - new OOXMLWordAndPowerPointTextHandler( - new OOXMLTikaBodyPartHandler(xhtml), - new HashMap<>()//empty - )); + PackagePartName relName = + PackagingURIHelper.createPartName(rel.getTargetURI()); + PackagePart drawingPart = rel.getPackage().getPart(relName); + if (drawingPart == null) { + continue; + } + // SAX-parse drawing XML for shape text and hyperlinks + try (InputStream is = drawingPart.getInputStream()) { + XMLReaderUtils.parseSAX(is, + new DrawingShapeHandler(xhtml, drawingHyperlinks), + parseContext); + } catch (IOException | TikaException e) { + //swallow } - seenParentDrawings.add(parentDrawing.getPackagePart().getPartName().toString()); + // Process diagram and chart data through drawing part relationships + handleGeneralTextContainingPart( + AbstractOOXMLExtractor.RELATION_DIAGRAM_DATA, + "diagram-data", drawingPart, metadata, + new OOXMLWordAndPowerPointTextHandler( + new OOXMLTikaBodyPartHandler(xhtml), + new HashMap<>())); + handleGeneralTextContainingPart(RELATION_CHART, "chart", + drawingPart, metadata, + new OOXMLWordAndPowerPointTextHandler( + new OOXMLTikaBodyPartHandler(xhtml), + new HashMap<>())); } + } catch (InvalidFormatException e) { + //swallow } } - private void extractHyperLinksFromShape(CTShape ctShape, XHTMLContentHandler xhtml) - throws SAXException { - - if (ctShape == null) { - return; - } + /** + * SAX handler for drawing XML that extracts shape text and hyperlinks + * without requiring XMLBeans or the POI usermodel (XSSFShape, etc.). + */ + private static class DrawingShapeHandler extends DefaultHandler { - CTShapeNonVisual nvSpPR = ctShape.getNvSpPr(); - if (nvSpPR == null) { - return; - } + private final XHTMLContentHandler xhtml; + private final Map hyperlinks; - CTNonVisualDrawingProps cNvPr = nvSpPR.getCNvPr(); - if (cNvPr == null) { - return; - } + private boolean inTxBody; + private boolean inT; + private final StringBuilder textBuffer = new StringBuilder(); + private final StringBuilder shapeText = new StringBuilder(); - CTHyperlink ctHyperlink = cNvPr.getHlinkClick(); - if (ctHyperlink == null) { - return; + DrawingShapeHandler(XHTMLContentHandler xhtml, Map hyperlinks) { + this.xhtml = xhtml; + this.hyperlinks = hyperlinks; } - String url = drawingHyperlinks.get(ctHyperlink.getId()); - if (url != null) { - xhtml.startElement("a", "href", url); - xhtml.characters(url); - xhtml.endElement("a"); + @Override + public void startElement(String uri, String localName, String qName, + Attributes atts) throws SAXException { + if ("txBody".equals(localName)) { + inTxBody = true; + shapeText.setLength(0); + } else if ("t".equals(localName) && inTxBody) { + inT = true; + textBuffer.setLength(0); + } else if ("hlinkClick".equals(localName) || "hlinkHover".equals(localName)) { + String rId = atts.getValue(NS_RELATIONSHIPS, "id"); + if (rId == null) { + // try non-namespace-aware fallback + rId = atts.getValue("r:id"); + } + if (rId != null) { + String url = hyperlinks.get(rId); + if (url != null) { + xhtml.startElement("a", "href", url); + xhtml.characters(url); + xhtml.endElement("a"); + } + } + } } - CTHyperlink ctHoverHyperlink = cNvPr.getHlinkHover(); - if (ctHoverHyperlink == null) { - return; + @Override + public void endElement(String uri, String localName, String qName) + throws SAXException { + if ("t".equals(localName) && inT) { + inT = false; + shapeText.append(textBuffer); + } else if ("p".equals(localName) && inTxBody && + shapeText.length() > 0) { + shapeText.append('\n'); + } else if ("txBody".equals(localName)) { + inTxBody = false; + String text = shapeText.toString().trim(); + if (!text.isEmpty()) { + xhtml.element("p", text); + } + } } - url = drawingHyperlinks.get(ctHoverHyperlink.getId()); - if (url != null) { - xhtml.startElement("a", "href", url); - xhtml.characters(url); - xhtml.endElement("a"); + @Override + public void characters(char[] ch, int start, int length) { + if (inT) { + textBuffer.append(ch, start, length); + } } - } - public void processSheet(SheetContentsHandler sheetContentsHandler, Comments comments, - StylesTable styles, ReadOnlySharedStringsTable strings, + public void processSheet(TikaSheetContentsHandler sheetContentsHandler, + XSSFCommentsShim commentsShim, + XSSFStylesShim stylesShim, XSSFSharedStringsShim stringsShim, InputStream sheetInputStream) throws IOException, SAXException { try { - XSSFSheetInterestingPartsCapturer handler = new XSSFSheetInterestingPartsCapturer( - new XSSFSheetXMLHandler(styles, comments, strings, sheetContentsHandler, - formatter, false)); + new TikaSheetXMLHandler(stylesShim, commentsShim, stringsShim, + sheetContentsHandler, formatter, false)); XMLReaderUtils.parseSAX(sheetInputStream, handler, parseContext); sheetInputStream.close(); @@ -826,6 +901,33 @@ public void processSheet(SheetContentsHandler sheetContentsHandler, Comments com } } + /** + * Parse the comments XML for a sheet part via SAX, avoiding XMLBeans. + */ + private XSSFCommentsShim parseSheetComments(PackagePart sheetPart) { + try { + PackageRelationshipCollection rels = + sheetPart.getRelationshipsByType(RELATION_COMMENTS); + if (rels.isEmpty()) { + return null; + } + PackageRelationship rel = rels.getRelationship(0); + PackagePartName partName = + PackagingURIHelper.createPartName(rel.getTargetURI()); + PackagePart commentsPart = rel.getPackage().getPart(partName); + if (commentsPart == null) { + return null; + } + try (InputStream is = commentsPart.getInputStream()) { + return new XSSFCommentsShim(is, parseContext); + } + } catch (InvalidFormatException | IOException | TikaException | SAXException e) { + //swallow — comments are not critical + return null; + } + } + + /** * In Excel files, sheets have things embedded in them, * and sheet drawings which have the images @@ -833,26 +935,33 @@ public void processSheet(SheetContentsHandler sheetContentsHandler, Comments com @Override protected List getMainDocumentParts() throws TikaException { List parts = new ArrayList<>(); + // The sheet order in sheetParts mirrors the workbook's sheet + // ordering (populated in buildXHTML), so the index here is the + // 1-based sheet number. + int sheetNumber = 0; for (PackagePart part : sheetParts) { + sheetNumber++; // Add the sheet parts.add(part); // If it has drawings, return those too try { for (PackageRelationship rel : part - .getRelationshipsByType(XSSFRelation.DRAWINGS.getRelation())) { + .getRelationshipsByType(RELATION_DRAWING)) { if (rel.getTargetMode() == TargetMode.INTERNAL) { PackagePartName relName = PackagingURIHelper.createPartName(rel.getTargetURI()); - parts.add(rel.getPackage().getPart(relName)); + PackagePart drawingPart = rel.getPackage().getPart(relName); + parts.add(drawingPart); } } for (PackageRelationship rel : part - .getRelationshipsByType(XSSFRelation.VML_DRAWINGS.getRelation())) { + .getRelationshipsByType(RELATION_VML_DRAWING)) { if (rel.getTargetMode() == TargetMode.INTERNAL) { PackagePartName relName = PackagingURIHelper.createPartName(rel.getTargetURI()); - parts.add(rel.getPackage().getPart(relName)); + PackagePart vmlPart = rel.getPackage().getPart(relName); + parts.add(vmlPart); } } } catch (InvalidFormatException e) { @@ -862,7 +971,7 @@ protected List getMainDocumentParts() throws TikaException { //add main document so that macros can be extracted //by AbstractOOXMLExtractor - parts.addAll(extractor.getPackage() + parts.addAll(opcPackage .getPartsByRelationshipType(PackageRelationshipTypes.CORE_DOCUMENT)); return parts; @@ -871,7 +980,8 @@ protected List getMainDocumentParts() throws TikaException { /** * Turns formatted sheet events into HTML */ - protected static class SheetTextAsHTML implements SheetContentsHandler { + protected static class SheetTextAsHTML + implements TikaSheetContentsHandler, SheetContentsHandler { private final boolean includeHeadersFooters; private final boolean includeMissingRows; protected List headers; @@ -879,6 +989,12 @@ protected static class SheetTextAsHTML implements SheetContentsHandler { private XHTMLContentHandler xhtml; private int lastSeenRow = -1; private int lastSeenCol = -1; + // Track open / so the outer catch can emit balanced closes + // when processSheet throws part-way through a row (e.g., a malformed + // sheet XML). Without this, the outer code would emit + // while (or ) was still on the stack, producing malformed XHTML. + private boolean rowOpen; + private boolean cellOpen; protected SheetTextAsHTML(OfficeParserConfig config, XHTMLContentHandler xhtml) { this.includeHeadersFooters = config.isIncludeHeadersAndFooters(); @@ -894,14 +1010,19 @@ public void startRow(int rowNum) { if (includeMissingRows && rowNum > (lastSeenRow + 1)) { for (int rn = lastSeenRow + 1; rn < rowNum; rn++) { xhtml.startElement("tr"); + rowOpen = true; xhtml.startElement("td"); + cellOpen = true; xhtml.endElement("td"); + cellOpen = false; xhtml.endElement("tr"); + rowOpen = false; } } // Start the new row xhtml.startElement("tr"); + rowOpen = true; lastSeenCol = -1; } catch (SAXException e) { //swallow @@ -913,24 +1034,45 @@ public void startRow(int rowNum) { public void endRow(int rowNum) { try { xhtml.endElement("tr"); + rowOpen = false; } catch (SAXException e) { throw new RuntimeSAXException(e); } } - public void cell(String cellRef, String formattedValue, XSSFComment comment) { + /** + * Closes any pending {@code } or {@code } that was opened + * before a {@link SAXException} interrupted sheet processing. Safe to + * call when nothing is open. + */ + void closeAnyPending() throws SAXException { + if (cellOpen) { + xhtml.endElement("td"); + cellOpen = false; + } + if (rowOpen) { + xhtml.endElement("tr"); + rowOpen = false; + } + } + + public void cell(String cellRef, String formattedValue, + XSSFCommentsShim.CommentData comment) { try { // Handle any missing cells int colNum = (cellRef == null) ? lastSeenCol + 1 : (new CellReference(cellRef)).getCol(); for (int cn = lastSeenCol + 1; cn < colNum; cn++) { xhtml.startElement("td"); + cellOpen = true; xhtml.endElement("td"); + cellOpen = false; } lastSeenCol = colNum; // Start this cell xhtml.startElement("td"); + cellOpen = true; // Main cell contents if (formattedValue != null) { @@ -943,15 +1085,31 @@ public void cell(String cellRef, String formattedValue, XSSFComment comment) { xhtml.endElement("br"); xhtml.characters(comment.getAuthor()); xhtml.characters(": "); - xhtml.characters(comment.getString().getString()); + xhtml.characters(comment.getText()); } xhtml.endElement("td"); + cellOpen = false; } catch (SAXException e) { throw new RuntimeSAXException(e); } } + /** + * Bridge for POI's {@link SheetContentsHandler} interface, used by the + * XLSB (binary) path via {@link org.apache.poi.xssf.binary.XSSFBSheetHandler}. + */ + public void cell(String cellRef, String formattedValue, XSSFComment comment) { + XSSFCommentsShim.CommentData commentData = null; + if (comment != null) { + String text = comment.getString() != null ? + comment.getString().getString() : ""; + commentData = new XSSFCommentsShim.CommentData( + comment.getAuthor(), text); + } + cell(cellRef, formattedValue, commentData); + } + public void headerFooter(String text, boolean isHeader, String tagName) { if (!includeHeadersFooters) { return; @@ -962,6 +1120,11 @@ public void headerFooter(String text, boolean isHeader, String tagName) { footers.add(text); } } + + @Override + public void endSheet() { + // no-op — satisfies both TikaSheetContentsHandler and SheetContentsHandler + } } protected static class HeaderFooterFromString implements HeaderFooter { @@ -1143,4 +1306,5 @@ public void ignorableWhitespace(char[] ch, int start, int length) throws SAXExce } } } + } 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/XSSFSharedStringsShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFSharedStringsShim.java new file mode 100644 index 00000000000..8556d0fbb38 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFSharedStringsShim.java @@ -0,0 +1,156 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.List; + +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.parser.ParseContext; +import org.apache.tika.utils.XMLReaderUtils; + +/** + * SAX-based shim that replaces POI's {@code ReadOnlySharedStringsTable} + * for XLSX event-based parsing. + *

+ * Parses {@code xl/sharedStrings.xml} and stores each shared string entry + * as a plain {@code String}, avoiding the XMLBeans dependency that + * {@code XSSFRichTextString} requires. Rich text runs within a single + * {@code } are concatenated into a single string. + */ +class XSSFSharedStringsShim { + + private final List strings; + private final boolean includePhoneticRuns; + + XSSFSharedStringsShim(InputStream sharedStringsData, + boolean includePhoneticRuns, + ParseContext parseContext) + throws IOException, SAXException, TikaException { + this.includePhoneticRuns = includePhoneticRuns; + SharedStringsHandler handler = new SharedStringsHandler(); + if (sharedStringsData != null) { + try { + XMLReaderUtils.parseSAX(sharedStringsData, handler, parseContext); + } finally { + sharedStringsData.close(); + } + } + this.strings = handler.strings; + } + + String getItemAt(int idx) { + return strings.get(idx); + } + + int getCount() { + return strings.size(); + } + + private class SharedStringsHandler extends DefaultHandler { + + private static final String NS = + "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + + final List strings = new ArrayList<>(); + private StringBuilder characters; + private boolean tIsOpen; + private boolean inRPh; + + @Override + public void startElement(String uri, String localName, String qName, + Attributes attributes) { + if (uri != null && !NS.equals(uri)) { + return; + } + switch (localName) { + case "sst": + String uniqueCount = attributes.getValue("uniqueCount"); + if (uniqueCount != null) { + try { + int hint = (int) Long.parseLong(uniqueCount); + // guard against corrupt files with absurd counts + ((ArrayList) strings).ensureCapacity( + Math.min(hint, 100_000)); + } catch (NumberFormatException e) { + // ignore + } + } + characters = new StringBuilder(64); + break; + case "si": + if (characters != null) { + characters.setLength(0); + } + break; + case "t": + tIsOpen = true; + break; + case "rPh": + inRPh = true; + if (includePhoneticRuns && characters != null && + characters.length() > 0) { + characters.append(" "); + } + break; + default: + break; + } + } + + @Override + public void endElement(String uri, String localName, String qName) { + if (uri != null && !NS.equals(uri)) { + return; + } + switch (localName) { + case "si": + if (characters != null) { + strings.add(characters.toString()); + } + break; + case "t": + tIsOpen = false; + break; + case "rPh": + inRPh = false; + break; + default: + break; + } + } + + @Override + public void characters(char[] ch, int start, int length) { + if (tIsOpen && characters != null) { + if (inRPh) { + if (includePhoneticRuns) { + characters.append(ch, start, length); + } + } else { + characters.append(ch, start, length); + } + } + } + } +} 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/XSSFStylesShim.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFStylesShim.java new file mode 100644 index 00000000000..ca99c7512ec --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XSSFStylesShim.java @@ -0,0 +1,146 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.apache.poi.ss.usermodel.BuiltinFormats; +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.parser.ParseContext; +import org.apache.tika.utils.XMLReaderUtils; + +/** + * SAX-based shim that replaces POI's {@code StylesTable} for XLSX event-based parsing. + *

+ * Parses {@code xl/styles.xml} and extracts only the information needed for text + * extraction: the number format resolution chain (cellXfs index to format string). + * This avoids the XMLBeans dependency that {@code StylesTable} requires. + */ +class XSSFStylesShim { + + private final Map numberFormats = new HashMap<>(); + private final List cellXfFormatIds = new ArrayList<>(); + + XSSFStylesShim(InputStream stylesData, ParseContext parseContext) + throws IOException, SAXException, TikaException { + if (stylesData != null) { + try { + XMLReaderUtils.parseSAX(stylesData, new StylesHandler(), parseContext); + } finally { + stylesData.close(); + } + } + } + + int getNumCellStyles() { + return cellXfFormatIds.size(); + } + + short getFormatIndex(int styleIndex) { + if (styleIndex < 0 || styleIndex >= cellXfFormatIds.size()) { + return -1; + } + return cellXfFormatIds.get(styleIndex); + } + + String getFormatString(int styleIndex) { + short fmtId = getFormatIndex(styleIndex); + if (fmtId == -1) { + return null; + } + String fmt = numberFormats.get(fmtId); + if (fmt == null) { + fmt = BuiltinFormats.getBuiltinFormat(fmtId); + } + return fmt; + } + + private class StylesHandler extends DefaultHandler { + + private static final String NS = + "http://schemas.openxmlformats.org/spreadsheetml/2006/main"; + + private boolean inCellXfs; + private boolean inNumFmts; + + @Override + public void startElement(String uri, String localName, String qName, + Attributes attributes) { + if (!NS.equals(uri)) { + return; + } + switch (localName) { + case "numFmts": + inNumFmts = true; + break; + case "numFmt": + if (inNumFmts) { + String idStr = attributes.getValue("numFmtId"); + String code = attributes.getValue("formatCode"); + if (idStr != null && code != null) { + try { + numberFormats.put(Short.parseShort(idStr), code); + } catch (NumberFormatException e) { + // skip malformed + } + } + } + break; + case "cellXfs": + inCellXfs = true; + break; + case "xf": + if (inCellXfs) { + String numFmtIdStr = attributes.getValue("numFmtId"); + short numFmtId = 0; + if (numFmtIdStr != null) { + try { + numFmtId = Short.parseShort(numFmtIdStr); + } catch (NumberFormatException e) { + // default to 0 (General) + } + } + cellXfFormatIds.add(numFmtId); + } + break; + default: + break; + } + } + + @Override + public void endElement(String uri, String localName, String qName) { + if (!NS.equals(uri)) { + return; + } + if ("numFmts".equals(localName)) { + inNumFmts = false; + } else if ("cellXfs".equals(localName)) { + inCellXfs = false; + } + } + } +} 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/XWPFBodyContentsHandler.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFBodyContentsHandler.java new file mode 100644 index 00000000000..a9eb400e988 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/XWPFBodyContentsHandler.java @@ -0,0 +1,113 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import java.util.Date; + +import org.xml.sax.SAXException; + +/** + * Callback interface for receiving structured document events from the + * OOXML SAX dispatcher. Implementations convert these events into output + * formats (e.g., XHTML, Markdown, plain text). + */ +public interface XWPFBodyContentsHandler { + + void run(RunProperties runProperties, String contents) throws SAXException; + + /** + * @param link the link; can be null + */ + void hyperlinkStart(String link) throws SAXException; + + /** + * Called when a hyperlink is found via a field code (instrText HYPERLINK). + * Distinct from relationship-based hyperlinks for security tracking purposes. + * + * @param link the link URL + */ + default void fieldCodeHyperlinkStart(String link) throws SAXException { + hyperlinkStart(link); + } + + void hyperlinkEnd() throws SAXException; + + void startParagraph(ParagraphProperties paragraphProperties) throws SAXException; + + void endParagraph() throws SAXException; + + void startTable() throws SAXException; + + void endTable() throws SAXException; + + void startTableRow() throws SAXException; + + void endTableRow() throws SAXException; + + void startTableCell() throws SAXException; + + void endTableCell() throws SAXException; + + void startSDT() throws SAXException; + + void endSDT() throws SAXException; + + void startEditedSection(String editor, Date date, EditType editType) throws SAXException; + + void endEditedSection() throws SAXException; + + boolean isIncludeDeletedText() throws SAXException; + + void footnoteReference(String id) throws SAXException; + + void endnoteReference(String id) throws SAXException; + + /** + * Called when a comment reference is encountered in the document body. + * + * @param id the comment ID + */ + void commentReference(String id) throws SAXException; + + boolean isIncludeMoveFromText() throws SAXException; + + void embeddedOLERef(String refId, String progId, String emfImageRId) throws SAXException; + + /** + * Called when a linked (vs embedded) OLE object is found. + * These reference external files and are a security concern. + */ + void linkedOLERef(String refId) throws SAXException; + + void embeddedPicRef(String picFileName, String picDescription) throws SAXException; + + void startBookmark(String id, String name) throws SAXException; + + void endBookmark(String id) throws SAXException; + + /** + * Called when an external reference URL is found in a field code. + * This includes INCLUDEPICTURE, INCLUDETEXT, IMPORT, LINK fields, + * and DrawingML/VML hyperlinks on shapes. + * + * @param fieldType the type of field (e.g., "INCLUDEPICTURE", "hlinkHover", "vml-href") + * @param url the external URL + */ + default void externalRef(String fieldType, String url) throws SAXException { + // Default no-op implementation for backward compatibility + } +} 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/xslf/XSLFEventBasedPowerPointExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xslf/XSLFEventBasedPowerPointExtractor.java index 2950e46be3d..651b12c81b7 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xslf/XSLFEventBasedPowerPointExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xslf/XSLFEventBasedPowerPointExtractor.java @@ -27,9 +27,10 @@ import org.apache.poi.openxml4j.opc.OPCPackage; import org.apache.xmlbeans.XmlException; -import org.apache.tika.parser.microsoft.ooxml.OOXMLWordAndPowerPointTextHandler; +import org.apache.tika.parser.microsoft.ooxml.EditType; import org.apache.tika.parser.microsoft.ooxml.ParagraphProperties; import org.apache.tika.parser.microsoft.ooxml.RunProperties; +import org.apache.tika.parser.microsoft.ooxml.XWPFBodyContentsHandler; public class XSLFEventBasedPowerPointExtractor implements POIXMLTextExtractor { @@ -92,7 +93,7 @@ public void close() throws IOException { } private static class XSLFToTextContentHandler - implements OOXMLWordAndPowerPointTextHandler.XWPFBodyContentsHandler { + implements XWPFBodyContentsHandler { private final StringBuilder buffer; public XSLFToTextContentHandler(StringBuilder buffer) { @@ -166,7 +167,7 @@ public void endSDT() { @Override public void startEditedSection(String editor, Date date, - OOXMLWordAndPowerPointTextHandler.EditType editType) { + EditType editType) { } @@ -197,7 +198,12 @@ public boolean isIncludeMoveFromText() { @Override - public void embeddedOLERef(String refId) { + public void embeddedOLERef(String refId, String progId, String emfImageRId) { + //no-op + } + + @Override + public void commentReference(String id) { //no-op } 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/xwpf/XWPFEventBasedWordExtractor.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFEventBasedWordExtractor.java index 2fb45ca7fd7..f509d3be7c2 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFEventBasedWordExtractor.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/xwpf/XWPFEventBasedWordExtractor.java @@ -45,9 +45,11 @@ import org.apache.tika.exception.TikaException; import org.apache.tika.exception.WriteLimitReachedException; import org.apache.tika.parser.ParseContext; +import org.apache.tika.parser.microsoft.ooxml.EditType; import org.apache.tika.parser.microsoft.ooxml.OOXMLWordAndPowerPointTextHandler; import org.apache.tika.parser.microsoft.ooxml.ParagraphProperties; import org.apache.tika.parser.microsoft.ooxml.RunProperties; +import org.apache.tika.parser.microsoft.ooxml.XWPFBodyContentsHandler; import org.apache.tika.parser.microsoft.ooxml.XWPFListManager; import org.apache.tika.utils.XMLReaderUtils; @@ -255,7 +257,7 @@ private XWPFNumbering loadNumbering(PackagePart packagePart) throws IOException } private static class XWPFToTextContentHandler - implements OOXMLWordAndPowerPointTextHandler.XWPFBodyContentsHandler { + implements XWPFBodyContentsHandler { private final StringBuilder buffer; public XWPFToTextContentHandler(StringBuilder buffer) { @@ -329,7 +331,7 @@ public void endSDT() { @Override public void startEditedSection(String editor, Date date, - OOXMLWordAndPowerPointTextHandler.EditType editType) { + EditType editType) { } @@ -359,7 +361,12 @@ public boolean isIncludeMoveFromText() { } @Override - public void embeddedOLERef(String refId) { + public void embeddedOLERef(String refId, String progId, String emfImageRId) { + //no-op + } + + @Override + public void commentReference(String id) { //no-op } 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/SAXBasedMetadataExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SAXBasedMetadataExtractorTest.java new file mode 100644 index 00000000000..99a89f3ef2f --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SAXBasedMetadataExtractorTest.java @@ -0,0 +1,216 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +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 java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +import org.junit.jupiter.api.Test; + +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.ParseContext; +import org.apache.tika.utils.XMLReaderUtils; + +/** + * Tests for length-cap defenses in {@link SAXBasedMetadataExtractor}. + *

+ * Both caps target attacker-controlled docProps/custom.xml. A 3 KB OOXML + * carrier whose {@code } contains a 1,000,000-digit numeric + * literal would otherwise burn ~25 s of CPU per file in JDK 17's + * {@code BigDecimal(String)} (O(n²)). + */ +public class SAXBasedMetadataExtractorTest { + + private static final String CUSTOM_HEADER = "" + + ""; + private static final String CUSTOM_FOOTER = ""; + + @Test + public void appendCappedTruncatesAtLimit() { + StringBuilder buf = new StringBuilder(); + char[] giant = new char[SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH + 10_000]; + java.util.Arrays.fill(giant, '9'); + + SAXBasedMetadataExtractor.appendCapped(buf, giant, 0, giant.length); + assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, buf.length(), + "buffer must be capped at MAX_TEXT_BUFFER_LENGTH"); + + // Further appends after the cap are silent no-ops. + SAXBasedMetadataExtractor.appendCapped(buf, giant, 0, 100); + assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, buf.length(), + "appends past the cap must be silently dropped"); + } + + @Test + public void appendCappedRespectsRemainingRoom() { + StringBuilder buf = new StringBuilder(); + // Pre-fill to one short of the cap; next 3 chars should partially land. + char[] padding = new char[SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH - 1]; + java.util.Arrays.fill(padding, 'x'); + buf.append(padding); + + SAXBasedMetadataExtractor.appendCapped(buf, new char[]{'a', 'b', 'c'}, 0, 3); + assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, buf.length(), + "remaining room (1 char) must be filled; overflow dropped"); + assertEquals('a', buf.charAt(buf.length() - 1)); + } + + @Test + public void normalDecimalIsExtracted() throws Exception { + Metadata m = parseCustomProperties(customProperty("price", "decimal", "1234.56")); + assertEquals("1234.56", m.get("custom:price")); + } + + @Test + public void decimalAtMaxLengthIsAccepted() throws Exception { + // Boundary: exactly MAX_DECIMAL_LENGTH digits must still parse. This is + // the upper edge of the accept-and-parse path. + String digits = "9".repeat(SAXBasedMetadataExtractor.MAX_DECIMAL_LENGTH); + Metadata m = parseCustomProperties(customProperty("ok", "decimal", digits)); + assertEquals(digits, m.get("custom:ok"), + "decimal of exactly MAX_DECIMAL_LENGTH digits must round-trip"); + } + + @Test + public void decimalOneOverMaxLengthIsRejected() throws Exception { + // Boundary: one character past the cap must short-circuit before + // BigDecimal(String). Combined with appendCappedTruncatesAtLimit this + // mechanically proves the O(n²) parser is never invoked above the cap. + String digits = "9".repeat(SAXBasedMetadataExtractor.MAX_DECIMAL_LENGTH + 1); + Metadata m = parseCustomProperties(customProperty("evil", "decimal", digits)); + assertNull(m.get("custom:evil"), + "decimal one char over MAX_DECIMAL_LENGTH must be rejected, not parsed"); + } + + @Test + public void oversizedDecimalAttackPayloadIsRejected() throws Exception { + // Reporter's actual attack shape: 1,000,000 digits. The SAX read + // truncates accumulation at MAX_TEXT_BUFFER_LENGTH (64 KB) via + // appendCapped, then the decimal-length check rejects the truncated + // value before BigDecimal(String) runs. No wall-clock assertion — + // the boundary tests above are the mechanical proof that + // BigDecimal is never called on an oversized payload. + String attackDigits = "9".repeat(1_000_000); + Metadata m = parseCustomProperties(customProperty("evil", "decimal", attackDigits)); + assertNull(m.get("custom:evil"), + "1M-digit attack payload must be rejected without parsing"); + } + + @Test + public void oversizedStringIsTruncatedNotRejected() throws Exception { + // A large lpwstr isn't a CPU-DoS like decimal, but unbounded text + // accumulation would still be a memory pressure vector. The buffer + // cap stops accumulation at 64 KB; the truncated value still flows. + String giantString = "a".repeat(200_000); + Metadata m = parseCustomProperties(customProperty("bigstr", "lpwstr", giantString)); + String got = m.get("custom:bigstr"); + assertNotNull(got, "string-typed property survives truncation"); + assertEquals(SAXBasedMetadataExtractor.MAX_TEXT_BUFFER_LENGTH, got.length(), + "string value must be capped at MAX_TEXT_BUFFER_LENGTH"); + } + + @Test + public void stringValuesPreserveLeadingAndTrailingWhitespace() throws Exception { + // Legacy POI's getLpwstr/getLpstr/getBstr returned the raw element text; + // anything that depends on whitespace inside the value would regress if + // the SAX path silently trimmed. + Metadata m = parseCustomProperties( + customProperty("padded", "lpwstr", " hello world ")); + assertEquals(" hello world ", m.get("custom:padded"), + "lpwstr must preserve leading/trailing whitespace"); + + m = parseCustomProperties(customProperty("padded", "lpstr", " ascii ")); + assertEquals(" ascii ", m.get("custom:padded")); + + m = parseCustomProperties(customProperty("padded", "bstr", "\tindented\n")); + assertEquals("\tindented\n", m.get("custom:padded")); + } + + @Test + public void boolLexicalOneIsNormalizedToTrue() throws Exception { + Metadata m = parseCustomProperties(customProperty("flag", "bool", "1")); + assertEquals("true", m.get("custom:flag"), + "1 must normalize to \"true\" (matching legacy POI)"); + } + + @Test + public void boolLexicalZeroIsNormalizedToFalse() throws Exception { + Metadata m = parseCustomProperties(customProperty("flag", "bool", "0")); + assertEquals("false", m.get("custom:flag"), + "0 must normalize to \"false\" (matching legacy POI)"); + } + + @Test + public void boolLexicalTrueAndFalsePassThrough() throws Exception { + Metadata m = parseCustomProperties(customProperty("flag", "bool", "true")); + assertEquals("true", m.get("custom:flag")); + + m = parseCustomProperties(customProperty("flag", "bool", "false")); + assertEquals("false", m.get("custom:flag")); + } + + @Test + public void vectorContainingScalarIsNotEmittedAsScalar() throws Exception { + // with inner children. The container latches as + // the value type; inner children must NOT overwrite it, and the + // container itself must not be emitted as a scalar. Legacy POI skipped + // vector/array entirely. + String xml = CUSTOM_HEADER + + "" + + "" + + "alpha" + + "beta" + + "" + + "" + + CUSTOM_FOOTER; + Metadata m = parseCustomProperties(xml); + assertNull(m.get("custom:tags"), + "vector container must not emit a scalar custom property"); + } + + // ===== helpers ===== + + private static String customProperty(String name, String type, String value) { + return CUSTOM_HEADER + + "" + + "" + value + "" + + "" + + CUSTOM_FOOTER; + } + + private static Metadata parseCustomProperties(String xml) throws Exception { + SAXBasedMetadataExtractor.CustomPropertiesHandler handler = + new SAXBasedMetadataExtractor.CustomPropertiesHandler(); + try (InputStream is = new ByteArrayInputStream( + xml.getBytes(StandardCharsets.UTF_8))) { + XMLReaderUtils.parseSAX(is, handler, new ParseContext()); + } + Metadata metadata = new Metadata(); + handler.applyTo(metadata); + return metadata; + } +} 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 b76b2567b92..a33139ad387 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 @@ -604,4 +604,9 @@ public void testPPTXGroups() throws Exception { metadataList.get(2).get(TikaCoreProperties.EMBEDDED_RESOURCE_PATH)); } + @Test + public void testPPTXDiagramData() throws Exception { + assertContains("President", getXML("testPPT_diagramData.pptx", parseContext).xml); + } + } 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/SXWPFExtractorTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java index 3168d9d4cf1..65a73faf3a6 100644 --- a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/SXWPFExtractorTest.java @@ -211,12 +211,17 @@ public void testWordFootnote() throws Exception { assertEquals("application/vnd.openxmlformats-officedocument.wordprocessingml.document", xmlResult.metadata.get(Metadata.CONTENT_TYPE)); assertTrue(xmlResult.xml.contains("snoska")); + //footnote content is inlined as a div, emitted after the paragraph closes + //(not nested inside the

, which would be malformed) + assertContains("

", xmlResult.xml); + assertNotContained("

", xmlResult.xml); } @Test public void testEndnoteWithTable() throws Exception { XMLResult xmlResult = getXML("testWORD_endnote_table.docx", parseContext); assertContains("Cat Property Act", xmlResult.xml); + assertContains("
", xmlResult.xml); } /** @@ -827,16 +832,16 @@ public void testTextDecoration() throws Exception { assertContains("Bold", xml); assertContains("italic", xml); assertContains("underline", xml); - assertContains("strikethrough", xml); + assertContains("strikethrough", xml); } @Test public void testTextDecorationNested() throws Exception { String xml = getXML("testWORD_various.docx", parseContext).xml; - assertContains("italic", xml); - assertContains("italic", xml); - assertContains("underline", xml); + assertContains("italic", xml); + assertContains("italic", xml); + assertContains("underline", xml); //confirm that spaces aren't added for and String txt = getText("testWORD_various.docx", new Metadata(), parseContext); 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/VSDXParserTest.java b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/VSDXParserTest.java new file mode 100644 index 00000000000..c4a832bd963 --- /dev/null +++ b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/VSDXParserTest.java @@ -0,0 +1,41 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.tika.parser.microsoft.ooxml; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import java.util.List; + +import org.junit.jupiter.api.Test; + +import org.apache.tika.TikaTest; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.metadata.TikaCoreProperties; + +public class VSDXParserTest extends TikaTest { + + @Test + public void testBasicTextExtraction() throws Exception { + List metadataList = getRecursiveMetadata("testVISIO.vsdx"); + String content = metadataList.get(0).get(TikaCoreProperties.TIKA_CONTENT); + assertEquals("application/vnd.ms-visio.drawing", + metadataList.get(0).get(Metadata.CONTENT_TYPE)); + assertContains("test", content); + assertContains("This is a test.", content); + assertContains("Nothing fancy.", content); + } +}