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

flowchart LR A[Upload] --> B{FileMagic} B -->|OOXML| C[OPCPackage read-only] B -->|OLE2| D[HWPFDocument] B -->|other| X[Reject] C --> E[Content controls] C --> F[Tables] C --> G[Headings and paragraphs] C --> H[Pictures and attachments] D --> F E --> V[Validate and collect problems] F --> V G --> V H --> V V -->|no problems| P[Persist] V -->|problems| R[Report with locations]

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 w:sdt element with w:sdtPr/w:tag, inline in a paragraph, around a block, or around a table cell or row

XWPFSDT and XWPFSDTCell for text; the underlying XML (XmlObject.selectPath) for the kind, the stored value and the checkbox state

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 w:tbl with w:tr and w:tc elements

XWPFDocument.getTables, XWPFTable, XWPFTableRow, XWPFTableCell

Repeating lines with the same columns

Free text under a title (justification, notes)

w:p paragraphs with a w:pStyle of Heading1, Heading2 …​

XWPFParagraph.getStyle, XWPFStyles

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

XWPFParagraph.getText

Last resort: fragile, because Word may split the text in any number of runs

Pictures

/word/media/imageN.png parts, related from the document

getAllPackagePictures, XWPFPicture

Scanned signatures, covers, supporting evidence

Attached files

