Постановка задачи

Задача: у нас есть документ формата *.docx содержащий шаблон некоторого пользовательского отчёта. Соответственно необходимо наполнить его данными, но при этом обязательно сохранить пользовательское форматирование.
Например, если в документе встречается шаблон вида "{шаблон}" и он имеет некоторое форматирование (цвет, шрифт, размер, заголовок и пр), то после замены это форматирование должно быть сохранено.


Больше того, если мы встречаем многострочный шаблон вида
{начало-шаблона-для-каждого-сотрудника
<вывести-фио-сотрудника> - <вывести-возраст-сотрудника>
конец-шаблона-для-каждого-сотрудника}

То мы должны скопировать содержимое многострочного шаблона столько раз, сколько у нас имеется сотрудников с сохранением всего того, что находится в шаблоне (таблицы, параграфы, заголовки. Любой степени вложенности друг в друга).
До кучи добавим что многострочные шаблоны могут быть вложены друг в друга, но это уже немного за пределами данной публикации.

Зачем и для кого написана данная статья

К сожалению, в сети почти нет примером решения подобной задачи относительно формата .docx на java.
Чтобы восполнить этот недостаток и написана данная статья.Я пишу её в том виде, в каком хотел бы найти в сети когда начинал работать над данной задачей.

Отказ от ответственности и предупреждение о грязном коде

Ниже будет много примеров относительно грязного кода. Я отдельно акцентирую этот момент, что код грязный, но рабочий в том смысле что он работает у меня, в теле документа. Весьма вероятно что сей код упускает пограничные случаи вроде колонтитулов, футеров и прочих "особенностей".
Возможно вам потребуется доработать предложенные примеры кода самостоятельно.
В любом случае вы используете его на свой страх и риск.

Почему XWPFDocument из API Apache POI это "ужас ужасный", а его разработчиков надо сослать в арктику считать снежинки на аналоговых калькуляторах

Просто два факта

  1. Документ XWPFDocument состоит из элементов типа IBodyElement. Собственно IBodyElement это общий интерфейс за которым может скрываться параграф - XWPFParagraph, таблица - XWPFTable или неведомая хрень под названием CONTENTCONTROL.
    Каждый IBodyElement имеет метод "получить родителя"(getBody), то есть тот компонент на котором он сам располагается.
    Параграф может располагаться в ячейке таблицы. Таблица внутри параграфа и так далее.
    Метод getBody() возвращает интерфейс IBody у которого можно запросить список всех элементов которые на нём располагаются getElementType().
    Логично?
    Логично.
    Но это только пока...
    Допустим мы стоим на каком-то параграфе (в котором мы нашли ��нтересующий нас текст). Как узнать контейнер для этого параграфа, то есть тот элемент на котором он лежит?
    Достаточно просто, применяем getBody() от параграфа (или от IBodyElement-а в общем случае) и получаем элемент типа IBody.
    Чувствуете уже этот лёгкий элемент безумия?
    Элемент имеет тип IBodyElement, а его родитель - IBody, хотя это, явно, тот же самый элемент.
    Ладно, допустим в этом есть какой-то тайный смысл. Но как нам узнать родителя элемента который является родителем для нашего параграфа? Другими словами как узнать внутри какого элемента лежит наш IBody?
    А никак! То есть вообще никак. По крайней мере мне этот способ неизвестен и только очень отдалённый намёк на него даёт метод getPart() возвращающий POIXMLDocumentPart что является более низким уровнем управления пакетом OOXML.

  2. Просто попробуйте решить самые простые задачи вроде: заменить все вхождения одного текста на другой. Попробуйте вставить новый параграф после заданного IBodyElement-а или любую другую задачу для решения которых пришлось писать довольно нетривиальный код в примерах ниже.

Переходим к примерам кода:

