Skip to content

Commit 7b6e860

Browse files
committed
TIKA-4744 - fix apple, rtf, odf, ooxml xhtml tag balancing
1 parent 0d830bc commit 7b6e860

16 files changed

Lines changed: 868 additions & 39 deletions

File tree

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-apple-module/src/main/java/org/apache/tika/parser/iwork/KeynoteContentHandler.java

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,12 @@ public void endElement(String uri, String localName, String qName) throws SAXExc
142142
} else if (inMetadata && "key:authors".equals(qName)) {
143143
inMetaDataAuthors = false;
144144
} else if (inSlide && "sf:tabular-model".equals(qName)) {
145+
// If the final row has fewer cells than numberOfColumns,
146+
// parseTableData never reaches its </tr> emit. Close any open
147+
// row before </table> so the SAX stream stays balanced.
148+
if (currentColumn != null && currentColumn != 0) {
149+
xhtml.endElement("tr");
150+
}
145151
xhtml.endElement("table");
146152
tableId = null;
147153
numberOfColumns = null;
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
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.parser.iwork;
18+
19+
import static org.junit.jupiter.api.Assertions.assertTrue;
20+
21+
import org.junit.jupiter.api.Test;
22+
import org.xml.sax.helpers.AttributesImpl;
23+
24+
import org.apache.tika.metadata.Metadata;
25+
import org.apache.tika.parser.ParseContext;
26+
import org.apache.tika.sax.StrictXHTMLValidator;
27+
import org.apache.tika.sax.ToXMLContentHandler;
28+
import org.apache.tika.sax.XHTMLContentHandler;
29+
30+
public class KeynoteContentHandlerTest {
31+
32+
/**
33+
* TIKA-4744: Keynote tables whose final row contains fewer cells than
34+
* numberOfColumns left {@code <tr>} open when {@code </sf:tabular-model>}
35+
* fired {@code </table>}. Drive the handler with a synthetic 3-column
36+
* table whose final row has only 2 cells and assert the XHTML stays
37+
* balanced through endDocument.
38+
*/
39+
@Test
40+
public void testIncompleteFinalRowClosesTr() throws Exception {
41+
Metadata md = new Metadata();
42+
ToXMLContentHandler xml = new ToXMLContentHandler();
43+
XHTMLContentHandler xhtml = new XHTMLContentHandler(
44+
new StrictXHTMLValidator(xml), md, new ParseContext());
45+
KeynoteContentHandler h = new KeynoteContentHandler(xhtml, md);
46+
47+
xhtml.startDocument();
48+
49+
// <key:slide>
50+
h.startElement("", "slide", "key:slide", new AttributesImpl());
51+
// <sf:tabular-model sfa:ID="t1">
52+
AttributesImpl tabAttrs = new AttributesImpl();
53+
tabAttrs.addAttribute("", "sfa:ID", "sfa:ID", "CDATA", "t1");
54+
h.startElement("", "tabular-model", "sf:tabular-model", tabAttrs);
55+
// <sf:columns sf:count="3">
56+
AttributesImpl colAttrs = new AttributesImpl();
57+
colAttrs.addAttribute("", "sf:count", "sf:count", "CDATA", "3");
58+
h.startElement("", "columns", "sf:columns", colAttrs);
59+
h.endElement("", "columns", "sf:columns");
60+
// Two cells (less than 3) -> incomplete final row.
61+
AttributesImpl cell = new AttributesImpl();
62+
cell.addAttribute("", "sfa:s", "sfa:s", "CDATA", "A");
63+
h.startElement("", "ct", "sf:ct", cell);
64+
h.endElement("", "ct", "sf:ct");
65+
cell = new AttributesImpl();
66+
cell.addAttribute("", "sfa:s", "sfa:s", "CDATA", "B");
67+
h.startElement("", "ct", "sf:ct", cell);
68+
h.endElement("", "ct", "sf:ct");
69+
// </sf:tabular-model> -- without the fix this emits </table> while
70+
// <tr> is still topmost and StrictXHTMLValidator throws.
71+
h.endElement("", "tabular-model", "sf:tabular-model");
72+
// </key:slide>
73+
h.endElement("", "slide", "key:slide");
74+
75+
xhtml.endDocument();
76+
77+
String out = xml.toString();
78+
// Sanity: the cells emitted, the row closed, and the table closed.
79+
assertTrue(out.contains("<td>A</td>"), "expected td A; got: " + out);
80+
assertTrue(out.contains("<td>B</td>"), "expected td B; got: " + out);
81+
assertTrue(out.contains("</tr>"), "expected </tr>; got: " + out);
82+
assertTrue(out.contains("</table>"), "expected </table>; got: " + out);
83+
}
84+
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/FormattingTagManager.java

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -114,14 +114,16 @@ void applyFormatting(RunProperties runProperties) throws SAXException {
114114
}
115115

116116
if (runProperties.isBold() != isBold) {
117-
if (isStrikeThrough) {
118-
xhtml.endElement("s");
119-
isStrikeThrough = false;
120-
}
117+
// Close inner tags before flipping <b>. Nesting is <b><i><s><u>
118+
// (outermost to innermost), so close innermost first: u, s, i.
121119
if (isUnderline) {
122120
xhtml.endElement("u");
123121
isUnderline = false;
124122
}
123+
if (isStrikeThrough) {
124+
xhtml.endElement("s");
125+
isStrikeThrough = false;
126+
}
125127
if (isItalics) {
126128
xhtml.endElement("i");
127129
isItalics = false;
@@ -135,14 +137,15 @@ void applyFormatting(RunProperties runProperties) throws SAXException {
135137
}
136138

137139
if (runProperties.isItalics() != isItalics) {
138-
if (isStrikeThrough) {
139-
xhtml.endElement("s");
140-
isStrikeThrough = false;
141-
}
140+
// Close inner tags before flipping <i>: u then s (u is innermost).
142141
if (isUnderline) {
143142
xhtml.endElement("u");
144143
isUnderline = false;
145144
}
145+
if (isStrikeThrough) {
146+
xhtml.endElement("s");
147+
isStrikeThrough = false;
148+
}
146149
if (runProperties.isItalics()) {
147150
xhtml.startElement("i");
148151
} else {

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/ooxml/OOXMLTikaBodyPartHandler.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,16 @@ private void popExpected(String tag) {
265265

266266
@Override
267267
public void startTable() throws SAXException {
268-
268+
// A <w:tbl> can appear nested inside an outer <w:p> -- corrupt-ish but
269+
// present in the corpus (e.g., <w:p><w:r>...<wps:txbx><w:txbxContent>
270+
// <w:tbl>...). At that point a run-level <b>/<i>/<u>/<s>/<a> may be on
271+
// the SAX stack just above where the <table> is about to land. When a
272+
// later paragraph inside a cell ends, formattingTags.closeAll() tries
273+
// to emit </b> for the outer-paragraph state, but <td> is topmost --
274+
// strict validator rejects the mismatch. Close pending formatting now
275+
// so the table opens at a clean layer and the outer style is forgotten.
276+
// Mirrors startSDT()'s same-shape guard.
277+
formattingTags.closeAll();
269278
xhtml.startElement("table");
270279
openStructuralTags.push("table");
271280
tableDepth++;

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/RTFEmbObjHandler.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.apache.tika.metadata.TikaCoreProperties;
3636
import org.apache.tika.parser.ParseContext;
3737
import org.apache.tika.sax.EmbeddedContentHandler;
38+
import org.apache.tika.sax.XHTMLBalancingHandler;
3839

3940
/**
4041
* This class buffers data from embedded objects and pictures.
@@ -235,12 +236,24 @@ private void extractObj(byte[] bytes, ContentHandler handler, Metadata metadata)
235236
}
236237
metadata.set(TikaCoreProperties.RESOURCE_NAME_EXTENSION_INFERRED, true);
237238
}
239+
// Wrap the outer handler in a balancing handler so that if
240+
// the embedded parser aborts mid-element (leaving <p>/<b>/etc
241+
// open on the wire), we can drain those before propagating.
242+
// Without this, the outer RTF parser's subsequent
243+
// </b>/</p>/</body> sequence trips StrictXHTMLValidator on
244+
// unbalanced nesting and surfaces as a misleading error at
245+
// endDocument. Same shape as the SXWPF / Epub catch arms.
246+
XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(handler);
238247
try {
239248
embeddedDocumentUtil
240-
.parseEmbedded(tis, new EmbeddedContentHandler(handler), metadata,
249+
.parseEmbedded(tis, new EmbeddedContentHandler(balancer), metadata,
241250
true);
242251
} catch (IOException e) {
252+
balancer.drainOpenElements();
243253
EmbeddedDocumentUtil.recordEmbeddedStreamException(e, metadata);
254+
} catch (SAXException e) {
255+
balancer.drainOpenElements();
256+
EmbeddedDocumentUtil.recordException(e, metadata);
244257
}
245258
}
246259
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/RTFParser.java

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import org.apache.tika.mime.MediaType;
3636
import org.apache.tika.parser.ParseContext;
3737
import org.apache.tika.parser.Parser;
38+
import org.apache.tika.sax.XHTMLBalancingHandler;
3839
import org.apache.tika.sax.XHTMLContentHandler;
3940

4041
/**
@@ -90,11 +91,22 @@ public void parse(TikaInputStream tis, ContentHandler handler, Metadata metadata
9091
TaggedInputStream tagged = new TaggedInputStream(tis);
9192
XHTMLContentHandler xhtml = new XHTMLContentHandler(handler, metadata, context);
9293
xhtml.startDocument();
94+
// Wrap xhtml in a balancing handler so the finally's endDocument
95+
// doesn't fire on an unbalanced stack if the RTF state machine
96+
// emits inconsistent SAX events (e.g., </b> while <p> is topmost
97+
// due to state-vs-stack drift on certain corpus files). Without
98+
// this, StrictXHTMLValidator's </body> vs <p>/<b> at endDocument
99+
// masks the real well-formedness error from inside extract().
100+
XHTMLBalancingHandler balancer = new XHTMLBalancingHandler(xhtml);
93101
try {
94-
parseInline(tis, xhtml, metadata, context);
102+
parseInline(tis, balancer, metadata, context);
95103
} catch (IOException e) {
96104
tagged.throwIfCauseOf(e);
105+
balancer.drainOpenElements();
97106
throw new TikaException("Error parsing an RTF document", e);
107+
} catch (SAXException e) {
108+
balancer.drainOpenElements();
109+
throw e;
98110
} finally {
99111
xhtml.endDocument();
100112
}

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/main/java/org/apache/tika/parser/microsoft/rtf/TextExtractor.java

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -473,7 +473,6 @@ public void extract(InputStream in) throws IOException, SAXException, TikaExcept
473473
}
474474

475475
private void extract(PushbackInputStream in) throws IOException, SAXException, TikaException {
476-
477476
while (true) {
478477
final int b = in.read();
479478
if (b == -1) {
@@ -648,6 +647,16 @@ private void endParagraph(boolean preserveStyles)
648647
lazyStartParagraph();
649648
}
650649
if (inParagraph || paragraphStack.size() > 0) {
650+
// A HYPERLINK's <a> can be open mid-fldrslt when a \par is
651+
// encountered. Closing </p> while <a> is topmost on the SAX
652+
// stack trips StrictXHTMLValidator -- close </a> first and
653+
// forget the hyperlink state (the paragraph break terminates
654+
// the link visually anyway).
655+
if (hyperlinkAnchorDepth >= 0) {
656+
end("a");
657+
fieldState = 0;
658+
hyperlinkAnchorDepth = -1;
659+
}
651660
if (groupState.italic) {
652661
end("i");
653662
groupState.italic = preserveStyles;
@@ -1385,6 +1394,13 @@ private void processControlWord() throws IOException, SAXException, TikaExceptio
13851394
}
13861395
} else if (equals("fldrslt") && fieldState == 2) {
13871396
assert pendingURL != null;
1397+
// Temporarily clear fieldState so lazyStartParagraph's endStyles
1398+
// actually flushes any pending <b>/<i>. endStyles short-circuits
1399+
// when fieldState != 0 to keep style flips inside an already-open
1400+
// <a> from leaking; but here we're about to OPEN the <a>, so any
1401+
// outer <b>/<i> still on the SAX stack must close first or <p>
1402+
// would land on top of <b> and the link's </b> later mismatches.
1403+
fieldState = 0;
13881404
lazyStartParagraph();
13891405
AttributesImpl attrs = new AttributesImpl();
13901406
attrs.addAttribute(XHTML, "href", "href", "CDATA", pendingURL);
@@ -1485,6 +1501,12 @@ private void processGroupEnd() throws IOException, SAXException, TikaException {
14851501
embObjHandler.handleCompletedObject();
14861502
} catch (TikaException | IOException e) {
14871503
EmbeddedDocumentUtil.recordException(e, metadata);
1504+
} catch (RuntimeException e) {
1505+
// POI dispatch on a zero-byte embedded object throws
1506+
// EmptyFileException; other malformed embedded payloads
1507+
// can surface as a variety of runtime exceptions. Record
1508+
// and continue rather than aborting the outer RTF parse.
1509+
EmbeddedDocumentUtil.recordException(e, metadata);
14881510
}
14891511
groupState.objdata = false;
14901512
} else if (groupState.pictDepth > 0) {
@@ -1511,6 +1533,18 @@ private void processGroupEnd() throws IOException, SAXException, TikaException {
15111533
if (groupStates.size() > 0) {
15121534
// Restore group state:
15131535
final GroupState outerGroupState = groupStates.removeLast();
1536+
// If we're leaving the fldrslt group that owns the current <a>,
1537+
// close </a> BEFORE the style restore runs. Otherwise the
1538+
// restore's start("b") would land on top of <a>, and the next
1539+
// </a>/<b> would mismatch. Doing this here also flips fieldState
1540+
// to 0, which un-gates the style compare below so the outer
1541+
// bold/italic context is actually re-emitted.
1542+
if (fieldState == 3 && hyperlinkAnchorDepth >= 0
1543+
&& outerGroupState.depth < hyperlinkAnchorDepth) {
1544+
end("a");
1545+
fieldState = 0;
1546+
hyperlinkAnchorDepth = -1;
1547+
}
15141548
//only modify styles if we're not in a hyperlink
15151549
if (fieldState == 0) {
15161550
// Close italic, if outer does not have italic or

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/ooxml/OOXMLDocxSAXTest.java

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,6 +282,34 @@ public void testEmptyParagraphInTextbox() throws Exception {
282282
assertContains("Vedný odbor:", r.xml);
283283
}
284284

285+
@Test //TIKA-4744
286+
public void testTableInRunInsideParagraph() throws Exception {
287+
// A <w:tbl> nested deeply inside an outer <w:p>'s run (via
288+
// <mc:AlternateContent>/<w:drawing>/<wps:txbx>/<w:txbxContent>) used
289+
// to leave the run's <b>/<i>/etc on the SAX stack below the new
290+
// <table>. A subsequent paragraph end inside a cell then tried to
291+
// closeFormattingTags() for the outer state, emitting </b> while
292+
// <td> was topmost. startTable now closes pending formatting first,
293+
// mirroring startSDT.
294+
XMLResult r = getXML("testWORD_tableInRunInsideParagraph.docx");
295+
assertContains("<table>", r.xml);
296+
}
297+
298+
@Test //TIKA-4744
299+
public void testFormattingFlipPreservesNestingOrder() throws Exception {
300+
// FormattingTagManager.applyFormatting used to close </s> before </u>
301+
// in the bold-flip and italic-flip branches, but XHTML nesting is
302+
// <b><i><s><u> (u is innermost). A run with bold+strike+underline
303+
// followed by a non-bold run trips the wrong-order close: </s>
304+
// emitted while <u> is topmost, then the catch arm's closeAnyPending
305+
// tries </u> against <s> -- the visible </u> vs <s> the validator
306+
// flagged on three corpus files.
307+
XMLResult r = getXML("testWORD_formattingFlipUnderlineOrder.docx");
308+
// Just confirm something extracted -- the real check is no validator
309+
// throw, which getXML enforces via StrictXHTMLValidator.
310+
assertContains("<body>", r.xml);
311+
}
312+
285313
@Test
286314
public void testDOCXOverrideParagraphNumbering() throws Exception {
287315
String xml = getXML("testWORD_override_list_numbering.docx").xml;

tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-microsoft-module/src/test/java/org/apache/tika/parser/microsoft/rtf/RTFParserTest.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,22 @@ public void testBasicExtraction() throws Exception {
6060
assertContains("indexation Word", content);
6161
}
6262

63+
@Test //TIKA-4744
64+
public void testParInsideHyperlink() throws Exception {
65+
// A \par inside a HYPERLINK's \fldrslt used to leave <a> on the SAX
66+
// stack while endParagraph emitted </p>, tripping the strict
67+
// validator with </p> vs <a>. The exception was masked by the
68+
// finally's xhtml.endDocument() throwing </body> vs <p>; visible
69+
// end-state was just "<p> open at endDocument". Closing pending <a>
70+
// before </p> drops the link span at the paragraph break, which is
71+
// the right XHTML rendering.
72+
XMLResult r = getXML("testRTF_parInsideHyperlink.rtf");
73+
// First paragraph contains the link text, properly closed.
74+
assertContains("<a href=\"#target\">line1</a>", r.xml);
75+
// Second paragraph picks up the post-\par text without an open <a>.
76+
assertContains("line2", r.xml);
77+
}
78+
6379
@Test //TIKA-4744
6480
public void testNestedHyperlinkPageRef() throws Exception {
6581
// A HYPERLINK field with a PAGEREF \field nested inside its \fldrslt
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
{\rtf1\ansi
2+
{\fonttbl{\f0 Arial;}}
3+
\pard {\f0 Before }
4+
{\field {\*\fldinst HYPERLINK "#target" }{\fldrslt
5+
{\f0 line1\par line2}
6+
}}
7+
{\f0 after.}
8+
\par
9+
}

0 commit comments

Comments
 (0)