Word Templates & Placeholder Replacement
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production. The reference material consulted while preparing these pages is listed in the section bibliography. |
Baseline date: 2026-10-09. This page was written against Apache POI 5.5.1 (the latest release on that date; 6.0 is announced but has not been released, so everything about 6.0 below is expected, not shipped), Java 21 and later, Spring Boot 4.x on Spring Framework 7 and Jakarta EE 11. The classes in this page were compiled and their tests run against POI 5.5.1; the outputs marked as observed come from those runs, not from the documentation.
Most Word documents that a server produces are not built paragraph by paragraph. A person writes the letter once, in
Word, with markers such as ${borrowerName} where the data goes, and the code only fills the markers. That is the same
division of labour as in Templates & Modifying
Workbooks for Excel, with one difference that makes Word harder: Word does not keep a marker in one piece. This page
explains why, shows the algorithm that copes with it, and builds a complete filler for the Bookshelf loan-letter.docx
of the section landing page: placeholders in the body, in a table that grows one row
per loan, in the header and in the footer. It builds on
XWPF Basics (the object model), on
Word Tables, Lists & Images (tables and pictures) and on
Word Headers, Footers, Sections & Fields
(headers and footers). The charts and embedded objects that a template may also carry are in
Word Charts & Embedded Objects.
What POI offers for Word templates, and what it does not
Apache POI has no template API and no mail-merge API for XWPF. There is no fill(template, model) call, no loop
construct, no conditional section and no MERGEFIELD processor. The XWPF documentation describes the object model of a
document (paragraphs, runs, tables, pictures, headers) and nothing about templating; the official project page for
related projects points to external projects for that purpose. What POI gives you are the primitives that a hand-written
filler is made of:
| Need | What POI provides | What you write |
|---|---|---|
Find text |
|
The match across runs, the replacement and the traversal of every part of the document |
Walk the document |
|
One recursive visitor so that no part is forgotten |
Change text |
|
The rule for which run keeps the text and which are emptied |
Repeat a block |
|
Cloning a prototype row and filling it before it is inserted |
Insert a picture |
|
Finding the marker paragraph and clearing its runs |
Content controls |
|
Writing into a control through the low-level |
Everything else in a template engine (a model with nested objects, if and for, formatting expressions, escaping) is
yours to build or to borrow from a library built on POI. Two such libraries are named by POI itself, poi-tl and
XDocReport; the last section of this page describes where they fit, in prose only. For most reports, a flat map of
fields, one repeating table and one or two pictures are enough, and the code in this page covers exactly that in about
two hundred lines.
The template contract
As with a spreadsheet, a template is a source file edited by someone who does not run the tests. Agree on the contract before writing any code:
| Rule | Why |
|---|---|
One syntax: |
A single regular expression finds every marker. Do not use characters that Word treats specially in autocorrect. |
A marker is written in one go, with no formatting change inside it |
It reduces, but never removes, the number of runs the marker is split into. Do not rely on it. |
Rows to repeat contain markers with a prefix, for example |
The code finds the prototype row by the prefix and keeps single-value fields and per-row fields apart. |
A picture is a paragraph that contains only the marker, such as |
It is replaced as a whole, so no text survives around the picture. |
The template lives in the repository, next to a test that opens it |
A missing or misspelled marker is found by the build, not by a customer. |
The Bookshelf loan-letter-template.docx used below has this content. The header says
Bookshelf Library - ${libraryName}, the body starts with Dear ${borrowerName},, announces that the books are due
on ${dueDate} and contains a two-column table whose second row holds ${line.title} and ${line.due}, and the
footer says Questions: ${contact}.
Why a placeholder is split across runs
A paragraph (w:p) in a .docx is a list of runs (w:r), and each run is a stretch of text with one set of properties
(w:rPr). The text of a run sits in w:t elements. Word starts a new run whenever the formatting changes, but also for
reasons you cannot see:
-
the spell checker or grammar checker marks part of the text (
w:proofErrelements split the runs around a word it did not know, such asborrowerName); -
the author typed the marker in several editing sessions, and Word records revision session identifiers (the
rsidattributes) that differ between sessions, which keeps the runs apart; -
part of the marker has a different language tag, a different font fallback or a different bold, italic or colour;
-
autocorrect or a copied fragment inserted a piece with its own properties;
-
a field, a bookmark, a hyperlink or a content control starts or ends inside the marker.
A marker the author saw as one word can therefore reach the code like this (the XML is what Word wrote, shortened):
<w:p>
<w:r><w:t xml:space="preserve">Dear </w:t></w:r>
<w:r><w:rPr><w:b/></w:rPr><w:t>${bor</w:t></w:r>
<w:proofErr w:type="spellStart"/>
<w:r><w:rPr><w:i/></w:rPr><w:t>rowerName</w:t></w:r>
<w:proofErr w:type="spellEnd"/>
<w:r><w:t>}, your loan is due.</w:t></w:r>
</w:p>
Source: POI XWPF Quick Guide and the
ECMA-376 definition of runs and
w:proofErr. Which runs Word creates is its own business and changes between versions; the example is representative,
not a rule.
The consequence is simple: you cannot look for a marker run by run. The next example builds a paragraph with the split above through the POI API (the same structure Word writes), saves it, reopens it and lists the runs. Building the fixture in code, instead of shipping a binary, is also how the tests at the end of the page work.
package com.bookshelf.office.word;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
/** Builds a tiny template whose placeholders are split across runs, the way Word saves them. */
public final class SplitRunsDemo {
private SplitRunsDemo() {
}
public static void main(String[] args) throws IOException {
Path out = Path.of(args.length > 0 ? args[0] : "loan-letter-split.docx");
try (XWPFDocument doc = new XWPFDocument()) {
XWPFParagraph p = doc.createParagraph();
XWPFRun a = p.createRun();
a.setBold(true);
a.setText("Dear ");
XWPFRun b = p.createRun();
b.setBold(true);
b.setColor("1F4E79");
b.setText("${bor");
XWPFRun c = p.createRun();
c.setItalic(true);
c.setText("rowerName}");
XWPFRun d = p.createRun();
d.setText(", your loan of ${title} is due on ${dueDate}.");
try (var os = Files.newOutputStream(out)) {
doc.write(os);
}
}
try (XWPFDocument doc = new XWPFDocument(Files.newInputStream(out))) {
XWPFParagraph p = doc.getParagraphs().get(0);
for (int i = 0; i < p.getRuns().size(); i++) {
System.out.printf("run %d: [%s]%n", i, p.getRuns().get(i).getText(0));
}
System.out.println("paragraph.getText(): " + p.getText());
}
}
}
Observed output with POI 5.5.1:
run 0: [Dear ]
run 1: [${bor]
run 2: [rowerName}]
run 3: [, your loan of ${title} is due on ${dueDate}.]
paragraph.getText(): Dear ${borrowerName}, your loan of ${title} is due on ${dueDate}.
Source: XWPFParagraph Javadoc and XWPFRun Javadoc.
Note the difference between the two views. XWPFParagraph.getText() joins the text of the runs and shows the marker in
one piece, which is why a program that only reads the document never notices the problem. The runs, which are what you
must change, do not contain it. Run 3 is the lucky case: ${title} and ${dueDate} are inside a single run and a
naive replacement would work for them.
The naive replacement and how it fails
The version found in most tutorials loops over the runs and calls String.replace on each of them:
package com.bookshelf.office.word;
import java.util.Map;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
/** The version found in many tutorials: it only works when Word keeps the placeholder in one run. */
public final class NaiveReplace {
private NaiveReplace() {
}
public static void replace(XWPFDocument doc, Map<String, String> values) {
for (XWPFParagraph paragraph : doc.getParagraphs()) {
for (XWPFRun run : paragraph.getRuns()) {
String text = run.getText(0);
if (text == null) {
continue;
}
for (Map.Entry<String, String> e : values.entrySet()) {
text = text.replace("${" + e.getKey() + "}", e.getValue());
}
run.setText(text, 0);
}
}
}
}
Run on the document of the previous example, with the borrower and the due date supplied, it leaves
Dear ${borrowerName}, untouched, while it replaces the markers that happen to be inside one run (observed with
POI 5.5.1: for the template of the repeating-table example below, ${dueDate} split as $, {dueD, ate} is
not replaced either). There is no exception and no warning. The output document is wrong and looks plausible. Three
symptoms identify the problem in a bug report: the same template works after the designer retypes the marker, it works
for some fields and not for others, and a marker is replaced in one environment and not in another because the template
was edited in a different Word version.
Source: XWPFRun.getText(int) and the analysis above; the method is deliberately not recommended.
Two other approaches are tempting and wrong. Setting the paragraph text by removing every run and creating one run with the replaced text destroys all the formatting inside the paragraph (bold words, a colored name, a footnote reference). Asking the designer to retype every marker "without formatting" fails the first time somebody edits the template.
searchText and TextSegment
POI does contain a helper for text that spans runs: XWPFParagraph.searchText(String, PositionInParagraph). It finds
the first occurrence of a string at or after the given position and returns a TextSegment that tells where the match
begins and ends, in runs and in characters:
package com.bookshelf.office.word;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.poi.xwpf.usermodel.PositionInParagraph;
import org.apache.poi.xwpf.usermodel.TextSegment;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
/** Prints where searchText finds a placeholder that spans several runs. */
public final class SearchTextDemo {
private SearchTextDemo() {
}
public static void main(String[] args) throws IOException {
try (XWPFDocument doc = new XWPFDocument(Files.newInputStream(Path.of(args[0])))) {
XWPFParagraph p = doc.getParagraphs().get(0);
TextSegment segment = p.searchText("${borrowerName}", new PositionInParagraph());
if (segment == null) {
System.out.println("not found");
return;
}
System.out.printf("runs %d..%d, begin char %d, end char %d%n", segment.getBeginRun(), segment.getEndRun(),
segment.getBeginChar(), segment.getEndChar());
System.out.println("text: " + p.getText(segment));
}
}
}
Observed output for the paragraph of the previous example:
runs 1..2, begin char 0, end char 9
text: ${borrowerName}
getBeginRun() and getEndRun() are run indexes, getBeginChar() is the index of the first character in the first
run and getEndChar() is the index of the last character in the last run (the run rowerName} has ten characters, so
the end is 9, not 10). XWPFParagraph.getText(TextSegment) returns the matched text.
searchText is useful to locate a marker, but it does not replace anything, and building the replacement on it has
three drawbacks:
-
it searches for a literal string, so a pattern such as "any `${...}`" needs one search per known name;
-
it counts positions in the text of the runs as it sees them, including the text of runs inside hyperlinks and fields, which your replacement code must count the same way or the offsets drift;
-
after a replacement the run list changes, so a loop has to restart the search, and every restart repeats the work.
The algorithm in the next section keeps its own offsets over exactly the text it modifies, finds all markers with one regular expression, and treats the paragraph the same way before and after a replacement.
A robust replace-across-runs algorithm
The idea has four steps, repeated for each marker in a paragraph.
-
Concatenate. Build one string from the text of all the runs, and remember the offset at which each run starts.
-
Find. Match the marker pattern in the concatenated string. The match is a range of offsets, no longer a run problem.
-
Merge into the first run. The run that holds the first character of the match (the one with the
$) receivesprefix + value + suffix, whereprefixis what it held before the marker andsuffixis what the last run of the match held after the marker. Because only text is written, the properties of that run (w:rPr) are untouched: the replacement is formatted like the opening of the marker. -
Empty the others. The runs between the first and the last, and the tail of the last, have their text removed. They are kept in the paragraph, empty, so that bookmarks, fields and run indexes elsewhere in the document stay valid.
XWPFDocument from bytes"] --> B["Visit every body
document, tables, headers, footers, footnotes"] B --> C["For each paragraph:
concatenate the text of its runs"] C --> D{"Marker found
by the regular expression?"} D -- no --> N["Next paragraph"] D -- yes --> E{"Value for the name?"} E -- no --> F["Record the name as missing,
keep the marker"] E -- yes --> G["Merge runs:
prefix + value + suffix into the first run"] G --> H["Empty the other runs of the match
(formatting of the first run kept)"] H --> I["Continue after the inserted value"] F --> I I --> C N --> J{"More paragraphs?"} J -- yes --> C J -- no --> K["Check missing names"] K --> L["write(OutputStream)
and close the document"]
The diagram is the whole life of one letter: load, find, merge runs, write. The loop on the right is per paragraph and per marker; the outer loop is the visitor over the parts of the document.
The design decisions, and the reason for each:
| Decision | Reason |
|---|---|
The text of a run is the concatenation of its |
|
Runs are never removed, only emptied |
|
The scan restarts after the inserted value |
A value that contains |
Names with no value are recorded and left in place |
The caller decides whether that is an error. A letter that goes out with a visible marker is worse than an exception. |
The paragraph is re-read after every replacement |
Cheaper in code than patching the offsets of a changing list, and cheap in practice: a paragraph has a handful of runs. |
Line breaks and tabs in the value become |
A raw newline inside |
The implementation:
package com.bookshelf.office.word;
import java.util.ArrayList;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.xwpf.usermodel.IBody;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFFooter;
import org.apache.poi.xwpf.usermodel.XWPFFootnote;
import org.apache.poi.xwpf.usermodel.XWPFHeader;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableCell;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTR;
/**
* Replaces ${name} placeholders in a .docx, even when Word split them across several runs.
* The formatting of the run that holds the opening "$" is kept. Not thread safe: use one per document.
*/
public final class PlaceholderReplacer {
private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{([A-Za-z0-9_.]+)}");
private final Map<String, String> values;
private final Set<String> missing = new LinkedHashSet<>();
private int replaced;
public PlaceholderReplacer(Map<String, String> values) {
this.values = values;
}
/** Replaces in the body, tables (nested too), headers, footers and footnotes. */
public void replaceAll(XWPFDocument doc) {
visit(doc, this::replaceInParagraph);
for (XWPFHeader header : doc.getHeaderList()) {
visit(header, this::replaceInParagraph);
}
for (XWPFFooter footer : doc.getFooterList()) {
visit(footer, this::replaceInParagraph);
}
for (XWPFFootnote footnote : doc.getFootnotes()) {
visit(footnote, this::replaceInParagraph);
}
}
public int replacedCount() {
return replaced;
}
/** Names that appeared in the document but had no value. They were left untouched. */
public Set<String> missingKeys() {
return missing;
}
/** Walks the paragraphs of any body (document, header, footer, footnote, table cell), tables recursively. */
public static void visit(IBody body, Consumer<XWPFParagraph> action) {
for (XWPFParagraph paragraph : new ArrayList<>(body.getParagraphs())) {
action.accept(paragraph);
}
for (XWPFTable table : body.getTables()) {
for (XWPFTableRow row : table.getRows()) {
for (XWPFTableCell cell : row.getTableCells()) {
visit(cell, action);
}
}
}
}
public void replaceInParagraph(XWPFParagraph paragraph) {
int searchFrom = 0;
while (true) {
List<XWPFRun> runs = paragraph.getRuns();
int[] starts = new int[runs.size()];
StringBuilder full = new StringBuilder();
for (int i = 0; i < runs.size(); i++) {
starts[i] = full.length();
full.append(textOf(runs.get(i)));
}
Matcher m = PLACEHOLDER.matcher(full);
if (searchFrom > full.length() || !m.find(searchFrom)) {
return;
}
String key = m.group(1);
String value = values.get(key);
if (value == null) {
missing.add(key);
searchFrom = m.end();
continue;
}
int first = runIndexAt(starts, runs, m.start());
int last = runIndexAt(starts, runs, m.end() - 1);
String firstText = textOf(runs.get(first));
String lastText = textOf(runs.get(last));
String prefix = firstText.substring(0, m.start() - starts[first]);
String suffix = lastText.substring(m.end() - starts[last]);
setText(runs.get(first), prefix + value + (first == last ? suffix : ""));
for (int i = first + 1; i < last; i++) {
setText(runs.get(i), "");
}
if (last != first) {
setText(runs.get(last), suffix);
}
replaced++;
// line breaks and tabs become w:br / w:tab, which textOf() does not count
searchFrom = m.start() + value.replace("\n", "").replace("\t", "").length();
}
}
/** Index of the run that holds the character at the given offset of the concatenated text. */
private static int runIndexAt(int[] starts, List<XWPFRun> runs, int offset) {
for (int i = runs.size() - 1; i >= 0; i--) {
if (starts[i] <= offset && !textOf(runs.get(i)).isEmpty()) {
return i;
}
}
throw new IllegalStateException("No run holds offset " + offset);
}
/** All w:t children of the run, concatenated. Tabs, breaks and pictures contribute nothing. */
static String textOf(XWPFRun run) {
CTR ctr = run.getCTR();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < ctr.sizeOfTArray(); i++) {
sb.append(ctr.getTArray(i).getStringValue());
}
return sb.toString();
}
/**
* Replaces every w:t of the run, formatting is untouched. A newline in the text becomes a w:br and a tab
* becomes a w:tab, because a raw newline inside w:t is shown by Word as a plain space.
*/
static void setText(XWPFRun run, String text) {
CTR ctr = run.getCTR();
for (int i = ctr.sizeOfTArray() - 1; i >= 0; i--) {
ctr.removeT(i);
}
StringBuilder chunk = new StringBuilder();
for (char c : text.replace("\r\n", "\n").toCharArray()) {
if (c == '\n' || c == '\t') {
flush(run, chunk);
if (c == '\n') {
run.addBreak();
} else {
run.addTab();
}
} else {
chunk.append(c);
}
}
flush(run, chunk);
if (ctr.sizeOfTArray() == 0) {
run.setText("", 0);
}
}
private static void flush(XWPFRun run, StringBuilder chunk) {
if (!chunk.isEmpty()) {
run.setText(chunk.toString(), run.getCTR().sizeOfTArray());
chunk.setLength(0);
}
}
}
Source: POI XWPF Quick Guide, IBody Javadoc, XWPFRun Javadoc and XWPFDocument Javadoc. The algorithm itself is not part of the POI documentation: it is this page’s answer to the absence of one.
Points of the code that deserve a second look:
-
visit(IBody, Consumer)is the single traversal.XWPFDocument,XWPFHeader,XWPFFooter,XWPFFootnoteandXWPFTableCellall implementIBody, so the same code covers a table cell, a table inside a cell and a footer.getParagraphs()is copied into a list before the action runs because an action may add paragraphs. -
runIndexAtskips runs with no text. A run that holds only a tab, a break or a picture occupies zero characters in the concatenation and must never be chosen as the first or last run of a match. -
setTextwrites the text withrun.setText(chunk, position)and keepsxml:space="preserve"handling to POI, so leading and trailing spaces of the value survive. -
The first run receives the whole replacement. If the marker started in the middle of the run, the text before it (
prefix) is written back unchanged.
Worked example: what happens to the runs
For the paragraph of the earlier example and the value Ada Lovelace, the runs go through these states:
| Run | Before | After |
|---|---|---|
0 |
`Dear ` (plain) |
`Dear ` (plain, untouched) |
1 |
|
|
2 |
|
empty (the suffix after the marker was empty) |
3 |
|
|
This is the observed result of the repeating-table example below: the borrower name appears in the bold of the run that
held the $, the italic run remains in the paragraph with no text, and the paragraph text reads
Dear Ada Lovelace,.
Which formatting the value gets is a rule you can state to the designer in one sentence: the value is formatted like the first character of the marker. If the whole marker is selected and formatted in Word (the usual way to style a field), every run of it carries the same properties and the result is what the designer expects.
Where the text lives: every part of the document
A document is a package of parts, and the text of a letter is spread over several of them. Forgetting one is the second most common template bug after the split runs.
| Where | POI access | Handled by the code above |
|---|---|---|
Body paragraphs |
|
Yes |
Tables, including nested ones |
|
Yes (recursive) |
Headers, including first-page and even-page headers |
|
Yes |
Footers |
|
Yes |
Footnotes |
|
Yes |
Endnotes |
|
No, add a loop if you use them |
Text boxes and shapes ( |
No usermodel API; low-level |
No |
Content controls ( |
|
No (see below) |
Text of a field result (page number, date) |
Runs with |
Not as a marker; the field is recalculated by Word |
Three consequences are worth stating:
-
A document with a different first-page header has two headers in
getHeaderList(), and the marker may be in only one of them. The visitor handles every entry, so nothing needs to be done. -
A header that is inherited from the previous section is stored once. It is replaced once, which is the expected behaviour.
-
A marker inside a text box is invisible to the usermodel. If the designer uses text boxes for a logo caption or a letterhead, either replace them with a table without borders (which XWPF can reach) or walk the XML of the body with
CT*beans and applyreplaceInParagraphto the paragraphs found there. The second option goes beyond the usermodel and thepoi-ooxml-liteschemas may not contain the beans you need; see Setup & Dependencies forpoi-ooxml-full.
Source: XWPFDocument Javadoc, XWPFHeaderFooter Javadoc and XWPFFootnote Javadoc.
Repeating a table row
A loan letter lists as many books as the borrower has on loan, so the table grows. POI has no "repeat" construct; the standard technique is a prototype row in the template that the code clones once per item and removes at the end.
package com.bookshelf.office.word;
import java.util.List;
import java.util.Map;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTRow;
/** Repeats a prototype row of a table once per item. */
public final class TableRows {
private TableRows() {
}
/** Index of the first row whose text contains the marker, or -1. */
public static int findRow(XWPFTable table, String marker) {
List<XWPFTableRow> rows = table.getRows();
for (int i = 0; i < rows.size(); i++) {
if (rows.get(i).getTableCells().stream().anyMatch(c -> c.getText().contains(marker))) {
return i;
}
}
return -1;
}
/**
* Replaces the prototype row (the one containing the marker) with one copy per item.
* Each copy is filled before it is inserted, because table.addRow stores a copy of the XML.
*/
public static void repeat(XWPFTable table, String marker, List<Map<String, String>> items) {
int prototypeIndex = findRow(table, marker);
if (prototypeIndex < 0) {
throw new IllegalStateException("No table row contains " + marker);
}
CTRow prototype = table.getRow(prototypeIndex).getCtRow();
int position = prototypeIndex + 1;
for (Map<String, String> item : items) {
XWPFTableRow copy = new XWPFTableRow((CTRow) prototype.copy(), table);
PlaceholderReplacer replacer = new PlaceholderReplacer(item);
copy.getTableCells().forEach(cell -> PlaceholderReplacer.visit(cell, replacer::replaceInParagraph));
table.addRow(copy, position++);
}
table.removeRow(prototypeIndex);
}
}
Source: XWPFTable Javadoc and XWPFTableRow Javadoc. The cloning technique is a common one, not an official recipe.
The comment in repeat is the trap of this approach, so here is the evidence. XWPFTable.addRow(row, pos) inserts a
copy of the row’s XML into the table and keeps the XWPFTableRow object you passed in the table’s row list. That
object is therefore detached from the XML that will be written. Observed with POI 5.5.1:
XWPFTableRow copy = new XWPFTableRow((CTRow) table.getRow(1).getCtRow().copy(), table);
table.addRow(copy, 2);
copy.getCell(0).getParagraphArray(0).getRuns().forEach(r -> r.setText("CHANGED", 0));
// in memory: table.getRow(2).getCell(0).getText() -> "CHANGEDCHANGED"
// after write and re-open: the same cell contains -> "${line.title}"
The edit is visible in the object model and lost on disk. Filling the copy before addRow avoids the problem, and
the output of the complete run confirms it (the re-opened document has the two loan rows):
ROW: [Title, Due]
ROW: [Dune, 2026-11-01]
ROW: [Emma, 2026-11-08]
REOPEN rows=3 Dune
For the same reason, every value a row needs must be in the map of its item. The global replaceAll that runs after
TableRows.repeat still visits the added rows, but through the detached objects: observed with POI 5.5.1, a
${dueDate} placed in the prototype row is counted as replaced and is still ${dueDate} in the written file. That
is why LoanLetterService below passes each item merged with the single-value fields. Do not edit the added rows
afterwards; if a later step must change them, write and re-open the document first.
Other details of repeating rows:
-
The cloned row keeps the cell widths, borders, shading and run formatting of the prototype, because the whole
w:tris copied. A header row that repeats on each page (w:tblHeader) is a property of a row; clone only the data row. -
Merged cells and nested tables inside the prototype are copied with it, and the visitor fills their markers.
-
An empty list of items removes the prototype row and leaves a table with only its header. If an empty table is not acceptable, test for it and remove the table or write "no loans" in a paragraph.
-
Cost is linear in the number of rows, but each row is an XML copy held in memory; a table of tens of thousands of rows belongs in a spreadsheet, not in a Word letter.
Inserting an image at a marker
A logo or a book cover is not text, so the replacement is a different operation: the paragraph that contains only the marker is cleared and a run with a picture is added. The marker may itself be split across runs, so the match uses the paragraph text, which is the concatenation of the runs.
package com.bookshelf.office.word;
import java.io.IOException;
import java.io.InputStream;
import org.apache.poi.common.usermodel.PictureType;
import org.apache.poi.util.Units;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.apache.poi.openxml4j.exceptions.InvalidFormatException;
/** Replaces a paragraph that contains only a marker such as ${coverImage} by a picture. */
public final class ImageAtMarker {
private ImageAtMarker() {
}
public static boolean insert(XWPFDocument doc, String marker, InputStream png, int widthPx, int heightPx)
throws IOException, InvalidFormatException {
for (XWPFParagraph paragraph : doc.getParagraphs()) {
if (!paragraph.getText().trim().equals(marker)) {
continue;
}
for (int i = paragraph.getRuns().size() - 1; i >= 0; i--) {
paragraph.removeRun(i);
}
XWPFRun run = paragraph.createRun();
run.addPicture(png, PictureType.PNG, "cover.png", Units.pixelToEMU(widthPx), Units.pixelToEMU(heightPx));
return true;
}
return false;
}
}
Observed with POI 5.5.1: for a paragraph whose marker was split as ${cover and Image}, the call returned true
and left one picture in the document and one run in the paragraph. The width and height are given in pixels and
converted to English Metric Units by Units.pixelToEMU (Units.toEMU takes points, not pixels); keep the aspect
ratio of the source image yourself. The sizes, formats (PictureType.PNG, PictureType.JPEG) and anchoring options are in
Word Tables, Lists & Images.
Source: POI XWPF Quick Guide, Images, XWPFRun.addPicture and the SimpleImages example.
Pictures are the main driver of the size of the generated letters and of the heap used per request: scale a large logo to its printed size before inserting it, and read it from a cached byte array instead of from disk on every request.
Inserting paragraphs and tables at a marker
A block whose length depends on the data (a list of rules, a second table) cannot be a prototype row. The technique is
the one for pictures, one level up: a body paragraph that contains only the marker is located, new paragraphs and tables
are created at its position with an XmlCursor, and the marker paragraph is removed. XWPFDocument.insertNewParagraph
and insertNewTbl create the element at the cursor, that is before the element the cursor points at, so a cursor on
the marker paragraph inserts each new element just before the marker and the elements keep the order of the calls.
package com.bookshelf.office.word;
import java.util.List;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFTable;
import org.apache.poi.xwpf.usermodel.XWPFTableRow;
import org.apache.xmlbeans.XmlCursor;
/** Replaces a body paragraph that contains only a marker such as ${rules} by paragraphs and a table. */
public final class BlockAtMarker {
private BlockAtMarker() {
}
public static boolean replace(XWPFDocument doc, String marker, List<String> lines, List<List<String>> rows) {
XWPFParagraph markerParagraph = doc.getParagraphs().stream()
.filter(p -> p.getText().trim().equals(marker))
.findFirst()
.orElse(null);
if (markerParagraph == null) {
return false;
}
// each insert goes just before the marker, so the new elements keep the order of the calls
for (String line : lines) {
XWPFParagraph paragraph;
try (XmlCursor cursor = markerParagraph.getCTP().newCursor()) {
paragraph = doc.insertNewParagraph(cursor);
}
paragraph.setStyle(markerParagraph.getStyle());
paragraph.createRun().setText(line);
}
if (!rows.isEmpty()) {
XWPFTable table;
try (XmlCursor cursor = markerParagraph.getCTP().newCursor()) {
table = doc.insertNewTbl(cursor);
}
for (int r = 0; r < rows.size(); r++) {
XWPFTableRow row = r == 0 ? table.getRow(0) : table.createRow();
List<String> values = rows.get(r);
for (int c = 0; c < values.size(); c++) {
(c < row.getTableCells().size() ? row.getCell(c) : row.addNewTableCell()).setText(values.get(c));
}
}
}
doc.removeBodyElement(doc.getPosOfParagraph(markerParagraph));
return true;
}
}
Observed with POI 5.5.1, for a body of Loan rules:, a marker paragraph split as ${ru / les} and
Kind regards., re-opened after write:
replaced=true
P: Loan rules:
P: Three books at a time.
P: Four weeks per loan.
ROW: [Late days, Fee]
ROW: [1-7, 0.50]
ROW: [8+, 1.00]
P: Kind regards.
Source: XWPFDocument.insertNewParagraph(XmlCursor),
XWPFDocument.insertNewTbl(XmlCursor)
and the XMLBeans XmlCursor documentation. The marker technique
itself is this page’s, not an official recipe.
Details of the technique:
-
A fresh cursor is opened for every insert and closed by the try-with-resources block (
XmlCursorisAutoCloseablein XMLBeans 5). The methods move the cursor they receive, so reusing one cursor would insert the next element at a different place. -
insertNewTblreturns a table with one row of one cell;createRowandaddNewTableCellgrow it. The new table has no borders or style; format it as described in Word Tables, Lists & Images. -
removeBodyElementtakes the position ingetBodyElements(), whichgetPosOfParagraphreturns. The removed marker paragraph object must not be used afterwards. -
The code works on paragraphs of the body. A marker inside a table cell needs the same calls on the
XWPFTableCell, which also implementsinsertNewParagraph(XmlCursor)andinsertNewTbl(XmlCursor).
Values that contain line breaks and tabs
A value such as a postal address has several lines. Putting "12 Elm St\nSpringfield" into a w:t element does not
produce two lines: observed with POI 5.5.1, XWPFRun.setText stores the newline and the tab as characters inside the
w:t, and Word shows them as spaces. The setText helper of PlaceholderReplacer therefore splits the value and adds
w:br and w:tab elements between the pieces. Observed result for the value "12 Elm St\nSpringfield\tUS" in the
paragraph Address: ${ad / dress}!:
<w:r><w:t>Address: 12 Elm St</w:t><w:br/><w:t>Springfield</w:t><w:tab/><w:t>US</w:t></w:r><w:r><w:t>!</w:t></w:r>
If a run being replaced already contains a tab or a break of its own, setText removes only the w:t elements and the
new text is appended after the existing w:tab and w:br elements, so their order relative to the text can change.
That is rare in a run that holds a marker; if your template has such runs, split the marker from the layout tabs by
putting them in separate runs.
Source: XWPFRun.addBreak() and XWPFRun.addTab().
For a long block of body text (a clause, an e-mail body), prefer a paragraph per line to a break per line: the paragraph style then controls the spacing, and the document reads better in Word. That needs a clone of the marker paragraph, in the same way as the table row, and is where hand-written code starts to resemble a template engine.
Putting it together: the loan letter
LoanLetterService reads the template once into a byte array, as the workbook version does, and opens a private
XWPFDocument for every request. A document is not thread safe and must not be shared between threads; the immutable
byte[] can be.
package com.bookshelf.office.word;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFTable;
/** Fills loan-letter.docx from a template kept on the classpath. */
public final class LoanLetterService {
private static final String RESOURCE = "/templates/loan-letter-template.docx";
private final byte[] templateBytes;
public LoanLetterService() throws IOException {
try (InputStream in = LoanLetterService.class.getResourceAsStream(RESOURCE)) {
if (in == null) {
throw new FileNotFoundException("Missing template " + RESOURCE);
}
this.templateBytes = in.readAllBytes();
}
}
public byte[] render(Map<String, String> fields, List<Map<String, String>> loanLines) throws IOException {
try (XWPFDocument doc = new XWPFDocument(new ByteArrayInputStream(templateBytes));
ByteArrayOutputStream out = new ByteArrayOutputStream()) {
// 1. repeat the table row first, filling each copy with the global fields too:
// the global pass below cannot reach the added rows (see TableRows)
XWPFTable table = doc.getTables().stream()
.filter(t -> TableRows.findRow(t, "${line.title}") >= 0)
.findFirst()
.orElseThrow(() -> new IllegalStateException("Template has no loan table"));
List<Map<String, String>> rows = loanLines.stream().map(line -> withFields(fields, line)).toList();
TableRows.repeat(table, "${line.title}", rows);
// 2. replace everything else: body, tables, headers, footers
PlaceholderReplacer replacer = new PlaceholderReplacer(fields);
replacer.replaceAll(doc);
if (!replacer.missingKeys().isEmpty()) {
throw new IllegalStateException("Template placeholders without a value: " + replacer.missingKeys());
}
doc.write(out);
return out.toByteArray();
}
}
/** The values of one table row: the single-value fields plus the row's own, which win. */
private static Map<String, String> withFields(Map<String, String> fields, Map<String, String> line) {
Map<String, String> row = new HashMap<>(fields);
row.putAll(line);
return row;
}
}
Source: XWPFDocument.write(OutputStream) and the POI XWPF Quick Guide.
The order of the steps matters: repeat rows first (the added rows are final), then replace the single-value fields in every part, then check for missing names, then write. The observed output of the complete run, for a borrower with two loans, is:
P: Dear Ada Lovelace,
P: The following books are due on 2026-11-01.
ROW: [Title, Due]
ROW: [Dune, 2026-11-01]
ROW: [Emma, 2026-11-08]
H: Bookshelf Library - Bookshelf
F: Questions: help@example.org
count=4 missing=[]
(The header and footer lines were printed together in the run; count is replacedCount() and missing is missingKeys(). The row cells are filled by TableRows.repeat and are not counted.) Writing to a byte array is right for
a letter of a few hundred kilobytes that is returned by a controller; the streaming alternatives, the content type
and the Content-Disposition header are in the export pages of this section. For a document that must also be a PDF,
POI offers nothing: convert the .docx with another tool, as described in the PDF Generation section of this site (see
Alternatives & Other Ecosystems).
Fields, bookmarks and mail-merge fields
Word has its own mail-merge machinery: MERGEFIELD fields that a Word user connects to a data source. They are
complex fields: a run with w:fldChar of type begin, a run with w:instrText holding MERGEFIELD borrowerName, a
separate marker, the displayed result in one or more runs and an end marker. POI does not interpret these. You have
three options, in order of simplicity:
-
Do not use them. Ask the designer to type
${name}text markers. They are visible, searchable and handled by the code of this page. -
Fill the result runs. Locate the runs between
separateandendand replace their text with the same merge-into-the-first-run rule. The instruction text stays, so Word can still refresh the field. -
Replace the whole field by plain text. Remove the field runs and write a normal run. The field is gone for good.
Page-number and date fields in the footer are also complex fields and are better left alone: Word updates them when the document is opened or printed. The field details are in Word Headers, Footers, Sections & Fields.
Bookmarks (w:bookmarkStart / w:bookmarkEnd) can mark the place of a value, an alternative to text markers that does
not show on the page. XWPF exposes them only at the CT* level, which makes them a poor choice for a first version.
The empty runs that the algorithm leaves behind are one reason for not removing runs: a bookmark that starts or ends in
a run you delete is the kind of damage that appears only when the file is opened in Word.
Content controls as an alternative
A content control (structured document tag, w:sdt) is a labelled, optionally typed region that Word shows as a
form-like box. Its w:tag and w:alias give it a stable name that does not depend on the text, so it has the
advantage that no run splitting can make it disappear. In XWPF a body-level control is available as XWPFSDT among the
body elements; it exposes the title, tag and text of the control for reading. There is no documented write API for its
content: to fill one you work on the underlying XML (CTSdtBlock / CTSdtRun, CTSdtPr and their CTSdtContent*
content) with the CT* beans. Those beans are in the default poi-ooxml-lite jar of POI 5.5.1, so no extra
dependency is needed, but the code is tied to the XML schema. Treat that as an escape hatch, not as the default.
| Aspect | Text markers ${name} |
Content controls |
|---|---|---|
Designer effort |
Type the text |
Developer tab, insert control, set tag |
Survives editing |
Only if nobody breaks the marker |
The control keeps its identity |
Readable by POI |
Yes, through runs |
Partly ( |
Writable by POI |
Yes (this page) |
Only through |
Data binding to custom XML |
No |
Possible in Word, not exposed by POI |
For a template that must be edited by non-technical people over years, content controls are the more robust contract; for a template that is maintained by the developers, the text marker with the algorithm above is simpler and equally reliable.
Source: XWPFSDT Javadoc, XWPFSDTContent Javadoc and the ECMA-376 definition of structured document tags.
Pitfalls and security
| Pitfall | What to do |
|---|---|
The marker in a value (template injection) |
Values often come from users. The algorithm never rescans inserted text, so a borrower called |
Values are text, not XML |
|
Control characters in the value |
Characters such as U+0000 to U+0008 are not allowed in XML 1.0 and make Word refuse to open the file. Strip them from user input before it reaches the template. |
Macros |
A .docx cannot carry macros (a .docm can). Keep templates as .docx and refuse a template whose package contains a VBA part. |
An untrusted template |
Templates come from your repository, not from users. If users upload templates, treat them as hostile files: apply the limits and the process isolation described in the security page of this section, and never evaluate expressions found in them. |
Missing names |
Leave the marker, record the name, and fail the request (or log and alert), as |
Thread safety |
One |
Memory |
XWPF loads the whole document, and XMLBeans keeps its object tree. Expect several times the file size in heap per concurrent request. Large pictures dominate; keep them small or scale them before inserting. |
Case and whitespace in names |
|
Source: POI Security and POI XWPF Quick Guide.
Testing the template and the filler
Two kinds of tests keep the process honest. The first checks the algorithm on a fixture built in code, so that it does not depend on how a particular Word version wrote the file:
package com.bookshelf.office.word;
import static org.assertj.core.api.Assertions.assertThat;
import java.io.IOException;
import java.util.Map;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFRun;
import org.junit.jupiter.api.Test;
class PlaceholderReplacerTest {
/** Builds a paragraph the way Word saves it: one placeholder split over three runs. */
private static XWPFParagraph splitParagraph(XWPFDocument doc) {
XWPFParagraph paragraph = doc.createParagraph();
paragraph.createRun().setText("Dear ");
XWPFRun head = paragraph.createRun();
head.setBold(true);
head.setText("${bor");
paragraph.createRun().setText("rowerNa");
paragraph.createRun().setText("me}, welcome.");
return paragraph;
}
@Test
void replacesPlaceholderSplitAcrossRunsAndKeepsFormatting() throws IOException {
try (XWPFDocument doc = new XWPFDocument()) {
XWPFParagraph paragraph = splitParagraph(doc);
PlaceholderReplacer replacer = new PlaceholderReplacer(Map.of("borrowerName", "Ada Lovelace"));
replacer.replaceAll(doc);
assertThat(paragraph.getText()).isEqualTo("Dear Ada Lovelace, welcome.");
assertThat(paragraph.getRuns().get(1).isBold()).isTrue();
assertThat(replacer.replacedCount()).isEqualTo(1);
assertThat(replacer.missingKeys()).isEmpty();
}
}
@Test
void leavesUnknownPlaceholdersAndReportsThem() throws IOException {
try (XWPFDocument doc = new XWPFDocument()) {
XWPFParagraph paragraph = splitParagraph(doc);
PlaceholderReplacer replacer = new PlaceholderReplacer(Map.of());
replacer.replaceAll(doc);
assertThat(paragraph.getText()).contains("${borrowerName}");
assertThat(replacer.missingKeys()).containsExactly("borrowerName");
}
}
@Test
void doesNotRescanInsertedValues() throws IOException {
try (XWPFDocument doc = new XWPFDocument()) {
XWPFParagraph paragraph = splitParagraph(doc);
new PlaceholderReplacer(Map.of("borrowerName", "${borrowerName}")).replaceAll(doc);
assertThat(paragraph.getText()).isEqualTo("Dear ${borrowerName}, welcome.");
}
}
}
These three tests pass against POI 5.5.1 (JUnit 5 and AssertJ).
Source: JUnit User Guide and AssertJ documentation.
The second kind checks the real template. Open loan-letter-template.docx, collect every marker with the same pattern
and assert the set equals the names the code supplies, for example borrowerName, dueDate, libraryName, contact
and the row names line.title and line.due. A designer who renames a marker then breaks the build with the name of
the marker in the message. A rendering test (open the output in Word or convert it with another tool and compare
images) belongs in a manual checklist, not in the unit tests: POI cannot render a document.
poi-tl and XDocReport
When the flat map, one repeating table and a picture are not enough (nested lists, conditional blocks, tables built from a collection, charts that follow the data, a template language the designers can read), writing the engine yourself is the wrong investment. The POI project lists two projects built on top of it for that purpose on its related-projects page:
-
poi-tl is a Word template engine built on POI. Its templates use tags written in the document, and its model covers text, images, tables, lists and other block types. It is the closer of the two to the approach of this page, because the template is an ordinary .docx edited in Word. See the project at poi-tl on GitHub.
-
XDocReport generates reports from .docx and .odt templates with a template engine (such as Freemarker or Velocity) and can convert the result to other formats through its own converters. See XDocReport on GitHub.
This page does not document how to use either library: it describes what they are so that the choice is informed. Check the current status, supported POI version and license of each project in its repository before depending on it; that information changes and is not covered by the POI documentation. Their cost is the cost of any dependency: another upgrade path tied to POI’s, another template syntax for the designers to learn, and a place where a missing feature is a project issue rather than a line of your own code.
| Situation | Hand-written filler (this page) | Template library |
|---|---|---|
One letter, a handful of fields, one table |
Best: about two hundred lines, no dependency |
Overkill |
Many templates, owned by non-developers, with loops and conditions |
Grows into an engine of your own |
Better |
Strict control of the POI version and of the dependency tree |
Yes |
Limited by the library’s POI version |
Output formats other than .docx |
No (not POI’s job) |
Some libraries offer conversions |
Source: POI Related Projects, poi-tl and XDocReport.
Changes between POI versions
| Version | Relevant to this page |
|---|---|
4.x |
POI follows semantic versioning since 4.0.0; the classes used here ( |
5.x |
The XMLBeans schema jar is |
5.5.1 |
The latest release on the baseline date. All code in this page was run against it. |
6.0 (not released) |
Announced as needing a newer Java and removing deprecated APIs. None of the methods used here is documented as deprecated, but re-run the tests of the previous section when 6.0 ships. |
Source: POI Versioning and POI Release Changes.
What POI does not provide
-
No template engine and no mail-merge for XWPF. The code on this page is yours; so are its bugs and its tests.
-
No write API for content controls, text boxes or complex fields. Only the
CT*XMLBeans level reaches them. -
No rendering. Whether a replaced document looks right can only be judged by opening it in a word processor.
-
No conversion to PDF. See the PDF Generation section of this site for converters.
-
No protection of the template. POI neither verifies that the template has the markers the code expects nor warns that a marker is split; that is why the missing-keys report and the template test exist.
-
No support for the legacy .doc format in these examples. HWPF has no comparable editing model; see Legacy .doc with HWPF.
References
-
POI XWPF Quick Guide: https://poi.apache.org/components/document/quick-guide-xwpf.html
-
POI Word overview: https://poi.apache.org/components/document/index.html
-
POI Related Projects (poi-tl, XDocReport): https://poi.apache.org/related-projects.html
-
POI Security: https://poi.apache.org/security.html
-
POI Versioning: https://poi.apache.org/versioning.html
-
POI Release Changes: https://poi.apache.org/changes.html
-
Javadoc,
XWPFParagraph: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFParagraph.html -
Javadoc,
XWPFRun: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFRun.html -
Javadoc,
TextSegment: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/TextSegment.html -
Javadoc,
PositionInParagraph: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/PositionInParagraph.html -
Javadoc,
IBody: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/IBody.html -
Javadoc,
XWPFDocument: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFDocument.html -
Javadoc,
XWPFHeaderFooter: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFHeaderFooter.html -
Javadoc,
XWPFFootnote: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFFootnote.html -
Javadoc,
XWPFTable: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFTable.html -
Javadoc,
XWPFTableRow: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFTableRow.html -
Javadoc,
XWPFSDT: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFSDT.html -
Javadoc,
XWPFSDTContent: https://poi.apache.org/apidocs/dev/org/apache/poi/xwpf/usermodel/XWPFSDTContent.html -
Official examples (
SimpleImages,SimpleTable,SimpleDocument): https://github.com/apache/poi/tree/trunk/poi-examples/src/main/java/org/apache/poi/examples/xwpf/usermodel -
poi-tl: https://github.com/Sayi/poi-tl
-
XDocReport: https://github.com/opensagres/xdocreport
-
ECMA-376: https://ecma-international.org/publications-and-standards/standards/ecma-376/
-
AssertJ: https://assertj.github.io/doc/