/word/embeddings/* parts (an OLE object or a packaged file)

OPCPackage.getPartsByName

A quote or a spreadsheet added through Insert > Object

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 Title)

Book request form

Heading 1 Requester

Name (text control, tag requester.name), Department (dropdown, requester.department), Needed by (date, request.neededBy), Needs approval (checkbox, request.approval), Priority (text control still showing its placeholder, request.priority)

Heading 1 Justification

A block-level rich text control request.justification with two paragraphs

Heading 1 Requested books

A table: Title, Author, ISBN, Quantity; three rows, the author cell of the second row merged downwards

Heading 1 Attachments

A cover picture with alt text and Microsoft_Excel_Sheet1.xlsx embedded in /word/embeddings

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 (doc.getTables().get(0))

Fine for a form you control; break when someone adds a table above.

By header row

Pick the table whose first row contains title and isbn. Robust to additions, and gives a clear error ("the table of requested books is missing") when none matches.

By the heading above it

Walk doc.getBodyElements() and remember the last heading before each XWPFTable; then ask for "the table under Requested books".

By table style or caption

XWPFTable.getStyleID(); useful when the template applies a dedicated style.

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

w:pStyle w:val="Heading1" in the paragraph; XWPFParagraph.getStyle() returns it

An internal key. Word writes Heading1 in an English-language document, but a template can rename or localise it.

Style name

w:style/w:name in /word/styles.xml; XWPFStyles.getStyle(id).getName()

The built-in names are heading 1 to heading 9 and Title in every language (the interface shows a localised label, the file keeps the built-in name).

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.

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

w:text

The text in w:sdtContent.

Rich text

(none)

The paragraphs and runs in w:sdtContent; no formatting restrictions.

Drop-down list, combo box

w:dropDownList, w:comboBox with w:listItem entries

w:sdtContent holds the display text; the entry whose w:displayText equals it gives the stored w:value. A combo box also accepts text that is not in the list.

Date picker

w:date with w:fullDate and w:dateFormat

w:fullDate is the machine value (ISO 8601); the content is the date formatted for the user. It is absent when no date was chosen.

Checkbox (Word 2010 and later)

w14:checkbox with w14:checked w14:val

w14:checked is 1 or 0 (or true and false); the content is the glyph shown, for example a ballot box.

Picture

w:picture

A drawing in the content; extract it as in the images section.

Building block, repeating section, citation, group

w:docPartObj, w15:repeatingSection, w:citation, w: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; byTag keeps 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:fldChar with w: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 …​)

/word/media/imageN.ext, related from document.xml

doc.getAllPackagePictures() (every image part), doc.getAllPictures() (the ones used in the body), XWPFRun.getEmbeddedPictures()

Picture with alt text and size

wp:inline or wp:anchor with pic:cNvPr descr

XWPFPicture.getDescription(), getPictureData(), getCTPicture()

Attached or embedded files and OLE objects

/word/embeddings/*.xlsx, .docx, oleObjectN.bin and others

doc.getPackage().getPartsByName(Pattern), PackagePart.getInputStream(), getContentType()

Linked (not embedded) files

A relationship with TargetMode="External"

PackageRelationship.getTargetURI(); never follow it on the server

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.

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

WordExtractor.getText()

The reliable part. Use it for search and for forms where the text is enough.

Tables

Range.getTable(paragraph), Table.numRows(), TableRow.getCell(i).text()

Cell text ends with the cell mark \u0007, which must be removed. Merges are not expanded for you.

Headings

doc.getStyleSheet().getStyleDescription(paragraph.getStyleIndex()).getName()

The built-in names are the same (heading 1).

Pictures

doc.getPicturesTable().getAllPictures()

Each Picture has getContent() and suggestFileExtension().

Form fields

Not exposed as a model

The classic Forms toolbar fields (FORMTEXT, FORMCHECKBOX) are field codes in the text; there is no equivalent of content controls. Ask the client for a .docx instead.

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.

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. Result is 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 BookLine and BookRequest objects with annotations (@NotBlank, @Pattern, @Min) and map each ConstraintViolation to a Problem. 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/2026 in one locale, 11/15/2026 in another). A number typed in a table cell is text: parse it with an explicit NumberFormat or 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.

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, FileMagic, and rejection of macro-enabled content types (.docm) before POI runs. The importer above does the FileMagic part; the rest is the UploadValidator of the security page.

Open read-only from a file

OPCPackage.open(File, PackageAccess.READ) does not create a temporary copy and cannot write back by accident. Copy the upload to a private temporary file first when it arrives as a stream.

Set the zip limits

ZipSecureFile ratio, entry size and entry count, set once at startup by PoiLimits.apply() of the security page. The limits are static and JVM-wide, so do not set them again in an importer: the last class to set them wins for every caller. A .docx is a zip: a small file can expand to gigabytes.

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 (TargetMode="External"), linked templates and w:altChunk parts can name URLs or files. Importing a document must not fetch or open any of them.

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 Throwable

Parsing hostile input can throw RuntimeException, StackOverflowError or OutOfMemoryError; catch at the boundary, use a timeout and, for high-risk services, a child process (SafeWorkbookReader and ChildProcessParser in the security page).

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 XMLHelper and never a default DocumentBuilderFactory.

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 XWPFRun`s; read `XWPFParagraph.getText() (or the control content), never a single run.

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 w:showingPlcHdr.

Tracked changes and hidden text

Deleted text of a tracked change is stored in w:delText, not in w:t, and hidden text (w:vanish) is still text of the document. Decide what "the content" means for your form and, if it matters, ask for a copy with the changes accepted. This page did not test these cases.

Checkbox glyph instead of state

The text of a checkbox control is a symbol (a ballot box). Use the w14:checked attribute, not the glyph.

Locale dependent dates

Use w:fullDate; do not parse the displayed text.

.doc renamed to .docx and the reverse

The extension is decoration. FileMagic decides, and the importer reports what it found.

Large documents

XWPF loads the whole document as an object tree; there is no streaming reader for .docx as there is for .xlsx (see Streaming Reads: Event & SAX APIs). Bound the upload size, and for very large inputs parse the XML parts with a StAX reader and the XMLHelper factories.

One document per thread

XWPFDocument and HWPFDocument are not thread safe. Open a document per request, never share one.

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 .docx data 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 .doc fixture of this page was produced by converting the .docx.

  • Reading signed or encrypted documents needs extra steps: see Encryption, Signing & Protection (a password-protected .docx is an OLE2 file that wraps the package, so FileMagic reports OLE2 for 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 ZipSecureFile and IOUtils limits are configured once by PoiLimits.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, use WordExtractor for text and the paragraph-based table reader for tables, and ask for .docx whenever you can.