Общий пример обработки .docx файла в контроллере спринга
    @PostMapping(value = "/docx", consumes = {MediaType.MULTIPART_FORM_DATA_VALUE})
    public ResponseEntity<Resource> generateReportForTemplate(@RequestPart("file") MultipartFile file, @RequestParam Map<String, String> allParams) {
        MediaType contentType = MediaType.valueOf(file.getContentType());
        
        if (contentType.equals(MediaType.valueOf("application/vnd.openxmlformats-officedocument.wordprocessingml.document"))) {
            XWPFDocument doc = new XWPFDocument(OPCPackage.open(file.getInputStream()));
            ByteArrayOutputStream os = new ByteArrayOutputStream();

            //обработка шаблонов в документе doc
          
           doc.write(os);
           doc.close();
           Resource fileResource = new InputStreamResource(new ByteArrayInputStream(os.toByteArray()));
           os.close();
            return ResponseEntity.ok()
                    .contentType(contentType)
                    .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=\"" + file.getOriginalFilename() + "\"")
                    .body(fileResource);            
        } else
            throw new RuntimeException("Неподдерживаемый формат файла. Только docx-файлы!");
    }

Рекурсивная обработка всех элементов IBodyElement которые только содержит документ

Интерфейс для рекурсивной обработки.

    /**
     * Интерфейс - обработчика
     */
    public static interface ProcessInterface {
        /**
         * Вызывается при рекурсивной обработки каждого элемента IBodyElement. Если вернёт истину, значит цели достигнуты и дальнейшая рекурсия прекращается
         */
        public boolean process(IBodyElement iBodyElement);
    }

И сами методы для рекурсивной обработки всех IBodyElement которые находятся внутри некоторого кастомного IBodyElement-а или же вообще всех внутри документа

    /**
     * Бежим по корневому элементу iBodyElement и для каждого входящего в него элемента рекурсивно вызываем метод processInterface.process
     * Вызвать как
     * for (IBodyElement iBodyElement : doc.getBodyElements())
     * if (processDoc(iBodyElement, (ib) -> {...})) break;
     *
     * @param iBodyElement     - элемент по потомкам которого рекурсивно бежим
     * @param processInterface - интерфейс для обратного вызова метода
     * @return - истина, если дальше бежать уже не надо (переданная функция сделала своё дело) и ложь, если не сделала
     */
    public static boolean process(IBodyElement iBodyElement, ProcessInterface processInterface) {
        if (BodyElementType.TABLE.equals(iBodyElement.getElementType())) {
            for (XWPFTableRow row : ((XWPFTable) iBodyElement).getRows()) {
                for (XWPFTableCell cell : row.getTableCells()) {
                    for (IBodyElement ibe : cell.getBodyElements()) {
                        if (processInterface.process(ibe))
                            return true;
                        else if (process(ibe, processInterface))
                            return true;
                    }
                }
            }
            return false;
        } else if (BodyElementType.PARAGRAPH.equals(iBodyElement.getElementType())) {
            return processInterface.process(iBodyElement);
        }

        return false;
    }
    /**
     * Бежим по всему документу и для каждого входящего в него элемента IBodyElement рекурсивно вызываем метод processInterface.process
     * При этом вложенные IBodyElement-ы не обрабатываются!
     * @param doc              - документ
     * @param processInterface - интерфейс для обратного вызова метода
     * @return - истина, если дальше бежать уже не надо (переданная функция сделала своё дело) и ложь, если не сделала
     */
    public static boolean process(XWPFDocument doc, ProcessInterface processInterface) {
        for (IBodyElement iBodyElement : doc.getBodyElements())
            if (processInterface.process(iBodyElement))
                return true;
        return false;
    }

Примеры использования указанных методов ниже

Пример замены одного текста на другой

