Skip to content

Commit d8b183f

Browse files
authored
TIKA-4692 - 3x port sax ooxml (#2937)
1 parent 93ecf6c commit d8b183f

39 files changed

Lines changed: 4533 additions & 536 deletions

CHANGES.txt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ Release 3.3.2 - (unreleased)
44
/status endpoints are selected; the server refuses to start otherwise. Previously
55
listing the endpoint was treated as sufficient consent (TIKA-4760).
66

7+
* Port the 4.x SAX-based OOXML parsers to 3.x. The docx/pptx/xlsx/vsdx SAX parsers
8+
gain field-code hyperlink extraction, inlined footnotes/endnotes/comments,
9+
balanced-XHTML recovery on error, and XMLBeans-free xlsx/xlsb reading. The SAX
10+
docx/pptx parsers stay opt-in via useSAXDocxExtractor/useSAXPptxExtractor, so DOM
11+
remains the default (TIKA-4692, TIKA-4708).
12+
713
Release 3.3.1 - 5/20/2026
814

915
* Dependency upgrades (TIKA-4695).
Lines changed: 229 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,229 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.sax;
18+
19+
import java.util.ArrayDeque;
20+
import java.util.Deque;
21+
import java.util.HashSet;
22+
import java.util.Set;
23+
24+
import org.xml.sax.Attributes;
25+
import org.xml.sax.ContentHandler;
26+
import org.xml.sax.SAXException;
27+
28+
/**
29+
* A SAX content handler decorator that enforces XHTML well-formedness on the
30+
* incoming event stream. Any parser that emits an event sequence that would
31+
* produce malformed XHTML triggers a {@link SAXException} synchronously — the
32+
* stack trace points at the parser code that made the offending call, instead
33+
* of surfacing later as a parse error on the serialized output.
34+
* <p>
35+
* Invariants enforced:
36+
* <ul>
37+
* <li>{@code startDocument} is called at most once.</li>
38+
* <li>No SAX events arrive after {@code endDocument}.</li>
39+
* <li>Every {@code endElement} matches the topmost open {@code startElement}
40+
* (no cross-nesting like {@code &lt;a&gt;&lt;b&gt;&lt;/a&gt;&lt;/b&gt;}).</li>
41+
* <li>The element stack is empty when {@code endDocument} fires (no unclosed
42+
* elements left dangling by an exception path).</li>
43+
* <li>Within a single {@code startElement}, no two attributes share the same
44+
* (namespaceURI, localName) pair (the bug class that produces
45+
* {@code &lt;div class="x" class="y"&gt;}).</li>
46+
* </ul>
47+
* Use as a decorator wrapping the real handler. It passes every event through
48+
* to the downstream handler after validation, so any normal text/XHTML capture
49+
* still works.
50+
*/
51+
public class StrictXHTMLValidator extends ContentHandlerDecorator {
52+
53+
private final Deque<QName> openElements = new ArrayDeque<>();
54+
private boolean documentStarted;
55+
private boolean documentEnded;
56+
57+
public StrictXHTMLValidator(ContentHandler handler) {
58+
super(handler);
59+
}
60+
61+
@Override
62+
public void startDocument() throws SAXException {
63+
if (documentStarted) {
64+
throw new SAXException("StrictXHTMLValidator: startDocument called twice");
65+
}
66+
if (documentEnded) {
67+
throw new SAXException(
68+
"StrictXHTMLValidator: startDocument after endDocument");
69+
}
70+
documentStarted = true;
71+
super.startDocument();
72+
}
73+
74+
@Override
75+
public void endDocument() throws SAXException {
76+
if (documentEnded) {
77+
throw new SAXException("StrictXHTMLValidator: endDocument called twice");
78+
}
79+
if (!openElements.isEmpty()) {
80+
throw new SAXException(
81+
"StrictXHTMLValidator: endDocument with " + openElements.size()
82+
+ " unclosed element(s); topmost was <"
83+
+ openElements.peek().qOrLocal() + ">");
84+
}
85+
documentEnded = true;
86+
super.endDocument();
87+
}
88+
89+
@Override
90+
public void startElement(String uri, String localName, String qName, Attributes attrs)
91+
throws SAXException {
92+
ensureNotEnded("startElement <" + display(qName, localName) + ">");
93+
checkAttributesUnique(qName, localName, attrs);
94+
openElements.push(new QName(uri, localName, qName));
95+
super.startElement(uri, localName, qName, attrs);
96+
}
97+
98+
@Override
99+
public void endElement(String uri, String localName, String qName) throws SAXException {
100+
ensureNotEnded("endElement </" + display(qName, localName) + ">");
101+
if (openElements.isEmpty()) {
102+
throw new SAXException(
103+
"StrictXHTMLValidator: endElement </" + display(qName, localName)
104+
+ "> with no matching startElement");
105+
}
106+
QName top = openElements.pop();
107+
if (!top.matches(uri, localName, qName)) {
108+
throw new SAXException(
109+
"StrictXHTMLValidator: endElement </" + display(qName, localName)
110+
+ "> does not match topmost open element <"
111+
+ top.qOrLocal() + ">");
112+
}
113+
super.endElement(uri, localName, qName);
114+
}
115+
116+
@Override
117+
public void characters(char[] ch, int start, int length) throws SAXException {
118+
ensureNotEnded("characters");
119+
super.characters(ch, start, length);
120+
}
121+
122+
@Override
123+
public void ignorableWhitespace(char[] ch, int start, int length) throws SAXException {
124+
ensureNotEnded("ignorableWhitespace");
125+
super.ignorableWhitespace(ch, start, length);
126+
}
127+
128+
@Override
129+
public void processingInstruction(String target, String data) throws SAXException {
130+
ensureNotEnded("processingInstruction");
131+
super.processingInstruction(target, data);
132+
}
133+
134+
@Override
135+
public void startPrefixMapping(String prefix, String uri) throws SAXException {
136+
ensureNotEnded("startPrefixMapping");
137+
super.startPrefixMapping(prefix, uri);
138+
}
139+
140+
@Override
141+
public void endPrefixMapping(String prefix) throws SAXException {
142+
ensureNotEnded("endPrefixMapping");
143+
super.endPrefixMapping(prefix);
144+
}
145+
146+
@Override
147+
public void skippedEntity(String name) throws SAXException {
148+
ensureNotEnded("skippedEntity");
149+
super.skippedEntity(name);
150+
}
151+
152+
private void ensureNotEnded(String event) throws SAXException {
153+
if (documentEnded) {
154+
throw new SAXException(
155+
"StrictXHTMLValidator: " + event + " arrived after endDocument");
156+
}
157+
}
158+
159+
private void checkAttributesUnique(String elementQName, String elementLocalName,
160+
Attributes attrs) throws SAXException {
161+
int n = attrs.getLength();
162+
if (n < 2) {
163+
return;
164+
}
165+
// (uri, localName) pairs must be unique per the XML namespaces spec.
166+
// We also check raw qnames because Tika's serializers emit by qname and
167+
// duplicate qnames produce malformed XHTML even when localnames differ.
168+
Set<String> seenUriLocal = new HashSet<>(n);
169+
Set<String> seenQNames = new HashSet<>(n);
170+
for (int i = 0; i < n; i++) {
171+
String uri = nullSafe(attrs.getURI(i));
172+
String local = nullSafe(attrs.getLocalName(i));
173+
String qn = nullSafe(attrs.getQName(i));
174+
// U+0001 cannot appear in a valid XML uri/localName, so it joins the
175+
// two unambiguously without risk of a key collision.
176+
String key = uri + "\u0001" + local;
177+
if (!seenUriLocal.add(key)) {
178+
throw new SAXException(
179+
"StrictXHTMLValidator: duplicate attribute on <"
180+
+ display(elementQName, elementLocalName) + ">: "
181+
+ (uri.isEmpty() ? local : ("{" + uri + "}" + local)));
182+
}
183+
if (!qn.isEmpty() && !seenQNames.add(qn)) {
184+
throw new SAXException(
185+
"StrictXHTMLValidator: duplicate attribute qname on <"
186+
+ display(elementQName, elementLocalName) + ">: " + qn);
187+
}
188+
}
189+
}
190+
191+
private static String nullSafe(String s) {
192+
return s == null ? "" : s;
193+
}
194+
195+
private static String display(String qName, String localName) {
196+
if (qName != null && !qName.isEmpty()) {
197+
return qName;
198+
}
199+
return localName == null ? "" : localName;
200+
}
201+
202+
private static final class QName {
203+
final String uri;
204+
final String localName;
205+
final String qName;
206+
207+
QName(String uri, String localName, String qName) {
208+
this.uri = nullSafe(uri);
209+
this.localName = nullSafe(localName);
210+
this.qName = nullSafe(qName);
211+
}
212+
213+
boolean matches(String u, String l, String q) {
214+
// SAX parsers can vary in which fields they populate. Accept a
215+
// match on either (uri, localName) or qName, whichever is present.
216+
String otherU = nullSafe(u);
217+
String otherL = nullSafe(l);
218+
String otherQ = nullSafe(q);
219+
boolean uriLocalMatch = uri.equals(otherU) && localName.equals(otherL)
220+
&& !localName.isEmpty();
221+
boolean qNameMatch = !qName.isEmpty() && qName.equals(otherQ);
222+
return uriLocalMatch || qNameMatch;
223+
}
224+
225+
String qOrLocal() {
226+
return qName.isEmpty() ? localName : qName;
227+
}
228+
}
229+
}
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
/*
2+
* Licensed to the Apache Software Foundation (ASF) under one or more
3+
* contributor license agreements. See the NOTICE file distributed with
4+
* this work for additional information regarding copyright ownership.
5+
* The ASF licenses this file to You under the Apache License, Version 2.0
6+
* (the "License"); you may not use this file except in compliance with
7+
* the License. You may obtain a copy of the License at
8+
*
9+
* http://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*/
17+
package org.apache.tika.sax;
18+
19+
import java.util.ArrayDeque;
20+
import java.util.Deque;
21+
22+
import org.xml.sax.Attributes;
23+
import org.xml.sax.ContentHandler;
24+
import org.xml.sax.SAXException;
25+
26+
/**
27+
* SAX decorator that tracks open elements so a parser can recover well-formed
28+
* XHTML when an exception interrupts the SAX stream mid-element.
29+
* <p>
30+
* The decorator is a thin passthrough on the happy path: it pushes and pops an
31+
* internal stack on {@code startElement}/{@code endElement} and otherwise forwards
32+
* every event to the wrapped handler unchanged. It deliberately does NOT mask
33+
* bad event sequences (mismatched or excess endElement, duplicate attributes,
34+
* etc.) -- those remain visible to {@link StrictXHTMLValidator} so parser bugs
35+
* still surface as test failures.
36+
* <p>
37+
* The unhappy path -- a per-part SAX parser throwing mid-element after emitting
38+
* one or more start tags -- is handled via {@link #drainOpenElements()}, which
39+
* emits a matching {@code endElement} (with the original uri/localName/qName)
40+
* for every element still on the stack, in reverse open order. The wrapped
41+
* handler is left in a well-formed state with no dangling elements from the
42+
* failed sub-parse.
43+
* <p>
44+
* Typical use wraps the handler that receives events from an inner SAX parser,
45+
* inside the catch arm that swallows the inner parser's exception:
46+
* <pre>{@code
47+
* XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(contentHandler);
48+
* try {
49+
* XMLReaderUtils.parseSAX(stream, new EmbeddedContentHandler(balancer), context);
50+
* } catch (SAXException e) {
51+
* balancer.drainOpenElements();
52+
* // ... log and continue ...
53+
* }
54+
* }</pre>
55+
* This handler does not touch {@code startDocument}/{@code endDocument}; the
56+
* caller still owns the document lifecycle.
57+
*/
58+
public class XHTMLBalancingHandler extends ContentHandlerDecorator {
59+
60+
private final Deque<QName> openElements = new ArrayDeque<>();
61+
62+
public XHTMLBalancingHandler(ContentHandler handler) {
63+
super(handler);
64+
}
65+
66+
@Override
67+
public void startElement(String uri, String localName, String qName, Attributes attrs)
68+
throws SAXException {
69+
openElements.push(new QName(uri, localName, qName));
70+
super.startElement(uri, localName, qName, attrs);
71+
}
72+
73+
@Override
74+
public void endElement(String uri, String localName, String qName) throws SAXException {
75+
// Pop best-effort: an unbalanced endElement (e.g., emitted after the
76+
// matching startElement was swallowed) still forwards downstream so a
77+
// wrapping StrictXHTMLValidator sees the violation.
78+
if (!openElements.isEmpty()) {
79+
openElements.pop();
80+
}
81+
super.endElement(uri, localName, qName);
82+
}
83+
84+
/**
85+
* Emits a matching {@code endElement} for every element still on the open
86+
* stack, in reverse open order. After this call the stack is empty.
87+
* <p>
88+
* Intended for the catch arm of a caller that swallowed a
89+
* {@link SAXException} from an inner SAX parser: the inner parser may have
90+
* left one or more elements open mid-stream, and downstream serialization
91+
* needs matching closers before any further events.
92+
* <p>
93+
* Does NOT emit {@code endDocument} -- document lifecycle stays with the
94+
* caller.
95+
*/
96+
public void drainOpenElements() throws SAXException {
97+
while (!openElements.isEmpty()) {
98+
QName q = openElements.pop();
99+
super.endElement(q.uri, q.localName, q.qName);
100+
}
101+
}
102+
103+
/**
104+
* Number of elements currently open through this handler. Exposed for
105+
* tests and for callers that want to know whether
106+
* {@link #drainOpenElements()} would emit anything.
107+
*/
108+
public int openElementCount() {
109+
return openElements.size();
110+
}
111+
112+
private static final class QName {
113+
final String uri;
114+
final String localName;
115+
final String qName;
116+
117+
QName(String uri, String localName, String qName) {
118+
this.uri = uri == null ? "" : uri;
119+
this.localName = localName == null ? "" : localName;
120+
this.qName = qName == null ? "" : qName;
121+
}
122+
}
123+
}

0 commit comments

Comments
 (0)