Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGES.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
229 changes: 229 additions & 0 deletions tika-core/src/main/java/org/apache/tika/sax/StrictXHTMLValidator.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* Invariants enforced:
* <ul>
* <li>{@code startDocument} is called at most once.</li>
* <li>No SAX events arrive after {@code endDocument}.</li>
* <li>Every {@code endElement} matches the topmost open {@code startElement}
* (no cross-nesting like {@code &lt;a&gt;&lt;b&gt;&lt;/a&gt;&lt;/b&gt;}).</li>
* <li>The element stack is empty when {@code endDocument} fires (no unclosed
* elements left dangling by an exception path).</li>
* <li>Within a single {@code startElement}, no two attributes share the same
* (namespaceURI, localName) pair (the bug class that produces
* {@code &lt;div class="x" class="y"&gt;}).</li>
* </ul>
* 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<QName> 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 </" + display(qName, localName) + ">");
if (openElements.isEmpty()) {
throw new SAXException(
"StrictXHTMLValidator: endElement </" + display(qName, localName)
+ "> with no matching startElement");
}
QName top = openElements.pop();
if (!top.matches(uri, localName, qName)) {
throw new SAXException(
"StrictXHTMLValidator: endElement </" + display(qName, localName)
+ "> 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<String> seenUriLocal = new HashSet<>(n);
Set<String> 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;
}
}
}
123 changes: 123 additions & 0 deletions tika-core/src/main/java/org/apache/tika/sax/XHTMLBalancingHandler.java
Original file line number Diff line number Diff line change
@@ -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.
* <p>
* 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.
* <p>
* 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.
* <p>
* Typical use wraps the handler that receives events from an inner SAX parser,
* inside the catch arm that swallows the inner parser's exception:
* <pre>{@code
* XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(contentHandler);
* try {
* XMLReaderUtils.parseSAX(stream, new EmbeddedContentHandler(balancer), context);
* } catch (SAXException e) {
* balancer.drainOpenElements();
* // ... log and continue ...
* }
* }</pre>
* This handler does not touch {@code startDocument}/{@code endDocument}; the
* caller still owns the document lifecycle.
*/
public class XHTMLBalancingHandler extends ContentHandlerDecorator {

private final Deque<QName> 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.
* <p>
* 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.
* <p>
* 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;
}
}
}
Loading
Loading