В примере использован доработанный код взятый из
https://stackoverflow.com/questions/71347456/update-content-of-references-to-text-mark-in-docx
Также можете посмотреть в сторону https://www.baeldung.com/java-replace-pattern-word-document-doc-docx

    /**
     * По всему документу заменить старый текст на новый
     *
     * @param doc     - документ
     * @param oldText - старый текст
     * @param newText - новый текст
     */
    public static void replaceText(XWPFDocument doc, String oldText, String newText) {
        process(doc, (iBodyElement) -> {
            return process(iBodyElement, (ib) -> {
                if (ib.getElementType().equals(BodyElementType.PARAGRAPH)) {
                    XWPFParagraph p = (XWPFParagraph) ib;
                    replaceTextSegment(p, oldText, newText);
                }

                return false;
            });
        });
    }

    /**
     * По всему вложенному содержимому iBodyElement заменить старый текст на новый
     *
     * @param iBodyElement - элемент внутри которого заменяем старый текст на новый
     * @param oldText      - старый текст
     * @param newText      - новый текст
     */
    public static void replaceText(IBodyElement iBodyElement, String oldText, String newText) {
        process(iBodyElement, (ib) -> {
            if (ib.getElementType().equals(BodyElementType.PARAGRAPH)) {
                XWPFParagraph p = (XWPFParagraph) ib;
                replaceTextSegment(p, oldText, newText);
            }
            return false;
        });
    }


    /**
     * Замена текста в пределах параграфа. Использовать как
     * if (paragraph.getText().contains(textToFind)) { // paragraph contains text to find
     * replaceTextSegment(paragraph, textToFind, replacement);
     * }
     */
    static public void replaceTextSegment(XWPFParagraph paragraph, String textToFind, String replacement) {
        TextSegment foundTextSegment = null;
        PositionInParagraph startPos = new PositionInParagraph(0, 0, 0);
        while ((foundTextSegment = searchTextExt(paragraph, textToFind, startPos)) != null) { // search all text segments having text to find

            // maybe there is text before textToFind in begin run
            XWPFRun beginRun = paragraph.getRuns().get(foundTextSegment.getBeginRun());
            String textInBeginRun = beginRun.getText(foundTextSegment.getBeginText());
            String textBefore = textInBeginRun.substring(0, foundTextSegment.getBeginChar()); // we only need the text before

            // maybe there is text after textToFind in end run
            XWPFRun endRun = paragraph.getRuns().get(foundTextSegment.getEndRun());
            String textInEndRun = endRun.getText(foundTextSegment.getEndText());
            String textAfter = textInEndRun.substring(foundTextSegment.getEndChar() + 1); // we only need the text after

            if (foundTextSegment.getEndRun() == foundTextSegment.getBeginRun()) {
                textInBeginRun = textBefore + replacement + textAfter; // if we have only one run, we need the text before, then the replacement, then the text after in that run
            } else {
                textInBeginRun = textBefore + replacement; // else we need the text before followed by the replacement in begin run
                endRun.setText(textAfter, foundTextSegment.getEndText()); // and the text after in end run
            }

            beginRun.setText(textInBeginRun, foundTextSegment.getBeginText());

            // runs between begin run and end run needs to be removed
            for (int runBetween = foundTextSegment.getEndRun() - 1; runBetween > foundTextSegment.getBeginRun(); runBetween--) {
                paragraph.removeRun(runBetween); // remove not needed runs
            }

        }
    }

    /**
     * this methods parse the paragraph and search for the string searched.
     * If it finds the string, it will return true and the position of the String
     * will be saved in the parameter startPos.
     * <p>
     * while((foundTextSegment = searchText(paragraph, textToFind, startPos)) != null)
     *
     * @param searched
     * @param startPos
     */
    public static TextSegment searchTextExt(XWPFParagraph paragraph, String searched, PositionInParagraph startPos) {
        int startRun = startPos.getRun(),
                startText = startPos.getText(),
                startChar = startPos.getChar();
        int beginRunPos = 0, candCharPos = 0;
        boolean newList = false;

        //CTR[] rArray = paragraph.getRArray(); //This does not contain all runs. It lacks hyperlink runs for ex.
        java.util.List<XWPFRun> runs = paragraph.getRuns();

        int beginTextPos = 0, beginCharPos = 0; //must be outside the for loop

        for (int runPos = startRun; runPos < runs.size(); runPos++) {
            int textPos = 0, charPos;
            CTR ctRun = runs.get(runPos).getCTR();
            XmlCursor c = ctRun.newCursor();
            c.selectPath("./*");
            try {
                while (c.toNextSelection()) {
                    XmlObject o = c.getObject();
                    if (o instanceof CTText) {
                        if (textPos >= startText) {
                            String candidate = ((CTText) o).getStringValue();
                            if (runPos == startRun) {
                                charPos = startChar;
                            } else {
                                charPos = 0;
                            }

                            for (; charPos < (candidate==null ? 0 : candidate.length()); charPos++) {
                                if ((candidate.charAt(charPos) == searched.charAt(0)) && (candCharPos == 0)) {
                                    beginTextPos = textPos;
                                    beginCharPos = charPos;
                                    beginRunPos = runPos;
                                    newList = true;
                                }
                                if (candidate.charAt(charPos) == searched.charAt(candCharPos)) {
                                    if (candCharPos + 1 < searched.length()) {
                                        candCharPos++;
                                    } else if (newList) {
                                        TextSegment segment = new TextSegment();
                                        segment.setBeginRun(beginRunPos);
                                        segment.setBeginText(beginTextPos);
                                        segment.setBeginChar(beginCharPos);
                                        segment.setEndRun(runPos);
                                        segment.setEndText(textPos);
                                        segment.setEndChar(charPos);
                                        return segment;
                                    }
                                } else {
                                    candCharPos = 0;
                                }
                            }
                        }
                        textPos++;
                    } else if (o instanceof CTProofErr) {
                        c.removeXml();
                    } else if (o instanceof CTRPr) {
                        //do nothing
                    } else {
                        candCharPos = 0;
                    }
                }
            } finally {
                c.dispose();
            }
        }
        return null;
    }

