This is an automated email from the ASF dual-hosted git repository.

tballison pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tika.git


The following commit(s) were added to refs/heads/main by this push:
     new 742b468ce3 TIKA-5014: write dupes up to a point (#3463)
742b468ce3 is described below

commit 742b468ce337897b60ea20954735c399a15fa265
Author: Tim Allison <[email protected]>
AuthorDate: Fri Oct 30 17:07:11 2026 -0400

    TIKA-5014: write dupes up to a point (#3463)
    
    * TIKA-5014: write dupes up to a point
    
    * TIKA-5014: update ODS macro tests for dropped empty rows
---
 CHANGES.txt                                        |   7 +
 .../org/apache/tika/parser/odf/ODFParserTest.java  |   6 +-
 .../tika/parser/odf/FlatOpenDocumentParser.java    |  17 +-
 .../tika/parser/odf/OpenDocumentBodyHandler.java   |  67 ++++-
 .../tika/parser/odf/OpenDocumentContentParser.java |   6 +-
 .../tika/parser/odf/RepeatedElementHandler.java    | 271 +++++++++++++++++++++
 .../parser/odf/OpenDocumentContentParserTest.java  | 102 ++++++++
 7 files changed, 453 insertions(+), 23 deletions(-)

diff --git a/CHANGES.txt b/CHANGES.txt
index 77e7c60b46..1846044090 100644
--- a/CHANGES.txt
+++ b/CHANGES.txt
@@ -56,6 +56,13 @@ Release 4.3.0 - ???
      and split GFM table cells on |. Math is emitted as <span class="math 
inline"> or
      <span class="math display">, and the Markdown writer outputs it unescaped 
(TIKA-5011).
 
+   * The OpenDocument parser writes repeated cells and rows 
(table:number-columns-repeated,
+     table:number-rows-repeated) once per repeat; a cell was written once with 
that
+     colspan and a row once, so values repeated across columns or rows were 
lost. Empty
+     cells and rows at the end of a row or table, such as the filler out to a 
sheet's
+     last column and row, are dropped. Past 10 million characters of repeated 
content
+     per document the rest are written once, with a tk:exception:warn entry 
(TIKA-5014).
+
    * VLM text recognizers keep superscripts and subscripts: inline <sup>/<sub> 
in model
      output (x<sup>2</sup>, H<sub>2</sub>O, footnote markers) reach the XHTML 
as sup/sub
      instead of plain text ("x2"), and the Markdown writer outputs them as 
inline HTML
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/odf/ODFParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/odf/ODFParserTest.java
index c2b98b799e..c2b8f82842 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/odf/ODFParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-integration-tests/src/test/java/org/apache/tika/parser/odf/ODFParserTest.java
@@ -91,7 +91,8 @@ public class ODFParserTest extends TikaTest {
         assertEquals(4, metadataList.size());
         Metadata parent = metadataList.get(0);
 
-        assertContains("<tr>", parent.get(TikaCoreProperties.TIKA_CONTENT));
+        // the sheet is an image shape and one empty row, which is dropped 
(TIKA-5014)
+        assertContains("<table><img", 
parent.get(TikaCoreProperties.TIKA_CONTENT));
         assertEquals("application/vnd.oasis.opendocument.spreadsheet",
                 parent.get(HttpHeaders.CONTENT_TYPE));
 
@@ -169,7 +170,8 @@ public class ODFParserTest extends TikaTest {
         assertEquals(3, metadataList.size());
         Metadata parent = metadataList.get(0);
 
-        assertContains("<tr>", parent.get(TikaCoreProperties.TIKA_CONTENT));
+        // the sheet is an image shape and one empty row, which is dropped 
(TIKA-5014)
+        assertContains("<table><img", 
parent.get(TikaCoreProperties.TIKA_CONTENT));
         assertEquals("application/vnd.oasis.opendocument.flat.spreadsheet",
                 parent.get(HttpHeaders.CONTENT_TYPE));
 
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/FlatOpenDocumentParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/FlatOpenDocumentParser.java
index 9f391a6e83..26a31af9b5 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/FlatOpenDocumentParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/FlatOpenDocumentParser.java
@@ -110,13 +110,14 @@ public class FlatOpenDocumentParser implements Parser {
         // and masks the real well-formedness error.
         final XHTMLBalancingHandler balancer = new 
XHTMLBalancingHandler(xhtml);
         tis.setCloseShield();
+        FlatOpenDocumentParserHandler fodHandler = null;
         try {
-            ContentHandler fodHandler = getContentHandler(balancer, metadata, 
context);
+            fodHandler = getContentHandler(balancer, metadata, context);
             XMLReaderUtils.parseSAX(tis,
                     new EmbeddedContentHandler(fodHandler), context);
             //can only detect subtype (text/pres/sheet) during parse.
             //update it here.
-            MediaType detected = ((FlatOpenDocumentParserHandler) 
fodHandler).getDetectedType();
+            MediaType detected = fodHandler.getDetectedType();
             if (detected != null) {
                 metadata.set(HttpHeaders.CONTENT_TYPE, detected.toString());
             }
@@ -125,6 +126,9 @@ public class FlatOpenDocumentParser implements Parser {
             // loop. Without this, the finally's xhtml.endDocument() throws
             // on the unbalanced stack and the masked </body> vs <p>/<b>
             // hides the real failure (truncated input, malformed XML, etc.).
+            if (fodHandler != null) {
+                fodHandler.bodyHandler.flushBuffered(e);
+            }
             balancer.drainOpenElements();
             throw e;
         } finally {
@@ -140,8 +144,9 @@ public class FlatOpenDocumentParser implements Parser {
     public boolean isExtractMacros() {
         return extractMacros;
     }
-    private ContentHandler getContentHandler(ContentHandler handler, Metadata 
metadata,
-                                             ParseContext context) {
+    private FlatOpenDocumentParserHandler getContentHandler(ContentHandler 
handler,
+                                                            Metadata metadata,
+                                                            ParseContext 
context) {
         return new FlatOpenDocumentParserHandler(handler, metadata, context, 
extractMacros);
     }
 
@@ -154,7 +159,7 @@ public class FlatOpenDocumentParser implements Parser {
 
         private final ContentHandler defaultHandler = new DefaultHandler();
 
-        private final ContentHandler bodyHandler;
+        private final OpenDocumentBodyHandler bodyHandler;
         private final ContentHandler metadataHandler;
         private final ContentHandler macroHandler;
         private final boolean extractMacros;
@@ -180,7 +185,7 @@ public class FlatOpenDocumentParser implements Parser {
             this.extractMacros = extractMacros;
 
             this.bodyHandler = new OpenDocumentBodyHandler(new 
NSNormalizerContentHandler(baseHandler),
-                            parseContext);
+                            parseContext, metadata);
 
             this.metadataHandler = new NSNormalizerContentHandler(
                     OpenDocumentMetaParser.getContentHandler(metadata, 
parseContext));
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentBodyHandler.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentBodyHandler.java
index 463d212f0a..3435dbfd2a 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentBodyHandler.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentBodyHandler.java
@@ -72,6 +72,9 @@ class OpenDocumentBodyHandler extends 
ElementMappingContentHandler {
             new HashMap<>();
     private static final char[] SPACE = new char[]{' '};
     private static final String CLASS = "class";
+    // ODF 1.2+ sheet limits
+    private static final int MAX_COLUMNS = 16384;
+    private static final int MAX_ROWS = 1048576;
     private static final Attributes ANNOTATION_ATTRIBUTES = 
buildAttributes(CLASS, "annotation");
     private static final Attributes NOTE_ATTRIBUTES = buildAttributes(CLASS, 
"note");
     private static final Attributes NOTES_ATTRIBUTES = buildAttributes(CLASS, 
"notes");
@@ -104,27 +107,21 @@ class OpenDocumentBodyHandler extends 
ElementMappingContentHandler {
 
         // create HTML tables from table:-tags
         MAPPINGS.put(new QName(TABLE_NS, "table"), new TargetElement(XHTML, 
"table"));
-        // repeating of rows is ignored; for columns, see below!
+        // number-rows-repeated: see RepeatedElementHandler
         MAPPINGS.put(new QName(TABLE_NS, "table-row"), new 
TargetElement(XHTML, "tr"));
         // special mapping for rowspan/colspan attributes
         final HashMap<QName, QName> tableCellAttsMapping = new HashMap<>();
         tableCellAttsMapping
                 .put(new QName(TABLE_NS, "number-columns-spanned"), new 
QName("colspan"));
         tableCellAttsMapping.put(new QName(TABLE_NS, "number-rows-spanned"), 
new QName("rowspan"));
-        /* TODO: The following is not correct, the cell should be repeated not 
spanned!
-         * Code generates a HTML cell, spanning all repeated columns, to make 
the cell look correct.
-         * Problems may occur when both spanning and repeating is given, which 
is not allowed by
-         *  spec.
-         * Cell spanning instead of repeating  is not a problem, because 
OpenOffice uses it
-         * only for empty cells.
-         */
-        tableCellAttsMapping
-                .put(new QName(TABLE_NS, "number-columns-repeated"), new 
QName("colspan"));
+        // number-columns-repeated: see RepeatedElementHandler
         MAPPINGS.put(new QName(TABLE_NS, "table-cell"),
                 new TargetElement(XHTML, "td", tableCellAttsMapping));
     }
 
     private final ContentHandler handler;
+    private final RepeatedElementHandler repeatedCells;
+    private final RepeatedElementHandler repeatedRows;
     private final ParseContext parseContext;
     private final BitSet textNodeStack = new BitSet();
     //have we written the start style tags
@@ -156,12 +153,49 @@ class OpenDocumentBodyHandler extends 
ElementMappingContentHandler {
     // is that bold/italic/underline runs that begin or end inside a link lose
     // their formatting in the captured XHTML.
     private int anchorDepth = 0;
-    OpenDocumentBodyHandler(ContentHandler handler, ParseContext parseContext) 
{
-        super(handler, MAPPINGS);
-        this.handler = handler;
+    OpenDocumentBodyHandler(ContentHandler handler, ParseContext parseContext, 
Metadata metadata) {
+        this(new RepeatedElementHandler(handler, "tr", "table", MAX_ROWS,
+                new RepeatedElementHandler.Budget(metadata)), parseContext);
+    }
+
+    private OpenDocumentBodyHandler(RepeatedElementHandler rows, ParseContext 
parseContext) {
+        this(new RepeatedElementHandler(rows, "td", "tr", MAX_COLUMNS, 
rows.budget()), rows,
+                parseContext);
+    }
+
+    private OpenDocumentBodyHandler(RepeatedElementHandler cells, 
RepeatedElementHandler rows,
+                                    ParseContext parseContext) {
+        super(cells, MAPPINGS);
+        this.handler = cells;
+        this.repeatedCells = cells;
+        this.repeatedRows = rows;
         this.parseContext = parseContext;
     }
 
+    /**
+     * Writes table content still held for repeat handling. Call when the 
parse fails,
+     * so the partly read row is not lost.
+     */
+    void flushBuffered(Exception cause) {
+        try {
+            repeatedCells.flushBuffered();
+            repeatedRows.flushBuffered();
+        } catch (SAXException e) {
+            cause.addSuppressed(e);
+        }
+    }
+
+    private static int parseRepeat(String value) {
+        if (value == null) {
+            return 1;
+        }
+        try {
+            return Math.max(1, Integer.parseInt(value.trim()));
+        } catch (NumberFormatException e) {
+            return 1;
+        }
+    }
+
     private static Attributes buildAttributes(String key, String value) {
         AttributesImpl attrs = new AttributesImpl();
         attrs.addAttribute("", key, key, "CDATA", value);
@@ -478,6 +512,13 @@ class OpenDocumentBodyHandler extends 
ElementMappingContentHandler {
         }
         // call next handler if no filtering
         if (completelyFiltered == 0) {
+            if (TABLE_NS.equals(namespaceURI) && 
"table-cell".equals(localName)) {
+                repeatedCells.repeatNext(
+                        parseRepeat(attrs.getValue(TABLE_NS, 
"number-columns-repeated")));
+            } else if (TABLE_NS.equals(namespaceURI) && 
"table-row".equals(localName)) {
+                repeatedRows.repeatNext(
+                        parseRepeat(attrs.getValue(TABLE_NS, 
"number-rows-repeated")));
+            }
             // special handling of text:h, that are directly passed
             // to incoming handler
             if (TEXT_NS.equals(namespaceURI) && "h".equals(localName)) {
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentContentParser.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentContentParser.java
index a9646265e0..8fce0ef133 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentContentParser.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/OpenDocumentContentParser.java
@@ -22,7 +22,6 @@ import java.util.Set;
 
 import org.xml.sax.ContentHandler;
 import org.xml.sax.SAXException;
-import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.exception.TikaException;
 import org.apache.tika.io.TikaInputStream;
@@ -50,12 +49,15 @@ public class OpenDocumentContentParser implements Parser {
     void parseInternal(TikaInputStream tis, final ContentHandler handler, 
Metadata metadata,
                        ParseContext context) throws IOException, SAXException, 
TikaException {
 
-        DefaultHandler dh = new OpenDocumentBodyHandler(handler, context);
+        OpenDocumentBodyHandler dh = new OpenDocumentBodyHandler(handler, 
context, metadata);
 
         tis.setCloseShield();
         try {
             XMLReaderUtils.parseSAX(tis,
                     new NSNormalizerContentHandler(dh), context);
+        } catch (SAXException | IOException | TikaException e) {
+            dh.flushBuffered(e);
+            throw e;
         } finally {
             tis.removeCloseShield();
         }
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/RepeatedElementHandler.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/RepeatedElementHandler.java
new file mode 100644
index 0000000000..17b5ba6ec6
--- /dev/null
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/main/java/org/apache/tika/parser/odf/RepeatedElementHandler.java
@@ -0,0 +1,271 @@
+/*
+ * 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.odf;
+
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Deque;
+import java.util.List;
+
+import org.xml.sax.Attributes;
+import org.xml.sax.ContentHandler;
+import org.xml.sax.SAXException;
+import org.xml.sax.helpers.AttributesImpl;
+
+import org.apache.tika.metadata.Metadata;
+import org.apache.tika.metadata.TikaCoreProperties;
+import org.apache.tika.sax.ContentHandlerDecorator;
+
+/**
+ * Writes an ODF table cell or row that carries a repeat count
+ * ({@code table:number-columns-repeated}, {@code table:number-rows-repeated}) 
once per
+ * repeat.
+ * <p>
+ * ODF stores runs of identical cells or rows once with a count. One with 
content is
+ * replayed. Empty ones, repeated or not, are held back and written only if a 
sibling
+ * with content follows before the parent closes, so the filler that pads a 
sheet out to
+ * its last column or row (often thousands of cells, a million rows) is 
dropped.
+ */
+class RepeatedElementHandler extends ContentHandlerDecorator {
+
+    /** Characters a document may add by replaying, beyond each element's 
first copy. */
+    static final long MAX_REPLAYED_CHARS = 10_000_000L;
+
+    /** Replay budget shared by the cell and row handlers of one document. */
+    static final class Budget {
+        private final Metadata metadata;
+        private long replayedChars;
+        private boolean warned;
+
+        Budget(Metadata metadata) {
+            this.metadata = metadata;
+        }
+
+        /** Whether {@code extra} more characters fit; warns once when they 
don't. */
+        boolean take(long extra) {
+            if (replayedChars + extra <= MAX_REPLAYED_CHARS) {
+                replayedChars += extra;
+                return true;
+            }
+            if (!warned && metadata != null) {
+                metadata.add(TikaCoreProperties.TIKA_META_EXCEPTION_WARNING,
+                        "OpenDocument repeated cells/rows exceeded " + 
MAX_REPLAYED_CHARS
+                                + " replayed characters; the rest were written 
once");
+            }
+            warned = true;
+            return false;
+        }
+    }
+
+    /** A run of held-back empty elements sharing a start tag. */
+    private static final class EmptyRun {
+        final String uri;
+        final String localName;
+        final String name;
+        final Attributes atts;
+        int count;
+
+        EmptyRun(String uri, String localName, String name, Attributes atts, 
int count) {
+            this.uri = uri;
+            this.localName = localName;
+            this.name = name;
+            this.atts = atts;
+            this.count = count;
+        }
+    }
+
+    private interface Event {
+        void replay(ContentHandler h) throws SAXException;
+    }
+
+    private final ContentHandler out;
+    private final String element;
+    private final String parent;
+    private final int maxRepeat;
+    private final Budget budget;
+
+    private int nextRepeat;
+    private List<Event> buffer;
+    private String bufferUri;
+    private String bufferName;
+    private Attributes bufferAtts;
+    private int depth;
+    private int repeat;
+    private boolean hasContent;
+    private long chars;
+    private final Deque<EmptyRun> pendingEmpty = new ArrayDeque<>();
+    private long pendingCount;
+
+    /**
+     * @param element the XHTML element that repeats ({@code td} or {@code tr})
+     * @param parent  the element whose end drops pending empty copies
+     */
+    RepeatedElementHandler(ContentHandler out, String element, String parent, 
int maxRepeat,
+                           Budget budget) {
+        super(out);
+        this.out = out;
+        this.element = element;
+        this.parent = parent;
+        this.maxRepeat = maxRepeat;
+        this.budget = budget;
+    }
+
+    /** After a parse failure: writes a buffered element as far as it got, 
once. */
+    void flushBuffered() throws SAXException {
+        if (buffer != null) {
+            List<Event> partial = buffer;
+            buffer = null;
+            for (Event e : partial) {
+                e.replay(out);
+            }
+        }
+    }
+
+    Budget budget() {
+        return budget;
+    }
+
+    /** Called before the start of an element whose repeat count is {@code n}. 
*/
+    void repeatNext(int n) {
+        if (buffer == null) {
+            nextRepeat = Math.min(n, maxRepeat);
+        }
+    }
+
+    @Override
+    public void startElement(String uri, String localName, String name, 
Attributes atts)
+            throws SAXException {
+        if (buffer != null) {
+            depth++;
+            if (localName.equals("img")) {
+                hasContent = true;
+            }
+            AttributesImpl copy = new AttributesImpl(atts);
+            buffer.add(h -> h.startElement(uri, localName, name, copy));
+            return;
+        }
+        if (localName.equals(element)) {
+            buffer = new ArrayList<>();
+            depth = 0;
+            repeat = Math.max(1, nextRepeat);
+            hasContent = false;
+            chars = 0;
+            nextRepeat = 0;
+            AttributesImpl copy = new AttributesImpl(atts);
+            bufferUri = uri;
+            bufferName = name;
+            bufferAtts = copy;
+            buffer.add(h -> h.startElement(uri, localName, name, copy));
+            return;
+        } else if (localName.equals(parent)) {
+            clearPending();
+        }
+        nextRepeat = 0;
+        super.startElement(uri, localName, name, atts);
+    }
+
+    @Override
+    public void endElement(String uri, String localName, String name) throws 
SAXException {
+        if (buffer != null) {
+            buffer.add(h -> h.endElement(uri, localName, name));
+            if (depth-- > 0) {
+                return;
+            }
+            List<Event> done = buffer;
+            buffer = null;
+            if (!hasContent) {
+                hold(bufferUri, localName, bufferName, bufferAtts, repeat);
+                return;
+            }
+            writePendingEmpty();
+            int copies = repeat;
+            if (copies > 1 && !budget.take(chars * (copies - 1))) {
+                copies = 1;
+            }
+            for (int i = 0; i < copies; i++) {
+                for (Event e : done) {
+                    e.replay(out);
+                }
+            }
+            return;
+        }
+        if (localName.equals(parent)) {
+            clearPending();
+        }
+        super.endElement(uri, localName, name);
+    }
+
+    @Override
+    public void characters(char[] ch, int start, int length) throws 
SAXException {
+        if (buffer != null) {
+            char[] copy = new char[length];
+            System.arraycopy(ch, start, copy, 0, length);
+            if (!hasContent) {
+                for (char c : copy) {
+                    if (!Character.isWhitespace(c)) {
+                        hasContent = true;
+                        break;
+                    }
+                }
+            }
+            chars += length;
+            buffer.add(h -> h.characters(copy, 0, copy.length));
+            return;
+        }
+        super.characters(ch, start, length);
+    }
+
+    @Override
+    public void ignorableWhitespace(char[] ch, int start, int length) throws 
SAXException {
+        if (buffer != null) {
+            char[] copy = new char[length];
+            System.arraycopy(ch, start, copy, 0, length);
+            buffer.add(h -> h.ignorableWhitespace(copy, 0, copy.length));
+            return;
+        }
+        super.ignorableWhitespace(ch, start, length);
+    }
+
+    private void hold(String uri, String localName, String name, Attributes 
atts, int count) {
+        int n = (int) Math.min(count, maxRepeat - pendingCount);
+        if (n <= 0) {
+            return;
+        }
+        EmptyRun last = pendingEmpty.peekLast();
+        if (last != null && last.atts.getLength() == 0 && atts.getLength() == 
0) {
+            last.count += n;
+        } else {
+            pendingEmpty.addLast(new EmptyRun(uri, localName, name, atts, n));
+        }
+        pendingCount += n;
+    }
+
+    private void clearPending() {
+        pendingEmpty.clear();
+        pendingCount = 0;
+    }
+
+    private void writePendingEmpty() throws SAXException {
+        for (EmptyRun run : pendingEmpty) {
+            for (int i = 0; i < run.count; i++) {
+                super.startElement(run.uri, run.localName, run.name, run.atts);
+                super.endElement(run.uri, run.localName, run.name);
+            }
+        }
+        clearPending();
+    }
+}
diff --git 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentContentParserTest.java
 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentContentParserTest.java
index 8acf8c4ba7..5a0b87f7ef 100644
--- 
a/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentContentParserTest.java
+++ 
b/tika-parsers/tika-parsers-standard/tika-parsers-standard-modules/tika-parser-miscoffice-module/src/test/java/org/apache/tika/parser/odf/OpenDocumentContentParserTest.java
@@ -16,7 +16,9 @@
  */
 package org.apache.tika.parser.odf;
 
+import static org.junit.jupiter.api.Assertions.assertEquals;
 import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.nio.charset.StandardCharsets;
@@ -25,6 +27,7 @@ import java.util.regex.Pattern;
 
 import org.apache.commons.compress.compressors.gzip.GzipCompressorInputStream;
 import org.junit.jupiter.api.Test;
+import org.xml.sax.SAXException;
 import org.xml.sax.helpers.DefaultHandler;
 
 import org.apache.tika.TikaTest;
@@ -32,6 +35,7 @@ import org.apache.tika.io.TikaInputStream;
 import org.apache.tika.metadata.Metadata;
 import org.apache.tika.parser.ParseContext;
 import org.apache.tika.parser.Parser;
+import org.apache.tika.sax.ToXMLContentHandler;
 import org.apache.tika.utils.XMLReaderUtils;
 
 public class OpenDocumentContentParserTest extends TikaTest {
@@ -68,4 +72,102 @@ public class OpenDocumentContentParserTest extends TikaTest 
{
         
XMLReaderUtils.parseSAX(TikaInputStream.get(xml.getBytes(StandardCharsets.UTF_8)),
                 new DefaultHandler(), new ParseContext());
     }
+
+    @Test
+    public void testRepeatedCells() throws Exception {
+        String content = "<office:document-content"
+                + " 
xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+                + " 
xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
+                + " 
xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
+                + "<office:body><office:spreadsheet><table:table 
table:name=\"S\">"
+                + "<table:table-row>"
+                + "<table:table-cell 
table:number-columns-repeated=\"3\"><text:p>TEST</text:p>"
+                + "</table:table-cell>"
+                + "<table:table-cell table:number-columns-repeated=\"1020\"/>"
+                + "</table:table-row><table:table-row>"
+                + "<table:table-cell><text:p>a</text:p></table:table-cell>"
+                + "<table:table-cell table:number-columns-repeated=\"2\"/>"
+                + "<table:table-cell><text:p>b</text:p></table:table-cell>"
+                + "<table:table-cell table:number-columns-repeated=\"1000\"/>"
+                + "</table:table-row><table:table-row>"
+                + "<table:table-cell 
table:number-columns-spanned=\"2\"><text:p>wide</text:p>"
+                + "</table:table-cell><table:covered-table-cell/>"
+                + "<table:table-cell><text:p>c</text:p></table:table-cell>"
+                + "</table:table-row>"
+                // a repeated row with content, empty rows between values, 
trailing filler
+                + "<table:table-row table:number-rows-repeated=\"2\">"
+                + "<table:table-cell><text:p>r</text:p></table:table-cell>"
+                + "<table:table-cell 
table:number-columns-repeated=\"2\"><text:p>s</text:p>"
+                + "</table:table-cell></table:table-row>"
+                + "<table:table-row table:number-rows-repeated=\"2\">"
+                + "<table:table-cell 
table:number-columns-repeated=\"1024\"/></table:table-row>"
+                + 
"<table:table-row><table:table-cell><text:p>z</text:p></table:table-cell>"
+                + "</table:table-row>"
+                + "<table:table-row table:number-rows-repeated=\"1048000\">"
+                + "<table:table-cell 
table:number-columns-repeated=\"1024\"/></table:table-row>"
+                // a plain empty row after the filler must not bring it back
+                + 
"<table:table-row><table:table-cell/><table:table-cell/></table:table-row>"
+                + 
"</table:table></office:spreadsheet></office:body></office:document-content>";
+        String xml;
+        try (TikaInputStream tis = 
TikaInputStream.get(content.getBytes(StandardCharsets.UTF_8))) {
+            xml = getXML(tis, new OpenDocumentContentParser(), new 
Metadata()).xml;
+        }
+        String table = xml.substring(xml.indexOf("<table>"), 
xml.indexOf("</table>"))
+                .replaceAll("\\s+", "");
+        
assertEquals("<table><tr><td><p>TEST</p></td><td><p>TEST</p></td><td><p>TEST</p></td></tr>"
+                + "<tr><td><p>a</p></td><td/><td/><td><p>b</p></td></tr>"
+                + "<tr><tdcolspan=\"2\"><p>wide</p></td><td><p>c</p></td></tr>"
+                + 
"<tr><td><p>r</p></td><td><p>s</p></td><td><p>s</p></td></tr>"
+                + 
"<tr><td><p>r</p></td><td><p>s</p></td><td><p>s</p></td></tr>"
+                + "<tr/><tr/><tr><td><p>z</p></td></tr>", table);
+    }
+
+    @Test
+    public void testRepeatedCellBudget() throws Exception {
+        String big = "x".repeat(1000);
+        String content = "<office:document-content"
+                + " 
xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+                + " 
xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
+                + " 
xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
+                + "<office:body><office:spreadsheet><table:table 
table:name=\"S\">"
+                + "<table:table-row><table:table-cell 
table:number-columns-spanned=\"2\"/>"
+                + 
"<table:covered-table-cell/><table:table-cell><text:p>v</text:p>"
+                + "</table:table-cell></table:table-row>"
+                + "<table:table-row><table:table-cell 
table:number-columns-repeated=\"16384\">"
+                + "<text:p>" + big + 
"</text:p></table:table-cell></table:table-row>"
+                + 
"</table:table></office:spreadsheet></office:body></office:document-content>";
+        Metadata metadata = new Metadata();
+        String xml;
+        try (TikaInputStream tis = 
TikaInputStream.get(content.getBytes(StandardCharsets.UTF_8))) {
+            xml = getXML(tis, new OpenDocumentContentParser(), metadata).xml;
+        }
+        // an empty merged cell keeps its span when a later cell is written
+        assertContains("<td colspan=\"2\" />", xml);
+        assertEquals(1, xml.split(big, -1).length - 1);
+        assertContains("written once",
+                
metadata.get(org.apache.tika.metadata.TikaCoreProperties.TIKA_META_EXCEPTION_WARNING));
+    }
+
+    @Test
+    public void testRowInProgressKeptOnParseFailure() throws Exception {
+        String content = "<office:document-content"
+                + " 
xmlns:office=\"urn:oasis:names:tc:opendocument:xmlns:office:1.0\""
+                + " 
xmlns:table=\"urn:oasis:names:tc:opendocument:xmlns:table:1.0\""
+                + " 
xmlns:text=\"urn:oasis:names:tc:opendocument:xmlns:text:1.0\">"
+                + "<office:body><office:spreadsheet><table:table 
table:name=\"S\">"
+                + 
"<table:table-row><table:table-cell><text:p>first</text:p></table:table-cell>"
+                + "</table:table-row><table:table-row>"
+                + "<table:table-cell><text:p>kept</text:p></table:table-cell>"
+                // duplicate attribute: not well-formed
+                + "<table:table-cell office:a=\"1\" office:a=\"2\"/>"
+                + 
"</table:table-row></table:table></office:spreadsheet></office:body>"
+                + "</office:document-content>";
+        ToXMLContentHandler out = new ToXMLContentHandler();
+        try (TikaInputStream tis = 
TikaInputStream.get(content.getBytes(StandardCharsets.UTF_8))) {
+            assertThrows(SAXException.class, () -> new 
OpenDocumentContentParser()
+                    .parse(tis, out, new Metadata(), new ParseContext()));
+        }
+        assertContains("first", out.toString());
+        assertContains("kept", out.toString());
+    }
 }

Reply via email to