Importing Data from Word
|
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. Every class in this page was compiled against POI 5.5.1 and run
against a generated book-request-form.docx (and, for the legacy part, a .doc written from it); the outputs marked
as observed come from those runs.
A Word file is a document for people, and people use it as a form: a library department fills in a
book-request-form.docx with its name, a delivery date, a few checkboxes and a table of the books it wants, and sends it
to the Bookshelf server. Importing that file means turning layout into data. Word has no "field" concept in the way a
database has one, so the data hides in four different structures, and a robust importer reads each one with the tool
that fits it:
-
Tables hold lists of records (the requested books). Section "Tables to rows".
-
Headings and paragraph styles give the document an outline, so the text under a heading can be read by the heading. Section "Paragraphs grouped by heading".
-
Content controls (structured document tags,
w:sdt) are Word’s real form fields: a text box, a dropdown, a date picker or a checkbox, each with a machine-readable tag. Section "Content controls as form fields". -
Embedded images and files are separate parts of the package. Section "Embedded images and attachments".
The older .doc format has the first three in a different form, covered in "Legacy .doc with HWPF". Everything ends in
one importer that reports every problem with a location, instead of failing at the first one, and that follows the
security rules of Security & Untrusted Files, because an
upload is hostile until proven otherwise. The Excel counterpart of this page, with a header-row mapping and an annotated
error workbook, is the Importing Data from Excel page of this section; the building blocks used here are described in
Word XWPF Basics (the object model),
Word Tables, Lists & Images (how tables and pictures are
written) and Legacy .doc with HWPF (HWPF in depth).
The import flow
The detection step looks at the content of the file, never at its name; the parse step is the only one that runs POI’s parsers, so it is the one bounded by limits, as described in the security page.
Where each kind of data lives
| Data in the form | Where it is in a .docx |
POI API | Good for |
|---|---|---|---|
A named field with one value (name, department, date, checkbox) |
A |
|
The reliable way to build a form that a program reads: the tag survives edits to the visible label |
A list of records (requested books) |
A |
|
Repeating lines with the same columns |
Free text under a title (justification, notes) |
|
|
Documents written by people without a form, where the heading is the only key |
A label followed by a value ("Name: Ana") |
Text in runs of one or more paragraphs |
|
Last resort: fragile, because Word may split the text in any number of runs |
Pictures |
|
|
Scanned signatures, covers, supporting evidence |
Attached files |
|
|
A quote or a spreadsheet added through Insert > Object |
Source: XWPF quick guide and xwpf.usermodel Javadoc.
The sample form and a generator for it
The examples read this document. It is generated by a small class so that the page is reproducible without Word: a
Title and two Heading 1 sections with controls (a text box, a dropdown, a date picker, a checkbox, an untouched text box
that still shows its placeholder), a block-level control around two paragraphs, a table of three requested books whose
second author cell is merged with the first, and finally a picture with alt text and an embedded .xlsx. The quantity of
the last book is the text x on purpose, so that the importer has something to complain about.
| Part of the form | Content |
|---|---|
Title (style |
Book request form |
Heading 1 |
Name (text control, tag |
Heading 1 |
A block-level rich text control |
Heading 1 |
A table: Title, Author, ISBN, Quantity; three rows, the author cell of the second row merged downwards |
Heading 1 |
A cover picture with alt text and |
package demo;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.imageio.ImageIO;
import org.apache.poi.openxml4j.opc.PackagePart;
import org.apache.poi.openxml4j.opc.PackagePartName;
import org.apache.poi.openxml4j.opc.PackagingURIHelper;
import org.apache.poi.openxml4j.opc.TargetMode;
import org.apache.poi.util.Units;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.apache.poi.xwpf.usermodel.*;
import org.apache.xmlbeans.XmlCursor;
import org.apache.xmlbeans.XmlException;
import org.apache.xmlbeans.XmlObject;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.CTStyle;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STStyleType;
/** Test fixture: builds book-request-form.docx with tables, content controls, an image and an attachment. */
public final class BookRequestFormGenerator {
private static final String W = "xmlns:w=\"http://schemas.openxmlformats.org/wordprocessingml/2006/main\"";
private static final String W14 = "xmlns:w14=\"http://schemas.microsoft.com/office/word/2010/wordml\"";
public static void main(String[] args) throws Exception {
Path target = Path.of(args.length > 0 ? args[0] : "book-request-form.docx");
try (XWPFDocument doc = new XWPFDocument(); OutputStream out = Files.newOutputStream(target)) {
addHeadingStyles(doc);
heading(doc, "Title", "Book request form");
heading(doc, "Heading1", "Requester");
XWPFParagraph p = doc.createParagraph();
p.createRun().setText("Name: ");
appendXml(p.getCTP(), "<w:sdt " + W + "><w:sdtPr><w:alias w:val=\"Requester name\"/>"
+ "<w:tag w:val=\"requester.name\"/><w:text/></w:sdtPr>"
+ "<w:sdtContent><w:r><w:t>Ana Pérez</w:t></w:r></w:sdtContent></w:sdt>");
p = doc.createParagraph();
p.createRun().setText("Department: ");
appendXml(p.getCTP(), "<w:sdt " + W + "><w:sdtPr><w:tag w:val=\"requester.department\"/>"
+ "<w:dropDownList><w:listItem w:displayText=\"Fiction\" w:value=\"FIC\"/>"
+ "<w:listItem w:displayText=\"Science\" w:value=\"SCI\"/></w:dropDownList></w:sdtPr>"
+ "<w:sdtContent><w:r><w:t>Science</w:t></w:r></w:sdtContent></w:sdt>");
p = doc.createParagraph();
p.createRun().setText("Needed by: ");
appendXml(p.getCTP(), "<w:sdt " + W + "><w:sdtPr><w:tag w:val=\"request.neededBy\"/>"
+ "<w:date w:fullDate=\"2026-11-15T00:00:00Z\"><w:dateFormat w:val=\"dd/MM/yyyy\"/></w:date>"
+ "</w:sdtPr><w:sdtContent><w:r><w:t>15/11/2026</w:t></w:r></w:sdtContent></w:sdt>");
p = doc.createParagraph();
p.createRun().setText("Needs approval: ");
appendXml(p.getCTP(), "<w:sdt " + W + " " + W14 + "><w:sdtPr><w:tag w:val=\"request.approval\"/>"
+ "<w14:checkbox><w14:checked w14:val=\"1\"/></w14:checkbox></w:sdtPr>"
+ "<w:sdtContent><w:r><w:t>☒</w:t></w:r></w:sdtContent></w:sdt>");
p = doc.createParagraph();
p.createRun().setText("Priority: ");
appendXml(p.getCTP(), "<w:sdt " + W + "><w:sdtPr><w:tag w:val=\"request.priority\"/><w:text/>"
+ "<w:showingPlcHdr/></w:sdtPr><w:sdtContent><w:r><w:t>Click to enter a priority</w:t></w:r>"
+ "</w:sdtContent></w:sdt>");
heading(doc, "Heading1", "Justification");
appendXml(doc.getDocument().getBody(), "<w:sdt " + W + "><w:sdtPr><w:alias w:val=\"Justification\"/>"
+ "<w:tag w:val=\"request.justification\"/></w:sdtPr><w:sdtContent>"
+ "<w:p><w:r><w:t>Three students need the course reader.</w:t></w:r></w:p>"
+ "<w:p><w:r><w:t>The library holds one copy.</w:t></w:r></w:p></w:sdtContent></w:sdt>");
heading(doc, "Heading1", "Requested books");
XWPFTable table = doc.createTable(4, 4);
String[][] rows = {{"Title", "Author", "ISBN", "Quantity"},
{"The Dispossessed", "Ursula K. Le Guin", "9780061054884", "2"},
{"The Left Hand of Darkness", "", "9780441478125", "1"},
{"Cosmos", "Carl Sagan", "9780345539434", "x"}};
for (int r = 0; r < rows.length; r++) {
for (int c = 0; c < 4; c++) {
table.getRow(r).getCell(c).setText(rows[r][c]);
}
}
// vertical merge: the author cell of row 2 continues the one of row 1
table.getRow(1).getCell(1).getCTTc().addNewTcPr().addNewVMerge().setVal(
org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge.RESTART);
table.getRow(2).getCell(1).getCTTc().addNewTcPr().addNewVMerge();
heading(doc, "Heading1", "Attachments");
byte[] png = cover();
XWPFRun run = doc.createParagraph().createRun();
run.addPicture(new ByteArrayInputStream(png), XWPFDocument.PICTURE_TYPE_PNG, "cover.png",
Units.toEMU(60), Units.toEMU(80));
run.getEmbeddedPictures().get(0).getCTPicture().getNvPicPr().getCNvPr().setDescr("Course reader cover");
embedWorkbook(doc);
doc.write(out);
}
}
private static void heading(XWPFDocument doc, String styleId, String text) {
XWPFParagraph p = doc.createParagraph();
p.setStyle(styleId);
p.createRun().setText(text);
}
private static void addHeadingStyles(XWPFDocument doc) {
XWPFStyles styles = doc.createStyles();
String[][] defs = {{"Title", "Title"}, {"Heading1", "heading 1"}, {"Heading2", "heading 2"}};
for (String[] d : defs) {
CTStyle ct = CTStyle.Factory.newInstance();
ct.setStyleId(d[0]);
ct.addNewName().setVal(d[1]);
ct.setType(STStyleType.PARAGRAPH);
styles.addStyle(new XWPFStyle(ct));
}
}
private static byte[] cover() throws IOException {
BufferedImage img = new BufferedImage(60, 80, BufferedImage.TYPE_INT_RGB);
try (ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
ImageIO.write(img, "png", bos);
return bos.toByteArray();
}
}
private static void embedWorkbook(XWPFDocument doc) throws Exception {
byte[] xlsx;
try (XSSFWorkbook wb = new XSSFWorkbook(); ByteArrayOutputStream bos = new ByteArrayOutputStream()) {
wb.createSheet("Quote").createRow(0).createCell(0).setCellValue("supplier quote");
wb.write(bos);
xlsx = bos.toByteArray();
}
PackagePartName name = PackagingURIHelper.createPartName("/word/embeddings/Microsoft_Excel_Sheet1.xlsx");
PackagePart part = doc.getPackage().createPart(name,
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
try (OutputStream os = part.getOutputStream()) {
os.write(xlsx);
}
doc.getPackagePart().addRelationship(name, TargetMode.INTERNAL,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/package");
}
private static void appendXml(XmlObject target, String xml) throws XmlException {
try (XmlCursor dst = target.newCursor(); XmlCursor src = XmlObject.Factory.parse(xml).newCursor()) {
dst.toEndToken();
src.toFirstChild();
src.copyXml(dst);
}
}
}
The generator shows the three ways in which Word stores a control (inline in a paragraph, around blocks in the body)
and how little XWPF helps with writing them: there is no XWPFSDT builder, so the w:sdt elements are inserted as XML
with XMLBeans. Reading them back is easier, as the next sections show. The merged cell is written with a w:vMerge
property; the styles Title, heading 1 and heading 2 are created by hand because a document made with new
XWPFDocument() has no styles part (a document saved by Word has dozens).
Source: poi-examples: xwpf/usermodel, from which the table, picture and package-part calls derive, and OPCPackage Javadoc.
Dependencies
Reading .docx needs poi-ooxml (which brings poi-ooxml-lite, the schemas of the common elements, w:sdt included).
Reading .doc needs poi-scratchpad. Both must have the same version. The full dependency discussion, including the
logging bridge, is in Setup & Dependencies.
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.5.1</version>
</dependency>
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>5.5.1</version>
</dependency>
dependencies {
implementation("org.apache.poi:poi-ooxml:5.5.1")
implementation("org.apache.poi:poi-scratchpad:5.5.1") // only for legacy .doc
}
Source: POI components.
Tables to rows
A table is the right structure for repeating data, and XWPFTable is easy to walk: rows, then cells, then the text of a
cell. The first row is taken as the header and every other row becomes a map from a normalised header to the cell text,
the same shape a CSV reader produces, so the later stages do not care where the data came from. Two details make the
difference between a toy and an importer.
Merged cells. Word represents a horizontal merge as one w:tc with a w:gridSpan of N and a vertical merge as a
sequence of cells where the first has w:vMerge w:val="restart" and the following ones have an empty w:vMerge. XWPF
returns the cells that exist in the XML, so a row with a merged cell has fewer cells than the header. The method
gridRow expands spans to one entry per grid column and repeats the value of the cell above for a vertical-merge
continuation. Without it the second book of the sample would have an empty author.
Cell text. XWPFTableCell.getText joins the text of the paragraphs of the cell with a newline. Use
getTextRecursively when the cell may contain a nested table, and getParagraphs when the paragraphs themselves matter
(a list in a cell).
package demo;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.CTTcPr;
import org.openxmlformats.schemas.wordprocessingml.x2006.main.STMerge;
/** Turns a Word table into a list of rows keyed by the header row. */
public final class DocxTables {
private DocxTables() {
}
/** One grid row: merged cells are expanded so that the list has one entry per grid column. */
static List<String> gridRow(XWPFTableRow row, List<String> rowAbove) {
List<String> values = new ArrayList<>();
for (XWPFTableCell cell : row.getTableCells()) {
CTTcPr pr = cell.getCTTc().getTcPr();
int span = pr != null && pr.isSetGridSpan() ? pr.getGridSpan().getVal().intValue() : 1;
String text = cell.getText().strip();
boolean continuation = pr != null && pr.isSetVMerge()
&& pr.getVMerge().getVal() != STMerge.RESTART;
if (continuation && rowAbove != null && values.size() < rowAbove.size()) {
text = rowAbove.get(values.size()); // vertical merge: repeat the value above
}
for (int i = 0; i < span; i++) {
values.add(text);
}
}
return values;
}
/** The first row is the header; every other row becomes a map header -> value. */
public static List<Map<String, String>> toRows(XWPFTable table) {
List<XWPFTableRow> rows = table.getRows();
if (rows.size() < 2) {
return List.of();
}
List<String> header = gridRow(rows.get(0), null).stream()
.map(h -> h.toLowerCase(java.util.Locale.ROOT)).toList();
List<Map<String, String>> result = new ArrayList<>();
List<String> above = header;
for (XWPFTableRow row : rows.subList(1, rows.size())) {
List<String> values = gridRow(row, above);
Map<String, String> record = new LinkedHashMap<>();
for (int c = 0; c < header.size(); c++) {
record.put(header.get(c), c < values.size() ? values.get(c) : "");
}
result.add(record);
above = values;
}
return result;
}
}
Observed on the sample form (the header of every record is lower-cased):
[{title=The Dispossessed, author=Ursula K. Le Guin, isbn=9780061054884, quantity=2},
{title=The Left Hand of Darkness, author=Ursula K. Le Guin, isbn=9780441478125, quantity=1},
{title=Cosmos, author=Carl Sagan, isbn=9780345539434, quantity=x}]
How to find the right table when a document has several:
| Strategy | Comment |
|---|---|
By position ( |
Fine for a form you control; break when someone adds a table above. |
By header row |
Pick the table whose first row contains |
By the heading above it |
Walk |
By table style or caption |
|
Source: XWPF quick guide: tables and XWPFTable Javadoc.
Paragraphs grouped by heading
When a document is prose, such as a report or a free-form request, the only keys it has are its headings. Word assigns each paragraph a style; the paragraphs "Requester", "Justification" and "Requested books" of the sample use the built-in style Heading 1. The style is stored in two places that must not be confused:
| Item | Where | Remark |
|---|---|---|
Style id |
|
An internal key. Word writes |
Style name |
|
The built-in names are |
So the reader resolves the id to the name and matches the name. It also falls back to the id when the document has no styles part at all, as happens with files generated by other libraries. The method groups every non-empty paragraph under the closest preceding heading:
package demo;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.xwpf.usermodel.IBodyElement;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFParagraph;
import org.apache.poi.xwpf.usermodel.XWPFStyle;
import org.apache.poi.xwpf.usermodel.XWPFStyles;
/** Groups the paragraphs of a document under the heading that precedes them. */
public final class DocxHeadings {
private static final Pattern HEADING = Pattern.compile("heading ?(\\d)");
private DocxHeadings() {
}
/** 0 for body text, 1..9 for Heading 1..9, regardless of the style id or the UI language. */
static int headingLevel(XWPFParagraph p, XWPFStyles styles) {
String styleId = p.getStyle();
if (styleId == null) {
return 0;
}
String name = styleId;
XWPFStyle style = styles == null ? null : styles.getStyle(styleId);
if (style != null && style.getName() != null) {
name = style.getName(); // "heading 1" is the built-in name
}
Matcher m = HEADING.matcher(name.toLowerCase(Locale.ROOT));
return m.matches() ? Integer.parseInt(m.group(1)) : 0;
}
/** Heading text -> the non-empty paragraphs under it; tables are skipped (see DocxTables). */
public static Map<String, List<String>> sections(XWPFDocument doc) {
Map<String, List<String>> sections = new LinkedHashMap<>();
String current = "(preamble)";
for (IBodyElement element : doc.getBodyElements()) {
if (element instanceof XWPFParagraph p) {
String text = p.getText().strip();
if (text.isEmpty()) {
continue;
}
if (headingLevel(p, doc.getStyles()) > 0) {
current = text;
sections.putIfAbsent(current, new ArrayList<>());
} else {
sections.computeIfAbsent(current, k -> new ArrayList<>()).add(text);
}
}
}
return sections;
}
}
Observed on the sample form:
{(preamble)=[Book request form],
Requester=[Name: Ana Pérez, Department: Science, Needed by: 15/11/2026, Needs approval: ☒,
Priority: Click to enter a priority],
Justification=[],
Requested books=[],
Attachments=[]}
The output teaches three things. The Title is not a heading in this sense and lands in the preamble. The text of
inline controls is part of XWPFParagraph.getText, so the paragraph "Name: Ana Pérez" shows the control value; but
"Justification" is empty because the block-level control is not a paragraph of the body (getBodyElements returns an
XWPFSDT for it, which the instanceof XWPFParagraph test skips), and the table is skipped by design. A reader that
needs those elements adds branches for XWPFSDT (use getContent().getText()) and XWPFTable to the loop; the
following section reads the controls directly, which is better for forms.
Other ways to find a heading when styles are not trustworthy: the outline level (w:outlineLvl in the paragraph
properties, paragraph.getCTP().getPPr().getOutlineLvl()), which Word sets for headings and for custom styles that
should appear in the navigation pane, or a numbering pattern in the text. Prefer the style: it is what the author sees
in the ribbon.
Source: XWPF quick guide and XWPFStyles Javadoc.
Content controls as form fields
Content controls are the feature that makes a Word document a form: the template author inserts a control from the
Developer tab, gives it a Title (shown in the UI, XML w:alias) and a Tag (a stable key for programs, XML
w:tag), and the person who fills the form edits only the content of the control. The tag is the key an importer
should use: the visible labels ("Name:", "Nombre:") change with edits and translations, the tag does not.
Kinds of controls (the child of w:sdtPr that defines the kind) and how their value is stored:
| Control | w:sdtPr child |
Value to read |
|---|---|---|
Plain text |
|
The text in |
Rich text |
(none) |
The paragraphs and runs in |
Drop-down list, combo box |
|
|
Date picker |
|
|
Checkbox (Word 2010 and later) |
|
|
Picture |
|
A drawing in the content; extract it as in the images section. |
Building block, repeating section, citation, group |
|
Not handled by XWPF; read the XML if needed. |
A control that was never edited still shows its placeholder text ("Click to enter a priority"). Word marks that state
with w:showingPlcHdr in the properties; the placeholder must be treated as "no value", otherwise the importer stores
the instruction text as data. The sample form has one such control.
Where controls can be. w:sdt appears in three structural positions: run level (inside a paragraph, CTSdtRun),
block level (a child of the body or of a table cell, around one or more paragraphs or tables, CTSdtBlock) and
cell or row level (CTSdtCell, CTSdtRow). XWPF models them as XWPFSDT (block and run level, also an
IRunElement in XWPFParagraph.getIRuns()) and XWPFSDTCell. Both give getTag(), getTitle() and
getContent().getText(), but neither exposes the kind, the checkbox state, the list items or the placeholder flag. For
a form importer the simplest uniform route is therefore to select every w:sdt of the body with an XPath and read the
properties from the XML. selectPath with declare namespace works on the lite schemas and needs no extra jar; nested
controls are returned in document order.
package demo;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import javax.xml.namespace.QName;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.xmlbeans.XmlCursor;
import org.apache.xmlbeans.XmlObject;
/** Reads content controls (w:sdt) anywhere in the body: block level, inline and in table cells. */
public final class ContentControls {
private static final String W = "http://schemas.openxmlformats.org/wordprocessingml/2006/main";
private static final String W14 = "http://schemas.microsoft.com/office/word/2010/wordml";
private static final String NS = "declare namespace w='" + W + "'; ";
public enum Kind { TEXT, RICH_TEXT, DROP_DOWN, COMBO_BOX, DATE, CHECKBOX, PICTURE, OTHER }
/** A control with its tag, its display value and, for dropdowns, the stored value. */
public record Control(String tag, String title, Kind kind, String text, String value, boolean placeholder) {
}
private ContentControls() {
}
public static List<Control> read(XWPFDocument doc) {
List<Control> controls = new ArrayList<>();
XmlObject body = doc.getDocument().getBody();
for (XmlObject sdt : body.selectPath(NS + ".//w:sdt")) { // document order, nested controls included
XmlObject[] props = sdt.selectPath(NS + "./w:sdtPr");
if (props.length == 0) {
continue;
}
XmlObject pr = props[0];
String tag = attr(pr, "./w:tag", W, "val");
String title = attr(pr, "./w:alias", W, "val");
String text = text(sdt);
controls.add(new Control(tag, title, kind(pr), text, storedValue(pr, text),
pr.selectPath(NS + "./w:showingPlcHdr").length > 0));
}
return controls;
}
private static Kind kind(XmlObject pr) {
if (pr.selectPath(NS + "./w:dropDownList").length > 0) return Kind.DROP_DOWN;
if (pr.selectPath(NS + "./w:comboBox").length > 0) return Kind.COMBO_BOX;
if (pr.selectPath(NS + "./w:date").length > 0) return Kind.DATE;
if (pr.selectPath(NS + "./w:picture").length > 0) return Kind.PICTURE;
if (pr.selectPath("declare namespace w14='" + W14 + "'; ./w14:checkbox").length > 0) return Kind.CHECKBOX;
if (pr.selectPath(NS + "./w:text").length > 0) return Kind.TEXT;
return Kind.RICH_TEXT;
}
/** The machine value: list item value, checkbox state, ISO date; otherwise the displayed text. */
private static String storedValue(XmlObject pr, String text) {
for (XmlObject item : pr.selectPath(NS + "./w:dropDownList/w:listItem | ./w:comboBox/w:listItem")) {
if (text.equals(attr(item, ".", W, "displayText"))) {
return attr(item, ".", W, "value");
}
}
for (XmlObject checked : pr.selectPath("declare namespace w14='" + W14 + "'; ./w14:checkbox/w14:checked")) {
String v = attr(checked, ".", W14, "val");
return String.valueOf("1".equals(v) || "true".equals(v));
}
for (XmlObject date : pr.selectPath(NS + "./w:date")) {
String full = attr(date, ".", W, "fullDate");
return full == null ? null : OffsetDateTime.parse(full).toLocalDate().toString();
}
return text;
}
/** Concatenates the w:t text of the content; paragraphs are separated by a newline. */
private static String text(XmlObject sdt) {
StringBuilder sb = new StringBuilder();
for (XmlObject content : sdt.selectPath(NS + "./w:sdtContent")) {
for (XmlObject t : content.selectPath(NS + ".//w:p | .//w:r/w:t")) {
try (XmlCursor c = t.newCursor()) {
if ("p".equals(c.getName().getLocalPart())) {
if (sb.length() > 0) sb.append('\n');
} else {
sb.append(c.getTextValue());
}
}
}
}
return sb.toString().strip();
}
private static String attr(XmlObject parent, String path, String ns, String local) {
for (XmlObject o : parent.selectPath(NS + path)) {
try (XmlCursor c = o.newCursor()) {
String v = c.getAttributeText(new QName(ns, local));
if (v != null) {
return v;
}
}
}
return null;
}
/** Controls with a tag, as a map tag -> control (the last one wins when a tag is repeated). */
public static Map<String, Control> byTag(List<Control> controls) {
Map<String, Control> map = new LinkedHashMap<>();
for (Control c : controls) {
if (c.tag() != null) {
map.put(c.tag(), c);
}
}
return map;
}
}
Observed on the sample form (value is what the importer should store, text is what the person sees):
Control[tag=requester.name, title=Requester name, kind=TEXT, text=Ana Pérez, value=Ana Pérez, placeholder=false]
Control[tag=requester.department, title=null, kind=DROP_DOWN, text=Science, value=SCI, placeholder=false]
Control[tag=request.neededBy, title=null, kind=DATE, text=15/11/2026, value=2026-11-15, placeholder=false]
Control[tag=request.approval, title=null, kind=CHECKBOX, text=☒, value=true, placeholder=false]
Control[tag=request.priority, title=null, kind=TEXT, text=Click to enter a priority, value=Click to enter a priority, placeholder=true]
Control[tag=request.justification, title=Justification, kind=RICH_TEXT, text=Three students need the course reader.
The library holds one copy., ...]
Points to keep in mind:
-
Tags are not unique unless the template author makes them so;
byTagkeeps the last one. A repeated tag usually means a copy-pasted control; the importer may prefer to report it. -
Headers, footers, footnotes and text boxes are separate parts.
doc.getDocument().getBody()covers the main body only; controls in a header or footer need the same query on the XML of that part. -
Word can lock a control (
w:lock) so the user cannot delete it or edit its content. Locking is a convenience for honest users: it does nothing against a hostile file, so validate the values anyway. -
A form can also use the older legacy form fields (
w:fldCharwithw:ffData, from the Forms toolbar) or document properties mapped to controls through a custom XML part. XWPF does not read either as fields; document properties are covered in Document Properties & Metadata.
Source: XWPFSDT Javadoc, the
ECMA-376 definition of structured
document tags (part 1, 17.5.2), and Apache XMLBeans for XmlObject.selectPath.
Embedded images and attachments
A .docx is a package (see File Formats: OOXML & OLE2),
so pictures and attached files are separate parts that the document points to by relationship.
| Content | Part | POI |
|---|---|---|
Pictures (PNG, JPEG, GIF, EMF, WMF …) |
|
|
Picture with alt text and size |
|
|
Attached or embedded files and OLE objects |
|
|
Linked (not embedded) files |
A relationship with |
|
Two rules shape the code below. First, the file names inside the document are data of the attacker: a part name or an
original file name can contain ../ or a name that overwrites something. The extractor therefore ignores them and
writes pictureN.ext into a directory chosen by the server, with the extension derived from the content type
(suggestFileExtension) and checked against an allow-list. Second, size limits are mandatory: a part is read with a
bounded read, and the content of an attachment is never opened or executed by the importer. If the attachment is a
spreadsheet or another document, import it in a later step with its own pipeline, with the same limits.
package demo;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.regex.Pattern;
import org.apache.poi.openxml4j.opc.PackagePart;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFPictureData;
/** Extracts embedded images and attachments without trusting any name stored in the file. */
public final class DocxEmbedded {
public static final int MAX_PART_BYTES = 10 * 1024 * 1024;
/** What was found; the bytes are only read when a caller asks for them. */
public record Attachment(String partName, String contentType, long size) {
}
private DocxEmbedded() {
}
/** Saves every picture as pictureN.ext in a directory chosen by the server. */
public static List<Path> savePictures(XWPFDocument doc, Path directory) throws IOException {
List<Path> saved = new ArrayList<>();
int n = 1;
for (XWPFPictureData picture : doc.getAllPackagePictures()) { // every image part, used or not
String ext = picture.suggestFileExtension(); // from the content type, not the file name
if (!List.of("png", "jpeg", "gif", "emf", "wmf", "bmp", "tiff").contains(ext)) {
continue; // skip unknown or active formats
}
byte[] data = picture.getData();
if (data.length > MAX_PART_BYTES) {
continue;
}
Path target = directory.resolve("picture" + n++ + "." + ext);
Files.write(target, data);
saved.add(target);
}
return saved;
}
/** Lists the parts under /word/embeddings (OLE objects, packaged files such as a .xlsx). */
public static List<Attachment> listAttachments(XWPFDocument doc) throws Exception {
List<Attachment> found = new ArrayList<>();
for (PackagePart part : doc.getPackage().getPartsByName(Pattern.compile("/word/embeddings/.*"))) {
try (InputStream in = part.getInputStream()) {
byte[] bytes = readLimited(in); // stops above the limit
found.add(new Attachment(part.getPartName().getName(), part.getContentType(), bytes.length));
}
}
return found;
}
/** Bytes of one attachment; the caller decides whether and how to open them. */
public static byte[] read(XWPFDocument doc, String partName) throws Exception {
for (PackagePart part : doc.getPackage().getPartsByName(Pattern.compile("/word/embeddings/.*"))) {
if (part.getPartName().getName().equals(partName)) {
try (InputStream in = part.getInputStream()) {
return readLimited(in);
}
}
}
throw new IllegalArgumentException("No such attachment: " + partName);
}
private static byte[] readLimited(InputStream in) throws IOException {
byte[] bytes = in.readNBytes(MAX_PART_BYTES + 1); // one byte more than allowed
if (bytes.length > MAX_PART_BYTES) {
throw new IOException("Embedded part is larger than " + MAX_PART_BYTES + " bytes");
}
return bytes;
}
}
Observed on the sample form:
[Attachment[partName=/word/embeddings/Microsoft_Excel_Sheet1.xlsx,
contentType=application/vnd.openxmlformats-officedocument.spreadsheetml.sheet, size=3290]]
[.../picture1.png]
alt=Course reader cover image1.png
image/png
An OLE object inserted with Insert > Object is stored as an oleObjectN.bin part (an OLE2 compound file wrapping the
real file in an Ole10Native stream) or, for Office files, as the packaged document; the type is in the content type of
the part, and the list above gives the part name without opening it. POI can read the wrapped bytes of an Ole10Native
stream with org.apache.poi.poifs.filesystem.Ole10Native in poi, but the result still has to be treated as an
untrusted file.
Source: XWPF quick guide: images, XWPFPictureData Javadoc and OpenXML4J.
Legacy .doc with HWPF
Word 97-2003 files have no content controls, no styles part and no package: they are an OLE2 compound file with a binary
WordDocument stream (see Legacy .doc with HWPF for the
format and the status of the component). What an importer can still read:
| Need | HWPF | Comment |
|---|---|---|
All the text, with headers, footers, footnotes and comments |
|
The reliable part. Use it for search and for forms where the text is enough. |
Tables |
|
Cell text ends with the cell mark |
Headings |
|
The built-in names are the same ( |
Pictures |
|
Each |
Form fields |
Not exposed as a model |
The classic Forms toolbar fields ( |
The class reads the table of the sample form and the headings, with the same normalised cell strings as in the .docx
reader; the table is found through the paragraphs, because a table is a sequence of paragraphs flagged as "in table".
package demo;
import java.io.IOException;
import java.io.InputStream;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.extractor.WordExtractor;
import org.apache.poi.hwpf.model.StyleDescription;
import org.apache.poi.hwpf.usermodel.Paragraph;
import org.apache.poi.hwpf.usermodel.Range;
import org.apache.poi.hwpf.usermodel.Table;
import org.apache.poi.hwpf.usermodel.TableRow;
/** Reads the same structures from a legacy .doc with HWPF. */
public final class LegacyDoc {
private LegacyDoc() {
}
/** Tables of the main text as lists of rows of cell strings (the end-of-cell mark is removed). */
public static List<List<List<String>>> tables(InputStream in) throws IOException {
try (HWPFDocument doc = new HWPFDocument(in)) {
List<List<List<String>>> result = new ArrayList<>();
Range body = doc.getRange();
int i = 0;
while (i < body.numParagraphs()) {
Paragraph paragraph = body.getParagraph(i);
if (!paragraph.isInTable()) {
i++;
continue;
}
Table table = body.getTable(paragraph); // the whole table that contains this paragraph
List<List<String>> rows = new ArrayList<>();
for (int r = 0; r < table.numRows(); r++) {
TableRow row = table.getRow(r);
List<String> cells = new ArrayList<>();
for (int c = 0; c < row.numCells(); c++) {
cells.add(clean(row.getCell(c).text()));
}
rows.add(cells);
}
result.add(rows);
i += table.numParagraphs(); // skip the paragraphs of the table
}
return result;
}
}
/** Heading texts (built-in "heading N" styles) in document order. */
public static List<String> headings(InputStream in) throws IOException {
try (HWPFDocument doc = new HWPFDocument(in)) {
List<String> headings = new ArrayList<>();
Range range = doc.getRange();
for (int i = 0; i < range.numParagraphs(); i++) {
Paragraph p = range.getParagraph(i);
StyleDescription style = doc.getStyleSheet().getStyleDescription(p.getStyleIndex());
if (style != null && style.getName() != null
&& style.getName().toLowerCase(Locale.ROOT).startsWith("heading")) {
headings.add(clean(p.text()));
}
}
return headings;
}
}
/** All the text, paragraph by paragraph, including headers, footers and footnotes. */
public static String text(InputStream in) throws IOException {
try (HWPFDocument doc = new HWPFDocument(in); WordExtractor extractor = new WordExtractor(doc)) {
return extractor.getText();
}
}
private static String clean(String raw) {
return raw.replace("\u0007", "").replace("\r", "\n").strip();
}
}
The .doc reader was run on the book-request-form.doc written from the sample; the table read back is
[[[Title, Author, ISBN, Quantity], [The Dispossessed, Ursula K. Le Guin, 9780061054884, 2],
[The Left Hand of Darkness, , 9780441478125, 1], [Cosmos, Carl Sagan, 9780345539434, x]]]
The author of the second book is empty here because the conversion used to write the fixture did not carry the
vertical merge over; a Word 2003 document with a vertical merge needs the same continuation rule as in gridRow. The headings method was compiled and
run, but the .doc of the fixture was not written by Word and has no built-in heading styles, so it returned an empty
list there; test it with files from your real clients. Opening a .doc safely (the POIFSFileSystem with a read-only
file, the Word 6 and encrypted cases) is described in the HWPF page.
Source: HWPF quick guide and WordExtractor Javadoc.
One importer: detect, read, validate, report
The pieces are combined in one class. It does what the security page asks for the early stages: detect the format from
the content with FileMagic (a text file named .docx is rejected without reaching the parser), rely on the
ZipSecureFile limits that PoiLimits.apply() of the security page sets once at startup, open the package read-only
from a File, and then collect problems with a location
("control request.neededBy", "table row 4") instead of throwing on the first one. The user of a form wants the full list
to fix in one pass.
package demo;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.LocalDate;
import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import org.apache.poi.openxml4j.opc.OPCPackage;
import org.apache.poi.openxml4j.opc.PackageAccess;
import org.apache.poi.poifs.filesystem.FileMagic;
import org.apache.poi.xwpf.usermodel.XWPFDocument;
import org.apache.poi.xwpf.usermodel.XWPFTable;
/** Imports book-request-form.docx (or the legacy .doc) into a BookRequest, collecting every problem. */
public final class WordImporter {
public record BookLine(int row, String title, String author, String isbn, int quantity) {
}
public record Problem(String location, String message) {
}
public record BookRequest(String requester, String department, LocalDate neededBy, boolean needsApproval,
String justification, List<BookLine> lines) {
}
/** Either a request or the list of everything that is wrong with the file. */
public record Result(BookRequest request, List<Problem> problems) {
public boolean ok() {
return problems.isEmpty();
}
}
private WordImporter() {
}
public static Result read(Path upload) throws IOException {
try (InputStream in = new BufferedInputStream(Files.newInputStream(upload))) {
FileMagic magic = FileMagic.valueOf(in);
return switch (magic) {
case OOXML -> readDocx(upload);
case OLE2 -> readDoc(upload);
default -> new Result(null, List.of(new Problem("file", "Not a Word document: " + magic)));
};
}
}
static Result readDocx(Path file) throws IOException {
try (OPCPackage pkg = OPCPackage.open(file.toFile(), PackageAccess.READ);
XWPFDocument doc = new XWPFDocument(pkg)) { // pkg is closed even when the constructor throws
List<Problem> problems = new ArrayList<>();
Map<String, ContentControls.Control> form = ContentControls.byTag(ContentControls.read(doc));
String requester = required(form, "requester.name", problems);
String department = required(form, "requester.department", problems);
LocalDate neededBy = null;
ContentControls.Control date = form.get("request.neededBy");
if (date == null || date.placeholder() || date.value() == null) {
problems.add(new Problem("control request.neededBy", "A date is required"));
} else {
neededBy = LocalDate.parse(date.value());
if (neededBy.isBefore(LocalDate.now())) {
problems.add(new Problem("control request.neededBy", "The date is in the past"));
}
}
ContentControls.Control approval = form.get("request.approval");
boolean needsApproval = approval != null && Boolean.parseBoolean(approval.value());
String justification = required(form, "request.justification", problems);
List<BookLine> lines = new ArrayList<>();
List<XWPFTable> tables = doc.getTables();
if (tables.isEmpty()) {
problems.add(new Problem("document", "The table of requested books is missing"));
} else {
List<Map<String, String>> rows = DocxTables.toRows(tables.get(0));
for (int i = 0; i < rows.size(); i++) {
mapLine(rows.get(i), i + 2, lines, problems); // row 1 of the table is the header
}
}
BookRequest request = problems.isEmpty()
? new BookRequest(requester, department, neededBy, needsApproval, justification, lines) : null;
return new Result(request, problems);
} catch (org.apache.poi.openxml4j.exceptions.InvalidFormatException e) {
throw new IOException("Invalid .docx", e);
}
}
static Result readDoc(Path file) throws IOException {
List<Problem> problems = new ArrayList<>();
List<BookLine> lines = new ArrayList<>();
try (InputStream in = Files.newInputStream(file)) {
List<List<List<String>>> tables = LegacyDoc.tables(in);
if (tables.isEmpty()) {
problems.add(new Problem("document", "The table of requested books is missing"));
} else {
List<List<String>> grid = tables.get(0);
List<String> header = grid.get(0).stream().map(String::toLowerCase).toList();
for (int r = 1; r < grid.size(); r++) {
Map<String, String> row = new LinkedHashMap<>();
for (int c = 0; c < header.size() && c < grid.get(r).size(); c++) {
row.put(header.get(c), grid.get(r).get(c));
}
mapLine(row, r + 1, lines, problems);
}
}
}
return new Result(problems.isEmpty() ? new BookRequest(null, null, null, false, null, lines) : null, problems);
}
private static void mapLine(Map<String, String> row, int tableRow, List<BookLine> lines, List<Problem> problems) {
String where = "table row " + tableRow;
String title = row.getOrDefault("title", "");
String isbn = row.getOrDefault("isbn", "").replace("-", "");
int quantity = 0;
if (title.isBlank()) {
problems.add(new Problem(where, "Title is required"));
}
if (!isbn.matches("\\d{13}")) {
problems.add(new Problem(where, "ISBN must have 13 digits: '" + isbn + "'"));
}
try {
quantity = Integer.parseInt(row.getOrDefault("quantity", ""));
if (quantity < 1 || quantity > 20) {
problems.add(new Problem(where, "Quantity must be between 1 and 20"));
}
} catch (NumberFormatException e) {
problems.add(new Problem(where, "Quantity is not a number: '" + row.get("quantity") + "'"));
}
lines.add(new BookLine(tableRow, title, row.get("author"), isbn, quantity));
}
private static String required(Map<String, ContentControls.Control> form, String tag, List<Problem> problems) {
ContentControls.Control control = form.get(tag);
if (control == null) {
problems.add(new Problem("control " + tag, "The control is missing"));
return null;
}
if (control.placeholder() || control.text().isBlank()) {
problems.add(new Problem("control " + tag, "A value is required"));
return null;
}
return control.value();
}
}
A thin runner prints the result:
package demo;
import java.nio.file.Path;
public final class ImportDemo {
public static void main(String[] args) throws Exception {
WordImporter.Result result = WordImporter.read(Path.of(args[0]));
if (result.ok()) {
WordImporter.BookRequest request = result.request();
System.out.println("Accepted request from " + request.requester() + " with "
+ request.lines().size() + " books");
request.lines().forEach(line -> System.out.println(" " + line));
} else {
System.out.println("Rejected, " + result.problems().size() + " problem(s):");
result.problems().forEach(p -> System.out.println(" " + p.location() + ": " + p.message()));
}
}
}
Observed output, for the sample with a valid last quantity, for the sample as generated, for a text file renamed to
.docx, and for the .doc version:
Accepted request from Ana Pérez with 3 books
BookLine[row=2, title=The Dispossessed, author=Ursula K. Le Guin, isbn=9780061054884, quantity=2]
BookLine[row=3, title=The Left Hand of Darkness, author=Ursula K. Le Guin, isbn=9780441478125, quantity=1]
BookLine[row=4, title=Cosmos, author=Carl Sagan, isbn=9780345539434, quantity=3]
Rejected, 1 problem(s):
table row 4: Quantity is not a number: 'x'
Rejected, 1 problem(s):
file: Not a Word document: UNKNOWN
Rejected, 1 problem(s):
table row 4: Quantity is not a number: 'x'
The design decisions in the class, and what to change for a real application:
-
Collect, then decide.
Resultis either a request or a list of problems; nothing is persisted from a file that has any problem. For a partial import (accept the valid lines, reject the others) keep the valid `BookLine`s and return the problems next to them; the choice and the transaction boundaries are discussed in the Excel import page, they are the same for Word. -
Locations a person can use. The table row is the row number in the Word table (the header is row 1); a control is identified by its tag, which the template author chose. Page numbers are not available: Word computes the layout when it opens the file, and POI has no layout engine.
-
Validation is yours. Beans validation with Jakarta Validation (Jakarta Validation 3.1 with Hibernate Validator) fits after the mapping: build the
BookLineandBookRequestobjects with annotations (@NotBlank,@Pattern,@Min) and map eachConstraintViolationto aProblem. The hand-written checks above are the same rules without the dependency. -
Locale and formats. The value of a date control comes from the machine attribute
w:fullDate, not from the text that the user sees (15/11/2026in one locale,11/15/2026in another). A number typed in a table cell is text: parse it with an explicitNumberFormator a regular expression, never with the platform default. -
Idempotency. A form that is imported twice should not create two requests. Keep a hash of the file or a business key (for example requester and date) and refuse or update the duplicate.
Source: FileMagic Javadoc and ZipSecureFile Javadoc.
Security rules for Word uploads
Everything in Security & Untrusted Files applies to a Word upload; the points that are specific to reading Word forms are these:
| Rule | Why and how |
|---|---|
Validate before parsing |
Size limit in the multipart configuration, extension allow-list, |
Open read-only from a file |
|
Set the zip limits |
|
Never trust text from the document |
A tag, a cell or a heading is attacker-controlled text. Use parameterised statements for the database, escape it on output (HTML, CSV, logs) and treat any value that will be exported again as a formula-injection risk. |
Do not follow what the document points to |
Hyperlinks, linked pictures ( |
Do not open attachments |
An embedded file is an upload inside an upload. Store it as a blob or send it through the same validation pipeline; never write it under its own name, never execute it, never open it with the user’s privileges. |
Expect any |
Parsing hostile input can throw |
XML external entities |
POI refuses a DOCTYPE in OOXML parts since 5.1.0; the XPath queries above run on the already parsed document. If you parse an extracted XML part yourself, use |
Keep POI current |
Parser hardening arrives in new releases; a form importer that reads untrusted files should update promptly. |
Pitfalls
| Pitfall | What happens and what to do |
|---|---|
Reading labels instead of tags |
"Name:" followed by a value breaks when the label changes. Use a content control with a tag whenever you control the template. |
Text split across runs |
The text of a label or value may be several |
Merged cells |
A merged row has fewer `XWPFTableCell`s than the header; expand the grid before indexing by column. |
Empty-looking placeholders |
A control that was not filled in contains the placeholder instruction. Check |
Tracked changes and hidden text |
Deleted text of a tracked change is stored in |
Checkbox glyph instead of state |
The text of a checkbox control is a symbol (a ballot box). Use the |
Locale dependent dates |
Use |
|
The extension is decoration. |
Large documents |
XWPF loads the whole document as an object tree; there is no streaming reader for |
One document per thread |
|
What POI does not provide
-
No form model: there is no
getFields()that returns the content controls with their kinds and values. The XPath route above is the way, and its XML details are in ECMA-376. -
No layout: page numbers, the page of a table, whether a control is visible on the first page are not known to POI.
-
No
.docxdata binding, schema validation or "map this table to a class" feature; the mapping code is yours. -
No streaming reader for
.docx. -
HWPF has no content controls, a limited view of merged cells and no write support to build test fixtures; the
.docfixture of this page was produced by converting the.docx. -
Reading signed or encrypted documents needs extra steps: see Encryption, Signing & Protection (a password-protected
.docxis an OLE2 file that wraps the package, soFileMagicreportsOLE2for it).
Changes across versions
-
The code on this page uses the stable XWPF and HWPF APIs and runs unchanged on POI 5.x. Since 5.1.0 a DOCTYPE in an OOXML part is rejected, which closes the XXE route described above; since 5.4.0 duplicated zip entry names are rejected, which removes an ambiguity attack (both are detailed in the security page).
-
The
ZipSecureFileandIOUtilslimits are configured once byPoiLimits.apply()of the security page; their defaults have changed between releases, so set them explicitly instead of relying on them. -
POI 6.0 is announced, not released, as of 2026-10-09. Per the project site it will need JDK 17, remove deprecated APIs and further harden against zip bombs and path traversal; pin the version and re-run the tests of the importer when it ships. Nothing in the classes above uses a deprecated API.
Summary
-
Read a Word form with the structure that carries each kind of data: tables for records, heading styles for prose, content controls with tags for fields, package parts for pictures and attachments.
-
Resolve styles by name, expand merged cells, ignore placeholder text, use the machine value of dates, dropdowns and checkboxes.
-
Detect the format with
FileMagic, open read-only from a file, apply the zip limits, and collect every problem with a location instead of failing on the first. -
Treat every string, every file name and every attachment of the document as attacker-controlled.
-
For
.doc, useWordExtractorfor text and the paragraph-based table reader for tables, and ask for.docxwhenever you can.