Получить текст из элементов или всего документа
    /**
     * Получить весь текст документа
     */
    public static String getText(XWPFDocument doc) throws IOException {
        XWPFWordExtractor ex = new XWPFWordExtractor(doc);
        return ex.getText();//xdoc.getDocument().toString();
    }

    /**
     * Получить весь текст из набора элементов (включая вложенные)
     */
    public static String getText(List<IBodyElement> iBodyElements){
        StringBuilder sb = new StringBuilder();
        iBodyElements.forEach(iBodyElement -> process(iBodyElement, ib -> {
            if (ib.getElementType().equals(BodyElementType.PARAGRAPH)) {
                XWPFParagraph p = (XWPFParagraph) ib;
                sb.append(p.getText());
            }
            return false;
        }));
        return sb.toString();
    }


    /**
     * Получить из текста список патернов обрамлённых открывающимся и закрывающимся тэгами. Считается что вложенности нет
     *
     * @param text     - текст в котором ищем вхождение патернов
     * @param begin    - отркывающий тэг
     * @param end      - закрывающий тэг
     * @param withTags - если истина, то вернёт патерны всместе с тэгами
     * @return - список найденных патернов
     */
    public static List<String> getAllTextsBetweenTags(String text, String begin, String end, Boolean withTags) {
        List<String> list = new ArrayList<>();
        int ibegin = 0;
        int iend = 0;
        while (true) {
            ibegin = text.indexOf(begin, iend);
            iend = text.indexOf(end, ibegin + 1);
            if (ibegin == -1 || iend == -1) break;
            list.add((withTags ? begin : "") + text.substring(ibegin + begin.length(), iend) + (withTags ? end : ""));
        }
        return list;
    }

