From 65a2937f067536d53954ae0523ea64aa373ba5aa Mon Sep 17 00:00:00 2001 From: Anton Oellerer <13524304+AntonOellerer@users.noreply.github.com> Date: Tue, 8 Aug 2023 16:23:34 +0200 Subject: [PATCH] Rework table paragraph substitution/removal While debugging replacing custom placeholders in tables, another issues related to nested paragraph substitution/removal was uncovered. This has been fixed at the price of more complicated code involving accessing the raw xml stuff. --- build.gradle | 2 +- .../impl/word/ElementRemovalException.java | 8 + .../jocument/impl/word/WordGenerator.java | 6 +- .../jocument/impl/word/WordUtilities.java | 209 +++++++++++------- .../jocument/impl/word/WordGeneratorTest.java | 22 ++ .../CustomPlaceholderInTableTemplate.docx | Bin 0 -> 8183 bytes 6 files changed, 159 insertions(+), 88 deletions(-) create mode 100644 src/main/java/com/docutools/jocument/impl/word/ElementRemovalException.java create mode 100644 src/test/resources/templates/word/CustomPlaceholderInTableTemplate.docx diff --git a/build.gradle b/build.gradle index 77c5b86b..0d25f758 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group 'com.docutools' -version = '1.6.2' +version = '1.6.3-beta.1' java { toolchain { diff --git a/src/main/java/com/docutools/jocument/impl/word/ElementRemovalException.java b/src/main/java/com/docutools/jocument/impl/word/ElementRemovalException.java new file mode 100644 index 00000000..80b870ac --- /dev/null +++ b/src/main/java/com/docutools/jocument/impl/word/ElementRemovalException.java @@ -0,0 +1,8 @@ +package com.docutools.jocument.impl.word; + +//TODO move to checked expression on major version bump +public class ElementRemovalException extends RuntimeException { + public ElementRemovalException(Exception e) { + super(e); + } +} diff --git a/src/main/java/com/docutools/jocument/impl/word/WordGenerator.java b/src/main/java/com/docutools/jocument/impl/word/WordGenerator.java index b203e34b..7ef8eba4 100644 --- a/src/main/java/com/docutools/jocument/impl/word/WordGenerator.java +++ b/src/main/java/com/docutools/jocument/impl/word/WordGenerator.java @@ -17,6 +17,7 @@ import org.apache.poi.xwpf.usermodel.XWPFParagraph; import org.apache.poi.xwpf.usermodel.XWPFSDT; import org.apache.poi.xwpf.usermodel.XWPFTable; +import org.apache.poi.xwpf.usermodel.XWPFTableCell; class WordGenerator { private static final Logger logger = LogManager.getLogger(); @@ -72,9 +73,8 @@ private void transform(XWPFTable table) { table.getRows() .stream() .flatMap(xwpfTableRow -> xwpfTableRow.getTableCells().stream()) - .flatMap(xwpfTableCell -> xwpfTableCell.getParagraphs().stream()) - .filter(xwpfParagraph -> !xwpfParagraph.isEmpty()) - .forEach(this::transform); + .map(XWPFTableCell::getBodyElements) + .forEachOrdered(bodyElements -> new WordGenerator(this.resolver, bodyElements, options).generate()); logger.debug("Transformed table {}", table); } diff --git a/src/main/java/com/docutools/jocument/impl/word/WordUtilities.java b/src/main/java/com/docutools/jocument/impl/word/WordUtilities.java index 89281001..b1a5ecf9 100644 --- a/src/main/java/com/docutools/jocument/impl/word/WordUtilities.java +++ b/src/main/java/com/docutools/jocument/impl/word/WordUtilities.java @@ -1,6 +1,7 @@ package com.docutools.jocument.impl.word; import com.docutools.jocument.impl.ParsingUtils; +import java.io.IOException; import java.util.Collection; import java.util.LinkedList; import java.util.List; @@ -16,6 +17,7 @@ import java.util.stream.Stream; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import org.apache.poi.xwpf.usermodel.IBody; import org.apache.poi.xwpf.usermodel.IBodyElement; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.apache.poi.xwpf.usermodel.XWPFFooter; @@ -26,6 +28,9 @@ import org.apache.poi.xwpf.usermodel.XWPFTableCell; import org.apache.poi.xwpf.usermodel.XWPFTableRow; import org.apache.xmlbeans.XmlCursor; +import org.apache.xmlbeans.XmlObject; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTDocument1; +import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTHdrFtr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTPPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRPr; import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRow; @@ -80,7 +85,7 @@ public static void replaceText(XWPFParagraph paragraph, String newText) { * @return {@code true} when exists */ public static boolean exists(IBodyElement element) { - return findPositionInBody(element).orElseGet(() -> findPositionInHeader(element).orElseGet(() -> findPositionInFooter(element).orElse(-1))) != -1; + return findPositionInBody(element).isPresent() || findInHeader(element) || findInFooter(element) || findNestedInTable(element); } /** @@ -89,25 +94,9 @@ public static boolean exists(IBodyElement element) { * @param element the element * @return the index */ - public static OptionalInt findPositionInBody(IBodyElement element) { - var document = element.getBody().getXWPFDocument(); - if (element instanceof XWPFParagraph xwpfParagraph) { - var position = document.getPosOfParagraph(xwpfParagraph); - if (position >= 0) { - return OptionalInt.of(position); - } else { - return OptionalInt.empty(); - } - } else if (element instanceof XWPFTable xwpfTable) { - var position = document.getPosOfTable(xwpfTable); - if (position >= 0) { - return OptionalInt.of(position); - } else { - return OptionalInt.empty(); - } - } - logger.warn("Failed to find position of element {}", element); - return OptionalInt.empty(); + public static Optional findPositionInBody(IBodyElement element) { + int index = element.getBody().getXWPFDocument().getBodyElements().indexOf(element); + return index == -1 ? Optional.empty() : Optional.of(index); } /** @@ -149,98 +138,150 @@ public static IBodyElement copyBefore(IBodyElement element, IBodyElement destina * @param element the element to be removed */ public static void removeIfExists(IBodyElement element) { - logger.debug("Removing element {}", element); - var document = element.getBody().getXWPFDocument(); - OptionalInt position = findPositionInBody(element); - if (position.isPresent()) { - document.removeBodyElement(position.getAsInt()); - } else { - if (element instanceof XWPFParagraph xwpfParagraph) { - var positionInHeader = findPositionInHeader(xwpfParagraph, document.getHeaderList()); - if (positionInHeader.isPresent()) { - document.getHeaderArray(positionInHeader.getAsInt()).removeParagraph(xwpfParagraph); - } else { - var positionInFooter = findPositionInFooter(xwpfParagraph, document.getFooterList()); - positionInFooter.ifPresent(integer -> document.getHeaderArray(integer).removeParagraph(xwpfParagraph)); + IBody body = element.getBody(); + CTDocument1 document = body.getXWPFDocument().getDocument(); + if (element instanceof XWPFParagraph xwpfParagraph) { + try (XmlCursor xmlCursor = xwpfParagraph.getCTP().newCursor()) { + XmlObject object = getParentObject(xmlCursor); + if (object.equals(document.getBody())) { + findPositionInBody(element).ifPresent(pos -> body.getXWPFDocument().removeBodyElement(pos)); + } else if (object instanceof CTTc ctTc) { + removeElementFromTable(element, ctTc, xmlCursor, body); + } else if (object instanceof CTHdrFtr ctHdrFtr) { + xmlCursor.toParent(); + new XWPFFooter(body.getXWPFDocument(), ctHdrFtr).removeParagraph(xwpfParagraph); } - } else if (element instanceof XWPFTable xwpfTable) { - var positionInHeader = findPositionInHeader(xwpfTable, document.getHeaderList()); - if (positionInHeader.isPresent()) { - document.getHeaderArray(positionInHeader.getAsInt()).removeTable(xwpfTable); - } else { - var positionInFooter = findPositionInFooter(xwpfTable, document.getFooterList()); - positionInFooter.ifPresent(integer -> document.getHeaderArray(integer).removeTable(xwpfTable)); + } catch (IOException e) { + throw new ElementRemovalException(e); + } + } else if (element instanceof XWPFTable xwpfTable) { + try (XmlCursor xmlCursor = xwpfTable.getCTTbl().newCursor()) { + XmlObject object = getParentObject(xmlCursor); + if (object.equals(document.getBody())) { + findPositionInBody(element).ifPresent(pos -> body.getXWPFDocument().removeBodyElement(pos)); + } else if (object instanceof CTTc ctTc) { + removeElementFromTable(element, ctTc, xmlCursor, body); + } else if (object instanceof CTHdrFtr ctHdrFtr) { + xmlCursor.toParent(); + new XWPFFooter(body.getXWPFDocument(), ctHdrFtr).removeTable(xwpfTable); } + } catch (IOException e) { + throw new ElementRemovalException(e); } } } - private static OptionalInt findPositionInHeader(IBodyElement element) { - if (element instanceof XWPFParagraph xwpfParagraph) { - return findPositionInHeader(xwpfParagraph, element.getBody().getXWPFDocument().getHeaderList()); - } else if (element instanceof XWPFTable xwpfTable) { - return findPositionInHeader(xwpfTable, element.getBody().getXWPFDocument().getHeaderList()); - } - return OptionalInt.empty(); + private static XmlObject getParentObject(XmlCursor xmlCursor) { + xmlCursor.toParent(); + return xmlCursor.getObject(); + } + + private static void removeElementFromTable(IBodyElement element, CTTc ctTc, XmlCursor xmlCursor, IBody body) { + XmlObject rowObject = getParentObject(xmlCursor); + XmlObject tableObject = getParentObject(xmlCursor); + XWPFTableCell cell = new XWPFTableCell(ctTc, new XWPFTableRow((CTRow) rowObject, new XWPFTable((CTTbl) tableObject, body)), body); + findPositionInParagraphs(element, cell.getParagraphs()).ifPresent(cell::removeParagraph); + } + + private static boolean findInHeader(IBodyElement element) { + return findInHeader(element, element.getBody().getXWPFDocument().getHeaderList()); } - private static OptionalInt findPositionInHeader(XWPFParagraph xwpfParagraph, List headerList) { - var i = 0; - for (XWPFHeader xwpfHeader : headerList) { - for (XWPFParagraph paragraph : xwpfHeader.getParagraphs()) { - if (xwpfParagraph.equals(paragraph)) { - return OptionalInt.of(i); + private static boolean findInHeader(IBodyElement element, List headers) { + for (XWPFHeader header : headers) { + for (IBodyElement bodyElement : header.getBodyElements()) { + if (element.equals(bodyElement)) { + return true; } } + if (findInTables(element, header.getTables())) { + return true; + } } - return OptionalInt.empty(); + return false; } - private static OptionalInt findPositionInHeader(XWPFTable xwpfTable, List headerList) { - var i = 0; - for (XWPFHeader xwpfHeader : headerList) { - for (XWPFTable table : xwpfHeader.getTables()) { - if (xwpfTable.equals(table)) { - return OptionalInt.of(i); + private static boolean findInFooter(IBodyElement element) { + return findInFooter(element, element.getBody().getXWPFDocument().getFooterList()); + } + + private static boolean findInFooter(IBodyElement element, List footers) { + for (XWPFFooter footer : footers) { + for (IBodyElement bodyElement : footer.getBodyElements()) { + if (element.equals(bodyElement)) { + return true; } } + if (findInTables(element, footer.getTables())) { + return true; + } } - return OptionalInt.empty(); + return false; } - private static OptionalInt findPositionInFooter(IBodyElement element) { - if (element instanceof XWPFParagraph xwpfParagraph) { - return findPositionInFooter(xwpfParagraph, element.getBody().getXWPFDocument().getFooterList()); - } else if (element instanceof XWPFTable xwpfTable) { - return findPositionInFooter(xwpfTable, element.getBody().getXWPFDocument().getFooterList()); + private static boolean findNestedInTable(IBodyElement element) { + for (XWPFTable table : element.getBody().getXWPFDocument().getTables()) { + if (findInTable(element, table)) { + return true; + } } - return OptionalInt.empty(); + return false; } - private static OptionalInt findPositionInFooter(XWPFParagraph xwpfParagraph, List footerList) { - var i = 0; - for (XWPFFooter xwpfFooter : footerList) { - for (XWPFParagraph paragraph : xwpfFooter.getParagraphs()) { - if (xwpfParagraph.equals(paragraph)) { - return OptionalInt.of(i); - } + private static boolean findInTable(IBodyElement element, XWPFTable table) { + for (XWPFTableRow row : table.getRows()) { + if (findInRow(element, row)) { + return true; } } - return OptionalInt.empty(); + return false; } - private static OptionalInt findPositionInFooter(XWPFTable xwpfTable, List footerList) { - var i = 0; - for (XWPFFooter xwpfFooter : footerList) { - for (XWPFTable table : xwpfFooter.getTables()) { - if (xwpfTable.equals(table)) { - return OptionalInt.of(i); - } + private static boolean findInRow(IBodyElement element, XWPFTableRow row) { + for (XWPFTableCell cell : row.getTableCells()) { + if (findInCell(element, cell)) { + return true; } } + return false; + } + + private static boolean findInCell(IBodyElement element, XWPFTableCell cell) { + if (element instanceof XWPFParagraph && findInParagraphs(element, cell.getParagraphs())) { + return true; + } + return findInTables(element, cell.getTables()); + } + + private static boolean findInParagraphs(IBodyElement element, List paragraphs) { + for (XWPFParagraph paragraph : paragraphs) { + if (element.equals(paragraph)) { + return true; + } + } + return false; + } + + private static OptionalInt findPositionInParagraphs(IBodyElement element, List paragraphs) { + var position = 0; + for (XWPFParagraph paragraph : paragraphs) { + if (element.equals(paragraph)) { + return OptionalInt.of(position); + } + position++; + } return OptionalInt.empty(); } + private static boolean findInTables(IBodyElement element, List tables) { + for (XWPFTable nestedTable : tables) { + if ((element instanceof XWPFTable && element.equals(nestedTable)) || findInTable(element, nestedTable)) { + return true; + } + } + return false; + } + /** * Opens a {@link org.apache.xmlbeans.XmlCursor} to the given element in its {@link org.apache.poi.xwpf.usermodel.XWPFDocument}. * @@ -250,10 +291,10 @@ private static OptionalInt findPositionInFooter(XWPFTable xwpfTable, List openCursor(IBodyElement element) { if (element instanceof XWPFParagraph xwpfParagraph) { logger.debug("Opening cursor to paragraph {}", xwpfParagraph); - return Optional.of((xwpfParagraph).getCTP().newCursor()); + return Optional.of(xwpfParagraph.getCTP().newCursor()); } else if (element instanceof XWPFTable xwpfTable) { logger.debug("Opening cursor to table {}", xwpfTable); - return Optional.of((xwpfTable).getCTTbl().newCursor()); + return Optional.of(xwpfTable.getCTTbl().newCursor()); } else { logger.warn("Failed to open cursor to element {}", element); return Optional.empty(); diff --git a/src/test/java/com/docutools/jocument/impl/word/WordGeneratorTest.java b/src/test/java/com/docutools/jocument/impl/word/WordGeneratorTest.java index 65be775e..69e45577 100644 --- a/src/test/java/com/docutools/jocument/impl/word/WordGeneratorTest.java +++ b/src/test/java/com/docutools/jocument/impl/word/WordGeneratorTest.java @@ -26,6 +26,7 @@ import java.util.Locale; import org.apache.poi.xwpf.usermodel.XWPFDocument; import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; @@ -126,6 +127,27 @@ void shouldReplacePlaceholdersInTables() throws InterruptedException, IOExceptio assertThat(table.row(2).cell(1).bodyElement(0).asParagraph().text(), equalTo(birthdate)); } + @Test + @Disabled("Pending apache poi 5.2.4 release") + @DisplayName("Replace custom placeholders in tables.") + void shouldReplaceCustomPlaceholderInTable() throws InterruptedException, IOException { + // Arrange + Template template = Template.fromClassPath("/templates/word/CustomPlaceholderInTableTemplate.docx") + .orElseThrow(); + PlaceholderResolver resolver = new ReflectionResolver(SampleModelData.PICARD); + + // Act + Document document = template.startGeneration(resolver); + document.blockUntilCompletion(60000L); // 1 minute + + // Assert + assertThat(document.completed(), is(true)); + xwpfDocument = TestUtils.getXWPFDocumentFromDocument(document); + var documentWrapper = new XWPFDocumentWrapper(xwpfDocument); + var table = documentWrapper.bodyElement(0).asTable(); + assertThat(table.row(0).cell(1).bodyElement(0).asParagraph().run(0).pictures().size(), equalTo(1)); + } + @Test @DisplayName("Resolve collection placeholders.") void shouldResolveCollectionPlaceholders() throws InterruptedException, IOException { diff --git a/src/test/resources/templates/word/CustomPlaceholderInTableTemplate.docx b/src/test/resources/templates/word/CustomPlaceholderInTableTemplate.docx new file mode 100644 index 0000000000000000000000000000000000000000..f9ad6dffc9800c265e957499ad815f55a08ae6b7 GIT binary patch literal 8183 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 Wk%xb904ON5hu6)+A=`R7IsXTiGbM!p literal 0 HcmV?d00001