Legacy .doc with HWPF
|
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; the 6.0
line is announced but has not been released), Java 21 and later, Spring Boot 4.x on Spring Framework 7 and Jakarta EE
11. It covers HWPF, the POI component for the binary Word 97-2003 format (.doc). Unlike the spreadsheet side, where
HSSF is a complete read and write implementation, HWPF is a read-mostly component: it extracts text and structure well,
edits existing text in place with limits, and cannot build a new document from scratch. This page is honest about
that. It shows what works (extraction, structure walking, placeholder replacement in a template, HTML conversion), what
is fragile, and the recommendation that follows: read .doc files when you must, but convert them to .docx as soon as
possible and do the authoring with XWPF, described in
Word XWPF Basics,
Word Tables, Lists & Images and
Word Templates & Placeholder Replacement.
The physical format is explained in File Formats: OOXML & OLE2
and the spreadsheet equivalent in Legacy .xls with HSSF.
The running scenario is the Bookshelf library: an archive of old loan letters (loan-letter.doc) that must be searched
and displayed, a legacy template that a department still maintains in Word 2003, and an upload endpoint that may receive
a book-request-form.doc from an old client.
HWPF status: what to expect
The Apache POI Word pages describe HWPF as still in early development, living in the scratchpad part of the project, with limited write support. Reading that statement at face value is the right approach: the project has not changed the status in many years, the maintainers' effort goes to the OOXML side, and the component is not thread safe. It still works well for the cases below and is worth using for them.
| Task | Support | Comment |
|---|---|---|
Extract the text of a document |
Good |
|
Walk paragraphs, character runs, sections and tables |
Good |
The |
Read embedded pictures |
Good |
|
Replace text in place ( |
Works in many cases |
|
Convert to HTML or XSL-FO |
Fair |
The |
Create a new |
Not supported |
There is no public way to create an empty document. A new document is made by modifying a prepared template. |
Insert tables, pictures, headers or styles into an existing document |
Very limited |
Rows and tables can be inserted in a few cases; pictures, section properties and styles cannot be authored reliably. |
Thread safety |
None |
A |
Convert to PDF directly |
Not supported |
Only through the XSL-FO converter and Apache FOP, as shown in a later section. |
The consequence is simple. Treat HWPF as a reader and, at most, a find-and-replace tool for a template that already exists. Everything that authors a document belongs in XWPF.
The jar and the dependency
HWPF is not in the main poi jar and not in poi-ooxml. It lives in poi-scratchpad, together with the other
components that read legacy binary formats of the Office suite, and it depends on poi for the OLE2 file system (POIFS)
and for the property sets. The .docx parser needs poi-ooxml and is unrelated: a project that handles both formats
declares both artifacts.
<dependencies>
<!-- OLE2 file system, shared by every legacy format -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi</artifactId>
<version>5.5.1</version>
</dependency>
<!-- HWPF (.doc), HSLF, HSMF and the other scratchpad components -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-scratchpad</artifactId>
<version>5.5.1</version>
</dependency>
<!-- Only if the same application also reads or writes .docx or .xlsx -->
<dependency>
<groupId>org.apache.poi</groupId>
<artifactId>poi-ooxml</artifactId>
<version>5.5.1</version>
</dependency>
<!-- Only for the XSL-FO to PDF path shown below -->
<dependency>
<groupId>org.apache.xmlgraphics</groupId>
<artifactId>fop</artifactId>
<version>2.11</version>
</dependency>
</dependencies>
The same dependencies with the Gradle Kotlin DSL:
dependencies {
implementation("org.apache.poi:poi:5.5.1")
implementation("org.apache.poi:poi-scratchpad:5.5.1")
implementation("org.apache.poi:poi-ooxml:5.5.1")
implementation("org.apache.xmlgraphics:fop:2.11")
}
Source: POI component map and
Apache FOP. Version numbers are the latest on Maven Central on 2026-10-09; keep
every org.apache.poi artifact on the same version. Setup details are in
Setup & Dependencies.
A frequent mistake is a NoClassDefFoundError for a class such as org.apache.poi.hwpf.HWPFDocument at runtime
when only poi-ooxml was declared: the scratchpad jar is not a transitive dependency of it.
The document model
HWPF follows the structure of the Word binary file. The text of the whole document is one long character stream
(the main text, then the footnotes, the headers and footers, the comments, the endnotes and the text boxes, each in its
own area). Formatting is stored separately as property tables that apply to character offsets of that stream. A
Range is a window on a span of that stream; everything the usermodel offers hangs off it.
The hierarchy has consequences that drive everything below.
-
Section,Paragraph,CharacterRun,Table,TableRowandTableCellare allRange. A paragraph is a range that happens to end at a paragraph mark, a run is a range with uniform character formatting. SoinsertBefore,insertAfter,deleteandreplaceTextare available on all of them. -
The document has several ranges, one per story.
getRange()is the main body,getHeaderStoryRange()is the area that holds every header and footer, andgetFootnoteRange(),getEndnoteRange(),getCommentsRange()andgetMainTextboxRange()hold the others.getOverallRange()spans all of them. -
Text uses Word control characters. A paragraph ends with
\r, a table cell with the bell character\u0007, a page break is\f, and fields appear as their code and result between marker characters.Range.stripFieldsremoves the field codes so that aPAGEorHYPERLINKfield does not leak into extracted text. -
There is no
getParagraphs()list. The API is index based:numParagraphs()andgetParagraph(i). Index loops are the idiom, and the index of a paragraph inside a table is the index in the enclosing range, not in the table.
The one-pointer rule
Because every Range is defined by character offsets into one shared stream, editing the document shifts the offsets
of everything after the edit. A Paragraph or CharacterRun obtained before an edit may therefore point at the wrong
text afterwards. The practical rule is to keep one pointer: do one structural edit, then discard every range you
hold and obtain a fresh one from doc.getRange() before the next edit. The documentation says that the edit methods
work in many but not all cases, and following this rule removes most of the cases that do not.
// Wrong: the second paragraph was obtained before the first edit shifted the offsets
Range body = doc.getRange();
Paragraph first = body.getParagraph(0);
Paragraph second = body.getParagraph(1);
first.insertBefore("Ref: 2026-0042\r");
second.replaceText("Dear reader", "Dear Ada"); // may hit the wrong offset
// Right: re-fetch after each edit
doc.getRange().getParagraph(0).insertBefore("Ref: 2026-0042\r");
doc.getRange().replaceText("Dear reader", "Dear Ada");
Source: HWPF Quick Guide, Changing Text and the Range Javadoc. The one-pointer rule is this page’s reading of how the offset model behaves; the project documents the limitation, not the rule.
Opening a .doc safely
A server rarely knows what a user uploaded. The safe sequence is to look at the magic bytes first, then open with the class that matches, and treat each failure as a distinct case.
| What arrives | How to recognise it | What to do |
|---|---|---|
Word 97-2003 |
|
Open with |
Word 2007 and later |
|
Open with XWPF. A renamed extension does not matter, the content does. |
Word 6 or Word 95 |
|
Extract text with |
Encrypted |
|
Provide the password through |
A different OLE2 document ( |
|
Reject as an unsupported or corrupt upload. |
Text, RTF or HTML named |
|
Reject, or route to an RTF or HTML pipeline. HWPF does not read them. |
The following class detects the format and opens the document. FileMagic.prepareToCheckMagic wraps the stream in a
buffered stream that supports mark and reset, so the first bytes are inspected without being lost.
package bookshelf.legacy;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.OldWordFileFormatException;
import org.apache.poi.poifs.filesystem.FileMagic;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public final class LegacyDocOpener {
public enum Kind { DOC, DOCX, OLD_WORD, UNSUPPORTED }
private LegacyDocOpener() {
}
/** Classifies the file by content, not by its name. */
public static Kind classify(Path file) throws IOException {
try (InputStream raw = Files.newInputStream(file);
InputStream in = FileMagic.prepareToCheckMagic(raw)) {
FileMagic magic = FileMagic.valueOf(in);
if (magic == FileMagic.OOXML) {
return Kind.DOCX;
}
if (magic != FileMagic.OLE2) {
return Kind.UNSUPPORTED;
}
}
// OLE2: try to open as Word 97-2003 and detect the older versions
try (POIFSFileSystem fs = new POIFSFileSystem(file.toFile(), true);
HWPFDocument ignored = new HWPFDocument(fs)) {
return Kind.DOC;
} catch (OldWordFileFormatException e) {
return Kind.OLD_WORD;
} catch (RuntimeException e) {
return Kind.UNSUPPORTED;
}
}
}
Two details deserve attention. The POIFSFileSystem(File, true) constructor opens the file read-only and memory-maps
it, which is cheaper than copying a file into the heap when a File is available. And HWPFDocument has constructors
for an InputStream, a POIFSFileSystem and a DirectoryNode, but no constructor that takes a File directly:
open the file system yourself, as above, and let try-with-resources close both.
Extracting text with WordExtractor
WordExtractor is the entry point for the most common requirement: get the readable text of a .doc, for search
indexing, a preview or a moderation check. It accepts an InputStream, a POIFSFileSystem, a DirectoryNode or an
already opened HWPFDocument, and it returns the text of every story separately.
| Method | Returns |
|---|---|
|
The whole main text as one string, paragraphs separated by line breaks, fields stripped. Use this for indexing. |
|
The main text as a |
|
The text of the headers and footers, all variants concatenated. |
|
A |
|
A |
|
A |
|
The raw text pieces of the file. Very fast, but it may return characters that are not part of the visible page.
The quick guide advises to use |
The following service extracts the text of every old loan letter, with the headers and the comments, and returns a small record. The extractor and the file system are closed by try-with-resources.
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Path;
import java.util.List;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.extractor.WordExtractor;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public class LoanLetterTextExtractor {
public record LetterText(String body, String header, String footer, List<String> comments) {
}
public LetterText extract(Path docFile) throws IOException {
try (POIFSFileSystem fs = new POIFSFileSystem(docFile.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs);
WordExtractor extractor = new WordExtractor(doc)) {
extractor.setCloseFilesystem(false); // the try-with-resources owns the file system
return new LetterText(
extractor.getText().strip(),
extractor.getHeaderText().strip(),
extractor.getFooterText().strip(),
List.of(extractor.getCommentsText()));
}
}
}
For a file Word 6 or Word 95 wrote, Word6Extractor has the same idea with a smaller surface, and it is the only
supported way to read those files:
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Path;
import org.apache.poi.hwpf.extractor.Word6Extractor;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public class OldWordTextExtractor {
public String extract(Path file) throws IOException {
try (POIFSFileSystem fs = new POIFSFileSystem(file.toFile(), true);
Word6Extractor extractor = new Word6Extractor(fs)) {
extractor.setCloseFilesystem(false);
return String.join("\n", extractor.getParagraphText()).strip();
}
}
}
Source: Word6Extractor Javadoc and POI Text Extraction.
If the application must extract text from many formats (.doc, .docx, PDF, spreadsheets) with one call, Apache Tika
wraps these POI extractors behind a single API and detects the type itself. It is referenced in the section
bibliography and it is the usual answer when the input format is not under your control.
Walking the structure: paragraphs, runs and tables
When the text is not enough, the usermodel gives access to the structure. The next class reads a legacy
book-request-form.doc, a Word form with a heading, a few paragraphs and one table of requested books, and turns it
into plain data. It shows the three loops every HWPF reader contains: paragraphs by index, character runs inside a
paragraph, and tables found through a paragraph.
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.usermodel.CharacterRun;
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.TableCell;
import org.apache.poi.hwpf.usermodel.TableRow;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public class BookRequestFormReader {
public record RequestedBook(String isbn, String title, int copies) {
}
public record Form(List<String> headings, List<String> paragraphs, List<RequestedBook> books) {
}
public Form read(Path docFile) throws IOException {
List<String> headings = new ArrayList<>();
List<String> paragraphs = new ArrayList<>();
List<RequestedBook> books = new ArrayList<>();
try (POIFSFileSystem fs = new POIFSFileSystem(docFile.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs)) {
Range body = doc.getRange();
int i = 0;
while (i < body.numParagraphs()) {
Paragraph paragraph = body.getParagraph(i);
if (paragraph.isInTable()) {
Table table = body.getTable(paragraph);
readBooks(table, books);
// the table is a range that includes its own paragraphs: skip them all
i += table.numParagraphs();
continue;
}
String text = clean(paragraph.text());
if (!text.isEmpty()) {
String style = doc.getStyleSheet()
.getStyleDescription(paragraph.getStyleIndex()).getName();
if (style.startsWith("Heading")) {
headings.add(text);
} else if (!allRunsBold(paragraph) || text.length() > 60) {
paragraphs.add(text);
} else {
headings.add(text); // a short, fully bold line used as a heading
}
}
i++;
}
}
return new Form(headings, paragraphs, books);
}
private void readBooks(Table table, List<RequestedBook> books) {
// row 0 is the header row
for (int r = 1; r < table.numRows(); r++) {
TableRow row = table.getRow(r);
if (row.numCells() < 3) {
continue;
}
books.add(new RequestedBook(
clean(row.getCell(0).text()),
clean(row.getCell(1).text()),
parseInt(clean(row.getCell(2).text()))));
}
}
private boolean allRunsBold(Paragraph paragraph) {
for (int c = 0; c < paragraph.numCharacterRuns(); c++) {
CharacterRun run = paragraph.getCharacterRun(c);
if (!run.isBold() && !clean(run.text()).isEmpty()) {
return false;
}
}
return true;
}
/** Removes the paragraph mark, the cell mark and surrounding spaces. */
private static String clean(String text) {
return text.replace("\r", "").replace("\u0007", "").replace("\f", "").strip();
}
private static int parseInt(String text) {
try {
return Integer.parseInt(text);
} catch (NumberFormatException e) {
return 0;
}
}
}
Points worth knowing about this code:
-
Range.getTable(paragraph)returns the table that contains the paragraph. ATableextendsRange, so itsnumParagraphs()is the count of paragraphs inside the table, including the paragraph that ends every cell and every row. That count is how the loop skips the table without reading its cells twice. -
Style names come from
doc.getStyleSheet(). The style index of a paragraph is an index into the document’s style table, not a stable constant, so compare names (Heading 1,Title), not indices. Documents written by non-English Word versions carry localised names for the built-in styles, which is a source of silent misses. -
Font sizes returned by
CharacterRun.getFontSize()are in half points. A value of24is 12 pt. -
Nested tables, merged cells and tables inside text boxes are the first places where the structure walk starts to disagree with what Word shows. Test with the real files of the real users.
Headers, footers, footnotes and comments
Headers and footers are not part of the main range. They live in the header story, and HeaderStories splits that
story into the odd, even and first-page variants of headers, footers and the footnote and endnote separators.
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Path;
import java.util.Objects;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.usermodel.HeaderStories;
import org.apache.poi.hwpf.usermodel.Range;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public class LoanLetterHeaderFooterReader {
public void print(Path docFile) throws IOException {
try (POIFSFileSystem fs = new POIFSFileSystem(docFile.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs)) {
HeaderStories stories = new HeaderStories(doc);
stories.setAreFieldsStripped(true); // drop PAGE and NUMPAGES field codes
System.out.println("Odd header : " + Objects.toString(stories.getOddHeader(), "").strip());
System.out.println("First header: " + Objects.toString(stories.getFirstHeader(), "").strip());
System.out.println("Odd footer : " + Objects.toString(stories.getOddFooter(), "").strip());
Range comments = doc.getCommentsRange();
for (int i = 0; i < comments.numParagraphs(); i++) {
System.out.println("Comment: " + comments.getParagraph(i).text().strip());
}
Range footnotes = doc.getFootnoteRange();
System.out.println("Footnote paragraphs: " + footnotes.numParagraphs());
}
}
}
The getters return null, not an empty string, when the document has no header story at all (a file produced by a
converter, for example), which is why the example wraps them in Objects.toString. HeaderStories has a getHeader(pageNumber) and getFooter(pageNumber) pair that picks the variant Word would use for
a given page, based on the odd/even and first-page flags. Comments carry no author through this range; the author
and date come from doc.getRevisionMarkAuthorTable() and the comment bookkeeping tables, which are more involved than
this page needs.
Reading pictures
Inline pictures are stored once in the data stream. PicturesTable lists them, and each Picture exposes the bytes, the
media type and a suggested file name. This is how a migration job saves the logo that sits in every old letter.
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.usermodel.Picture;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public class LoanLetterPictureExtractor {
/** Saves every picture of the document and returns the files written. */
public int extract(Path docFile, Path outputDirectory) throws IOException {
Files.createDirectories(outputDirectory);
try (POIFSFileSystem fs = new POIFSFileSystem(docFile.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs)) {
List<Picture> pictures = doc.getPicturesTable().getAllPictures();
int index = 0;
for (Picture picture : pictures) {
String name = "picture-" + (++index) + "." + picture.suggestFileExtension();
Files.write(outputDirectory.resolve(name), picture.getContent());
System.out.printf("%s %s %dx%d%n", name, picture.getMimeType(),
picture.getWidth(), picture.getHeight());
}
return pictures.size();
}
}
}
Source: Picture Javadoc and HWPFDocument Javadoc.
Floating pictures and drawn shapes (Office Art drawings) are separate: they are reachable through
doc.getOfficeDrawingsMain() and getOfficeDrawingsHeaders() and are not part of getAllPictures() in every case.
Never write an extracted file with a name taken from the document; use the generated name, as above.
Replacing text in a template
The one editing feature that is useful in production is replacing placeholders in a template that already exists. A
department keeps loan-letter-template.doc with the markers ${borrower}, ${title} and ${dueDate}; the server
opens it, replaces the markers and saves the result. This keeps the Word 2003 layout, header and logo that HWPF could
never create.
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Path;
import java.time.LocalDate;
import java.time.format.DateTimeFormatter;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.usermodel.Range;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
public class LoanLetterFiller {
private static final DateTimeFormatter DATE = DateTimeFormatter.ofPattern("d MMMM yyyy");
private static final Pattern PLACEHOLDER = Pattern.compile("\\$\\{[A-Za-z0-9_.]+}");
public void fill(Path template, Path output, String borrower, String title, LocalDate dueDate)
throws IOException {
Map<String, String> values = new LinkedHashMap<>();
values.put("${borrower}", borrower);
values.put("${title}", title);
values.put("${dueDate}", dueDate.format(DATE));
try (POIFSFileSystem fs = new POIFSFileSystem(template.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs)) {
replaceAll(doc, values);
doc.write(output.toFile());
}
}
/** Replaces the markers one occurrence at a time, left to right, never rescanning an inserted value. */
static void replaceAll(HWPFDocument doc, Map<String, String> values) {
int from = 0;
while (true) {
// one pointer: take a fresh range for every replacement
Range range = doc.getRange();
Matcher marker = PLACEHOLDER.matcher(range.text());
if (!marker.find(from)) {
return;
}
String value = values.get(marker.group());
if (value == null) {
from = marker.end(); // unknown marker: leave it
} else {
range.replaceText(marker.group(), value, marker.start());
from = marker.start() + value.length(); // continue after the value
}
}
}
}
Source: HWPF Quick Guide, Changing Text and the Range Javadoc.
What to know before relying on it:
-
replaceText(placeholder, value)replaces all occurrences in the range by searching again from the start until the placeholder is gone. A value that contains its own placeholder (a borrower who typed${borrower}) therefore never terminates, and a loop over the keys replaces a later key that an earlier value inserted. The code above uses the overloadreplaceText(placeholder, value, offset), which replaces the one occurrence at that offset of the range text, and moves past each inserted value, so values are never rescanned. Both overloads work on the text of the range and not on individual runs, so a marker that Word split in two runs (for example after a spell-check or a partial format) is usually still found, which is better than the equivalent problem in XWPF described in Word Templates & Placeholder Replacement. The formatting of the replacement follows the first character of the match; test the templates, because the documentation states that the change code does not work in every case. -
The replacement is plain text. Line breaks inside the value need the Word character,
\u000Bfor a soft line break and\rfor a new paragraph, and a\rin the middle of a table cell can corrupt the cell structure. -
The range for headers and footers is a different one. To fill a placeholder in a header call
doc.getHeaderStoryRange().replaceText(...)as well; the main range does not include it. -
Write with
write(File)when you have a file: the Javadoc notes it performs better thanwrite(OutputStream). AHWPFDocumentthat was opened from a stream cannot be written back to the same stream, so write to a new location. -
Do not reuse the template object. Every request opens the template again; the document is not thread safe and its state is changed by the replacement.
insertBefore(String) and insertAfter(String) add text next to a range and return the new CharacterRun, and
delete() removes the range. They are enough to add a reference line or strike a paragraph from a template, within the
one-pointer rule. They are not a substitute for authoring: they do not create new styles, new sections or new
pictures.
// Add a reference line at the top of the letter and remove the "draft" paragraph
Range body = doc.getRange();
body.getParagraph(0).insertBefore("Ref: BK-2026-0042\r");
body = doc.getRange(); // fresh pointer after the edit
for (int i = 0; i < body.numParagraphs(); i++) {
if (body.getParagraph(i).text().startsWith("DRAFT")) {
body.getParagraph(i).delete();
break;
}
}
Source: Range Javadoc.
For new templates, prefer .docx and the approach of
Word Templates & Placeholder Replacement.
The HWPF replacement above is a compatibility measure for a template that cannot be migrated yet.
What HWPF cannot do
The table lists the requests that come up and the honest answer, so a design review does not discover them late.
| Request | Answer | Alternative |
|---|---|---|
Build |
Not possible: no public API creates a document |
Generate |
Add a table, a picture or a header to an existing |
Not reliably |
Convert to |
Edit with full fidelity, round trip without loss |
Not guaranteed |
Treat |
Render a page, compute page numbers, produce a PDF directly |
Not possible |
Use the converters below with Apache FOP, or LibreOffice for a better fidelity. |
Read macros, track changes in detail or complex fields |
Partial |
Check the real files; extraction of tracked changes is limited. |
Multi-threaded access to one document |
Not supported |
Open one |
Streaming for huge files |
No event API exists for Word |
Limit upload sizes; see the security section. |
Content controls (the SDT form fields of Word 2007+) |
Do not exist in |
Legacy form fields are present as fields; for structured forms use |
The converter package
The org.apache.poi.hwpf.converter package translates a .doc into another representation. It is the only part of POI
that gets close to producing a visual output from a Word file, and the project documents it as converting Word files
(95-2007) to HTML or XSL-FO.
| Class | Output |
|---|---|
|
An HTML DOM. Paragraph styles become CSS classes, tables become |
|
An XSL-FO DOM that Apache FOP renders to PDF, PostScript or an image. |
|
Plain text, with more control over structure than |
Converting to HTML
The conversion builds a DOM, so a Transformer serializes it. A PicturesManager decides where to save each picture
and what URL goes in the img element. Without one, pictures are dropped.
package bookshelf.legacy;
import java.io.IOException;
import java.io.StringWriter;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.xml.XMLConstants;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.OutputKeys;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.converter.WordToHtmlConverter;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.w3c.dom.Document;
public class LoanLetterHtmlConverter {
public String toHtml(Path docFile, Path imageDirectory) throws IOException {
Files.createDirectories(imageDirectory);
try (POIFSFileSystem fs = new POIFSFileSystem(docFile.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs)) {
Document target = DocumentBuilderFactory.newInstance()
.newDocumentBuilder().newDocument();
WordToHtmlConverter converter = new WordToHtmlConverter(target);
converter.setPicturesManager((content, pictureType, suggestedName, widthInches, heightInches) -> {
try {
Files.write(imageDirectory.resolve(suggestedName), content);
} catch (IOException e) {
throw new IllegalStateException(e);
}
return "images/" + suggestedName;
});
converter.processDocument(doc);
return serialize(converter.getDocument());
} catch (ParserConfigurationException | TransformerException e) {
throw new IOException("Cannot convert " + docFile, e);
}
}
private static String serialize(Document html) throws TransformerException {
TransformerFactory factory = TransformerFactory.newInstance();
factory.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
factory.setAttribute(XMLConstants.ACCESS_EXTERNAL_STYLESHEET, "");
Transformer transformer = factory.newTransformer();
transformer.setOutputProperty(OutputKeys.METHOD, "html");
transformer.setOutputProperty(OutputKeys.ENCODING, "UTF-8");
StringWriter out = new StringWriter();
transformer.transform(new DOMSource(html), new StreamResult(out));
return out.toString();
}
}
Source: hwpf.converter package
Javadoc and WordToHtmlConverter
Javadoc; the pattern follows the converter classes' own main method.
Two cautions. The generated HTML is derived from an untrusted document: if it is shown to other users, pass it through an HTML sanitizer, because link targets and text come straight from the file. And the converter reproduces structure, not layout; expect wrong spacing and approximate fonts.
Converting to PDF through XSL-FO and Apache FOP
POI cannot write PDF, but WordToFoConverter produces XSL-FO, a formatting language that Apache FOP renders. This is
the only built-in path from .doc to PDF, and it needs the FOP dependency shown at the top of the page.
package bookshelf.legacy;
import java.io.BufferedOutputStream;
import java.io.File;
import java.io.IOException;
import java.io.OutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.transform.Result;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.sax.SAXResult;
import org.apache.fop.apps.Fop;
import org.apache.fop.apps.FopFactory;
import org.apache.poi.hwpf.HWPFDocument;
import org.apache.poi.hwpf.converter.WordToFoConverter;
import org.apache.poi.poifs.filesystem.POIFSFileSystem;
import org.apache.xmlgraphics.util.MimeConstants;
import org.w3c.dom.Document;
public class LoanLetterPdfConverter {
private final FopFactory fopFactory = FopFactory.newInstance(new File(".").toURI());
public void toPdf(Path docFile, Path pdfFile) throws Exception {
Document fo;
try (POIFSFileSystem fs = new POIFSFileSystem(docFile.toFile(), true);
HWPFDocument doc = new HWPFDocument(fs)) {
Document target = DocumentBuilderFactory.newInstance().newDocumentBuilder().newDocument();
WordToFoConverter converter = new WordToFoConverter(target);
converter.processDocument(doc);
fo = converter.getDocument();
}
try (OutputStream out = new BufferedOutputStream(Files.newOutputStream(pdfFile))) {
Fop fop = fopFactory.newFop(MimeConstants.MIME_PDF, out);
Transformer identity = TransformerFactory.newInstance().newTransformer();
Result result = new SAXResult(fop.getDefaultHandler());
identity.transform(new DOMSource(fo), result);
}
}
}
Source: Apache FOP and WordToFoConverter Javadoc.
Be realistic about the result. The chain is converter, then FOP, and each stage has its own limits; the PDF is
adequate for a text-and-table letter and visibly different from what Word prints for a complex one. Fonts must be
available to FOP, and an embedded font configuration is needed for non-Latin text. A FopFactory is thread safe and
expensive to create, so keep one for the application. When fidelity matters, a LibreOffice conversion (next section)
is the better choice, and the PDF libraries compared in the PDF Generation section of this site are the choice when
the PDF should be built from data and not converted from a Word file.
Converting .doc to .docx: the recommended strategy
Everything above argues for the same conclusion: once a file has been read, move it to .docx. The format is
supported by the active part of POI (XWPF), by every current Office suite and by the other tools of your stack, and
it can be authored, templated and streamed by the techniques of the other Word pages.
POI itself has no .doc to .docx converter. The options are:
| Option | Fidelity | Notes |
|---|---|---|
LibreOffice in headless mode |
Good for ordinary documents |
An external process, invoked per file or through a pool of long-lived instances. It is the pragmatic answer for a migration job. |
Microsoft Word automation |
Best |
Needs Windows and an Office licence; not an option on a Linux server. |
A commercial library |
Varies |
Check licence and support; evaluate with your real files. |
Extract with HWPF and rebuild with XWPF |
Low |
Only for very regular documents, such as letters that contain text, one table and one logo. You write the mapping. |
A migration job usually calls LibreOffice once per file:
$ soffice --headless --convert-to docx:"MS Word 2007 XML" --outdir converted archive/old-loan-letters/loan-letter.doc
convert archive/old-loan-letters/loan-letter.doc -> converted/loan-letter.docx using filter : MS Word 2007 XML
$ ls converted
loan-letter.docx
Source: LibreOffice command-line parameters, documented in the LibreOffice help; this is an external tool and is not part of POI. Run it with a timeout, a private profile directory per worker, and no network, since converting an untrusted file is parsing an untrusted file.
The decision of which route to take for each uploaded file can be centralised in a small service. It uses the
classification of the first section: a .docx goes to XWPF, a .doc is read with HWPF, and the original is stored for
the batch converter.
package bookshelf.legacy;
import java.io.IOException;
import java.nio.file.Path;
import org.springframework.stereotype.Service;
@Service
public class WordUploadRouter {
private final LoanLetterTextExtractor docExtractor = new LoanLetterTextExtractor();
private final OldWordTextExtractor oldWordExtractor = new OldWordTextExtractor();
/** Returns the plain text of any supported Word upload. */
public String textOf(Path upload) throws IOException {
return switch (LegacyDocOpener.classify(upload)) {
case DOC -> docExtractor.extract(upload).body();
case OLD_WORD -> oldWordExtractor.extract(upload);
case DOCX -> throw new UnsupportedOperationException(
"Route .docx to the XWPF reader, see word-xwpf-basics");
case UNSUPPORTED -> throw new IllegalArgumentException("Not a Word document");
};
}
}
Source: POI Word components and the Spring Framework reference for multipart handling; the upload endpoint that feeds this service is the subject of the import pages of this section.
Security and robustness
A legacy binary file is a complex structure produced by software that may be hostile or broken, and HWPF parses it in the JVM of your server.
-
Cap the size. There is no streaming API for Word, so the whole file is parsed in memory. Enforce an upload limit before the file reaches POI.
-
Cap the record sizes. HWPF reads length-prefixed structures.
HWPFDocument.setMaxRecordLength(int)and theIOUtils.setByteArrayMaxOverridesetting reject records that claim to be larger than a sane value, which prevents a small hostile file from forcing a huge allocation. Keep the defaults unless a real file needs more. -
Catch what the parser throws. Corrupt files raise
IOException,IllegalArgumentException,ArrayIndexOutOfBoundsExceptionand similar runtime exceptions, not onlyIOException. Convert them to a 400-class response and never expose the exception message. -
Time-box the work. There is no cancellation inside a parse. Run conversions on a bounded executor, and run LibreOffice with an operating-system timeout.
-
Do not trust names inside the file. Saved picture names, bookmark names and fields come from the document.
-
Passwords. A password-protected
.doccan be opened by settingBiff8EncryptionKey.setCurrentUserPassword(password)before opening, which is a thread-local setting; always clear it in afinallyblock, and read the password from configuration or from the request, never from source code. -
Never evaluate macros. HWPF does not run VBA, and nothing in the described code should. Do not forward a legacy document to other users without scanning it.
Source: POI security, POI configuration and POI encryption. The cross-cutting limits and the upload checklist are covered with the other security topics of this section.
Common pitfalls
| Symptom | Cause and fix |
|---|---|
|
The |
|
The file is Word 6 or Word 95. Use |
Text ends with strange characters such as |
Cell marks and fields are part of the text. Strip them, or use |
A replacement hits the wrong place or corrupts the file |
A stale range was used after an edit. Re-fetch from |
Filling a template never returns, or a value shows another field’s value |
|
The saved document opens in Word with a repair message |
The edit exceeded what HWPF can do (table surgery, style changes). Edit less, or move to |
|
The document was opened from a stream. Write to a different file. |
Bold is detected on only some characters of a paragraph |
Formatting is per run. Check every |
Different results from two threads on the same document |
HWPF is not thread safe. One document per thread. |
Pictures are missing from the HTML |
No |
Versions and changes
| Version | Change for HWPF users |
|---|---|
3.x to 4.x |
|
4.x to 5.x |
The project switched to log4j-api for logging (5.1.0), with no change to the HWPF API. Parser hardening continues in every release; some formerly accepted but malformed documents now fail with an exception. |
5.5.1 |
The latest release on 2026-10-09. HWPF is in |
6.0 (planned, not released as of 2026-10-09) |
Announced: JDK 17 as the minimum and removal of deprecated APIs. No new HWPF authoring features are announced. Code
that avoids deprecated methods and does not depend on |
Summary
-
Use HWPF to read:
WordExtractorfor text, theRangehierarchy for structure,PicturesTablefor pictures. -
Use HWPF to fill an existing template with
replaceText, re-fetching the range after each edit, and write to a new file. -
Do not expect to create or restyle a
.doc; there is no API for it, and the project describes the component as early development. -
Use the converters for a best-effort HTML or XSL-FO view, and Apache FOP when a PDF is needed from a simple document.
-
Plan the exit: convert
.docfiles to.docx(LibreOffice is the pragmatic tool) and do all authoring with XWPF.
References
-
Word components (HWPF and XWPF): https://poi.apache.org/components/document/
-
HWPF Quick Guide: https://poi.apache.org/components/document/quick-guide.html
-
HWPF overview: https://poi.apache.org/components/document/docoverview.html
-
POI component map: https://poi.apache.org/components/index.html
-
Javadoc,
HWPFDocument: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/HWPFDocument.html -
Javadoc,
Range: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/usermodel/Range.html -
Javadoc,
HeaderStories: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/usermodel/HeaderStories.html -
Javadoc,
Picture: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/usermodel/Picture.html -
Javadoc,
WordExtractor: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/extractor/WordExtractor.html -
Javadoc,
Word6Extractor: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/extractor/Word6Extractor.html -
Javadoc,
hwpf.converterpackage: https://poi.apache.org/apidocs/dev/org/apache/poi/hwpf/converter/package-summary.html -
Javadoc,
FileMagic: https://poi.apache.org/apidocs/dev/org/apache/poi/poifs/filesystem/FileMagic.html -
Text extraction: https://poi.apache.org/text-extraction.html
-
Encryption: https://poi.apache.org/encryption.html
-
Security: https://poi.apache.org/security.html
-
Configuration: https://poi.apache.org/components/configuration.html
-
Apache FOP: https://xmlgraphics.apache.org/fop/
-
Apache Tika: https://tika.apache.org/
-
Spring multipart handling: https://docs.spring.io/spring-framework/reference/web/webmvc/mvc-controller/ann-methods/multipart-forms.html
-
Official examples: https://github.com/apache/poi/tree/trunk/poi-examples/src/main/java/org/apache/poi/examples/
-
Release changes: https://poi.apache.org/changes.html