Клонирование элементов
   /**
     * Копировать один iBodyElement и вставить его в body на позицию курсора (важно, курсор должен указывать на body, иначе получим искл)
     * Если курсор нулл или боду нулл, то новый элемент будет вставлен сразу после текущего на его body
     *
     * @param body         - место куда вставляем новый элемент (контейнер под него)
     * @param cursor       - курсор для вставки
     * @param iBodyElement - клонируемый элементы
     * @return - копию склонированного элемента
     */
    public static IBodyElement cloneIbodyElement(IBody body, IBodyElement iBodyElement, XmlCursor cursor) {
        IBodyElement iBodyElementNew = null;
        if (iBodyElement.getElementType().equals(BodyElementType.PARAGRAPH)) {
            if (cursor == null || body == null) {
                if (body == null)
                    body = iBodyElement.getBody();
                if (cursor == null) {
                    cursor = ((XWPFParagraph) iBodyElement).getCTP().newCursor();
                    cursor.toNextSibling();
                }
            }
            iBodyElementNew = body.insertNewParagraph(cursor);
            UtilXWPFDocument.cloneParagraph((XWPFParagraph) iBodyElementNew, (XWPFParagraph) iBodyElement);
        }
        if (iBodyElement.getElementType().equals(BodyElementType.TABLE)) {
            if (cursor == null || body == null) {
                body = iBodyElement.getBody();
                cursor = ((XWPFTable) iBodyElement).getCTTbl().newCursor();
            }
            iBodyElementNew = body.insertNewTbl(cursor);
            ((XWPFTable) iBodyElementNew).getCTTbl().set(((XWPFTable) iBodyElement).getCTTbl());
            UtilXWPFDocument.copyTable((XWPFTable) iBodyElement, (XWPFTable) iBodyElementNew);
        }
        cursor.toNextToken();
        return iBodyElementNew;
    }

    /**
     * Скопировать набор IBodyElement в позицию определяемую cursor или, при его отсутвии, последним из элементов в списке копирования
     *
     * @param cursor - курсор для вставки копируемых элементов. Если пуст, то вставка производится после последнего элемента в списке
     * @param copis  - список копируемых элементов
     * @return - список скопированных элементов
     */
    public static List<IBodyElement> cloneIbodyElements(IBody body, XmlCursor cursor, List<IBodyElement> copis) {
        if (cursor == null || body == null) {
            IBodyElement lastElement = copis.get(copis.size() - 1);
            if (body == null)
                body = lastElement.getBody();
            if (cursor == null) {
                cursor = lastElement.getElementType().equals(BodyElementType.PARAGRAPH) ? ((XWPFParagraph) lastElement).getCTP().newCursor() : ((XWPFTable) lastElement).getCTTbl().newCursor();
                cursor.toNextSibling();
            }
        }
        List<IBodyElement> resp = new ArrayList<>();
        for (IBodyElement ib : copis) {
            //создадим новый элемент и скопируем туда текст из сохранённого
            IBodyElement new_ib = UtilXWPFDocument.cloneIbodyElement(body, ib, cursor);
            resp.add(new_ib);
        }
        return resp;
    }


    /**
     * Клонирует параграф в новый, пустой, существующий параграф.
     * Новый параграф должен быть уже создан
     * }
     */
    public static void cloneParagraph(XWPFParagraph clone, XWPFParagraph source) {//https://stackoverflow.com/questions/23112924/make-an-exact-copy-of-a-paragraph-including-all-contents-and-properties
        CTPPr pPr = clone.getCTP().isSetPPr() ? clone.getCTP().getPPr() : clone.getCTP().addNewPPr();
        pPr.set(source.getCTP().getPPr());
        for (XWPFRun r : source.getRuns()) {
            XWPFRun nr = clone.createRun();
            cloneRun(nr, r);
        }
    }

    public static void cloneRun(XWPFRun clone, XWPFRun source) {
        CTRPr rPr = clone.getCTR().isSetRPr() ? clone.getCTR().getRPr() : clone.getCTR().addNewRPr();
        rPr.set(source.getCTR().getRPr());
        clone.setText(source.getText(0));
    }

    /** Клонирует таблицу в новую, пустую, существующую таблицу
     * https://stackoverflow.com/questions/48322534/apache-poi-how-to-copy-tables-from-one-docx-to-another-docx
     * 
     * XWPFTable newTbl = output_doc.insertNewTbl(cursor);
     * copyTable(table, newTbl);
     */
    public static void copyTable(XWPFTable source, XWPFTable target) {
        target.getCTTbl().setTblPr(source.getCTTbl().getTblPr());
        target.getCTTbl().setTblGrid(source.getCTTbl().getTblGrid());

        //newly created table has one row by default. we need to remove the default row.
        target.removeRow(0);

        for (int r = 0; r < source.getRows().size(); r++) {
            XWPFTableRow targetRow = target.createRow();
            XWPFTableRow row = source.getRows().get(r);
            targetRow.getCtRow().setTrPr(row.getCtRow().getTrPr());
            for (int c = 0; c < row.getTableCells().size(); c++) {
                //newly created row has 1 cell
                XWPFTableCell targetCell = targetRow.createCell();
                XWPFTableCell cell = row.getTableCells().get(c);
                targetCell.getCTTc().setTcPr(cell.getCTTc().getTcPr());
                XmlCursor cursor = targetCell.getParagraphArray(0).getCTP().newCursor();
                for (int p = 0; p < cell.getBodyElements().size(); p++) {
                    IBodyElement elem = cell.getBodyElements().get(p);
                    if (elem instanceof XWPFParagraph) {
                        XWPFParagraph targetPar = targetCell.insertNewParagraph(cursor);
                        cursor.toNextToken();
                        XWPFParagraph par = (XWPFParagraph) elem;
                        //copyParagraph(par, targetPar);
                        cloneParagraph(targetPar, par);
                    } else if (elem instanceof XWPFTable) {
                        XWPFTable targetTable = targetCell.insertNewTbl(cursor);
                        XWPFTable table = (XWPFTable) elem;
                        copyTable(table, targetTable);
                        cursor.toNextToken();
                    }
                }
                //newly created cell has one default paragraph we need to remove
                targetCell.removeParagraph(targetCell.getParagraphs().size() - 1);
            }
        }
    }

Удаление элементов
    /**
     * Очистить параграф не удаляя его
     */
    public static void clearParagraph(final XWPFParagraph p) {
        //p.getCTP().getRList().clear();

        for (XWPFRun r : p.getRuns())
            r.setText("", 0);
    }


    /**
     * Очистить таблицу не удаляя его. То есть очистить все парагрфы внутри данной таблицы с любым уровнем вложенности
     */
    public static void clearTable(final XWPFTable table) {
       process(table,iBodyElement->{
           if (iBodyElement.getElementType().equals(BodyElementType.PARAGRAPH)) {
               XWPFParagraph p = (XWPFParagraph) iBodyElement;
               clearParagraph(p);
           }
           return false;
       });
    }

    /**
     * Очистить боди-элемент от текста
     */
    public static void clearElement(final IBodyElement iBodyElement) {
        if (iBodyElement.getElementType().equals(BodyElementType.PARAGRAPH)) clearParagraph((XWPFParagraph) iBodyElement);
        if (iBodyElement.getElementType().equals(BodyElementType.TABLE)) clearTable((XWPFTable) iBodyElement);
    }

    /**
     * Удаление заданного IBodyElement (таблицы или параграфа)
     */
    public static void removeBodyElement(IBodyElement iBodyElement) {
        IBody body = iBodyElement.getBody();
        if (body instanceof XWPFDocument) {
            final XWPFDocument doreplacedent = (XWPFDocument) body;
            final int index = doreplacedent.getBodyElements().indexOf(iBodyElement);
            if (index != -1) {
                doreplacedent.removeBodyElement(index);
            }
        } else if (body instanceof XWPFHeaderFooter) {
            final XWPFHeaderFooter headerFooter = (XWPFHeaderFooter) body;
            if (iBodyElement.getElementType().equals(BodyElementType.PARAGRAPH))
                headerFooter.removeParagraph((XWPFParagraph) iBodyElement);
            if (iBodyElement.getElementType().equals(BodyElementType.TABLE))
                headerFooter.removeTable((XWPFTable) iBodyElement);
        } else if (body instanceof XWPFTableCell) {
            final XWPFTableCell cell = (XWPFTableCell) body;
            if (iBodyElement.getElementType().equals(BodyElementType.PARAGRAPH)) {
                final int index = cell.getParagraphs().indexOf(iBodyElement);
                if (index != -1)
                    cell.removeParagraph(index);
            }
            if (iBodyElement.getElementType().equals(BodyElementType.TABLE)) {
                final int index = cell.getTables().indexOf(iBodyElement);
                if (index != -1)
                    cell.removeTable(index);
            }
        } else {
            throw new IllegalStateException("can't delete");
        }
    }


    /**
     * Удалить с body принадлежащие ему bodyElements начиная с ibegin и заканчивая iend
     */
    public static void deleteBodyElementsFromBody(IBody body, int ibegin, int iend) {
        List<IBodyElement> dels = Lists.newArrayList(body.getBodyElements().subList(ibegin, iend + 1).iterator());
        //если мы удаляем всё, что есть в теле, то это особый случай, иначе документ будет повреждён (видимо потому, что мы взяли курсор с последнего параграфа)
        if (body.getBodyElements().equals(dels)) {
            UtilXWPFDocument.clearParagraph((XWPFParagraph) dels.get(dels.size() - 1));//очищаем текст параграфа без его удаления
            dels = Lists.newArrayList(dels.subList(0, dels.size() - 1).iterator()); //и не удаляем этот параграф
        }

        for (IBodyElement ib : dels)
            UtilXWPFDocument.removeBodyElement(ib);
    }

Обработка текста для всего документа и пример её использования для удаления со всего документа всего текста начиная с некого открывающего тэга и заканчивая закрывающим
    /**
     * Удалить из документа весь текст начиная с открывающего тэга и кончая закрывающим тэгом включитлеьно. Отк и закр тэги должны располагаться на одном и том же уровне вложенности
     *
     * @param doc    - документ
     * @param sbegin - открывающий тэг
     * @param send   - закрыывающий тэг
     */
    public static void deleteText(XWPFDocument doc, String sbegin, String send) {
        class TemplateWork extends TemplateWorkAbstract {

            public TemplateWork(String sbegin, String send) {
                super(sbegin, send);
            }

            @Override
            public void processTemplate() {
                deleteBodyElementsFromBody(body, ibegin, iend);
            }
        }

        TemplateWorkAbstract templateWork = new TemplateWork(sbegin, send);
        findAndProcessTemplate(doc, templateWork);
    }

    /**
     * Класс для обработки шаблонов в методе  findAndProcessTemplate
     */
    @FieldDefaults(level = AccessLevel.PUBLIC)
    public static abstract class TemplateWorkAbstract {
        IBody body;
        String sbegin;//отркывающий тэг для шаблона
        String send;//закрывающий тэг для шаблона
        int ibegin = -1;//позиция параграфа где был найден открывающий тэг шаблона внутри body.getBodyElements.get(?)
        int iend = -1;//поцизийия параграфа где был найден закрывающий тэг шаблона

        public TemplateWorkAbstract(String sbegin, String send) {
            this.sbegin = sbegin;
            this.send = send;
        }

        public void reset() {//сбросить
            body = null;
            ibegin = -1;
            iend = -1;
        }

        public void findBegin(XWPFParagraph p, int i) {//событие нахождения начала шаблона, где p - параграф в котором был найден открывающий тэг
            ibegin = i;
        }

        public void findEnd(XWPFParagraph p, int i) {//событие нахождения конца шаблона, где p - параграф в котором был найден закрывающий тэг
            iend = i;
        }

        public abstract void processTemplate();//обработка шаблона
    }

    /**
     * Найти в документе все шаблоны начинающийся на тэг templateWork.sbegin и заканчивающийся на тэг templateWork.send и обработать их, последовательно для каждого вызывая templateWork.processTemplate()
     *
     * @param doc          - документ
     * @param templateWork - объект класса наследуемого от TemplateWorkAbstract который содержит инф для обработки событий нахождения начала шаблона, нахождения конца шаблона и обработки самого шаблона
     */
    public static void findAndProcessTemplate(XWPFDocument doc, TemplateWorkAbstract templateWork) {
        templateWork.reset();
        while (process(doc, (iBodyElement) -> {
            return process(iBodyElement, (ib) -> {
                return templateFind(templateWork, ib);
            });
        })) {
            templateProcess(templateWork);
        }
    }

    /**
     * Выполнить обработку шаблона находящегося в templateWork. Сначала требуется выполнить метод {@see templateFind}
     */
    private static void templateProcess(TemplateWorkAbstract templateWork) {
        templateWork.processTemplate();
        templateWork.reset();
    }

    /**
     * Найти вхождение шаблона описываемого templateWork внутри заданного IBodyElement (вернёт данные внутри templateWork)
     */
    private static boolean templateFind(TemplateWorkAbstract templateWork, IBodyElement ib) {
        if (ib.getElementType().equals(BodyElementType.PARAGRAPH)) {
            XWPFParagraph p = (XWPFParagraph) ib;
            if (p.getText().contains(templateWork.sbegin)) {
                templateWork.body = p.getBody();
                int i = 0;
                for (IBodyElement ww : p.getBody().getBodyElements()) {
                    if (ww.getElementType().equals(BodyElementType.PARAGRAPH)) {
                        XWPFParagraph ee = (XWPFParagraph) ww;
                        if (ee.getText().contains(templateWork.sbegin))
                            templateWork.findBegin(ee, i);
                        else if (ee.getText().contains(templateWork.send)) {
                            templateWork.findEnd(ee, i);
                            break;
                        }
                    }
                    i++;
                }
                if (templateWork.iend == -1)
                    throw new RuntimeException("Не найдено окончание шаблона для: " + templateWork.sbegin);
                else {
                    return true;
                }
            }
        }
        return false;
    }

Практические примеры использования приведённых выше функций
//По всему документу заменить вхождение одного текста на другой
UtilXWPFDocument.replaceText(doc, "старый текст", "новый текст");

//Выбрать все шаблоны вида <dataset>некий многострочный шаблон</dataset> из документа
List<String> stringDatasets = UtilXWPFDocument.getAllTextsBetweenTags(UtilXWPFDocument.getText(doc), "<dataset>", "</dataset>", true);

//И затем удалить эти шаблоны из документа
UtilXWPFDocument.deleteText(doc, "<dataset>", "</dataset>");

//Пример рекурсивной обработки списка iBodyElement-ов (и всех вложенных в них элементов) полученных, например как doc.getBodyElements()
       iBodyElements.forEach(iBodyElement -> {
            UtilXWPFDocument.process(iBodyElement, (ib) -> {
              //некий код возвращающий обработки каждого ib-элемента
              return false;
            });
        });

//Пример рекурсивной обработки каждого ib-элемента в документе.
//Обработка прекращается когда функция обработки вернёт истину
boolean isProcessing=process(doc, (iBodyElement) -> {
            return process(iBodyElement, (ib) -> {
                return некоторая-функция-обработки(ib);
            });
        });