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

WordExtractor returns paragraphs, headers, footers, footnotes, endnotes, comments and text boxes. It is the most used and the most reliable part of HWPF.

Walk paragraphs, character runs, sections and tables

Good

The Range hierarchy exposes text and the common formatting properties (style, bold, italic, font, size, colour).

Read embedded pictures

Good

PicturesTable returns the bytes and the media type of inline pictures.

Replace text in place (${placeholders} of a template)

Works in many cases

Range.replaceText and insertBefore / insertAfter / delete. The documentation itself says this works in many, but not all cases.

Convert to HTML or XSL-FO

Fair

The hwpf.converter package. Output quality is that of a best-effort converter: simple documents are fine, complex layouts are not.

Create a new .doc from scratch

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 HWPFDocument and every Range taken from it must stay inside one thread.

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.

classDiagram class HWPFDocument { getRange() getOverallRange() getHeaderStoryRange() getFootnoteRange() getCommentsRange() getPicturesTable() write(File) } class Range { text() numParagraphs() numCharacterRuns() numSections() replaceText() insertBefore() insertAfter() delete() } class Section class Paragraph { isInTable() getStyleIndex() } class CharacterRun { isBold() getFontName() getFontSize() } class Table { numRows() getRow() } class TableRow class TableCell HWPFDocument --> Range : creates Range <|-- Section Range <|-- Paragraph Range <|-- CharacterRun Range <|-- Table Range <|-- TableRow TableRow <|-- TableCell Table "1" o-- "*" TableRow TableRow "1" o-- "*" TableCell

The hierarchy has consequences that drive everything below.

  • Section, Paragraph, CharacterRun, Table, TableRow and TableCell are all Range. A paragraph is a range that happens to end at a paragraph mark, a run is a range with uniform character formatting. So insertBefore, insertAfter, delete and replaceText are 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, and getFootnoteRange(), getEndnoteRange(), getCommentsRange() and getMainTextboxRange() 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.stripFields removes the field codes so that a PAGE or HYPERLINK field does not leak into extracted text.

  • There is no getParagraphs() list. The API is index based: numParagraphs() and getParagraph(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 .doc

FileMagic.OLE2, and a WordDocument stream inside the compound file

Open with HWPFDocument.

Word 2007 and later .docx

FileMagic.OOXML

Open with XWPF. A renamed extension does not matter, the content does.

Word 6 or Word 95 .doc

FileMagic.OLE2 but HWPFDocument throws OldWordFileFormatException

Extract text with Word6Extractor. There is no usermodel for this version.

Encrypted .doc

EncryptedDocumentException on open

Provide the password through Biff8EncryptionKey, or reject the file; see the security section.

A different OLE2 document (.xls, .ppt) with a .doc name

FileMagic.OLE2 but no WordDocument stream, which raises an IOException or IllegalArgumentException

Reject as an unsupported or corrupt upload.

Text, RTF or HTML named .doc

FileMagic.RTF, FileMagic.HTML or UNKNOWN

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

getText()

The whole main text as one string, paragraphs separated by line breaks, fields stripped. Use this for indexing.

getParagraphText()

The main text as a String[], one entry per paragraph. Entries keep the paragraph mark; trim as needed.

getHeaderText() / getFooterText()

The text of the headers and footers, all variants concatenated.

getFootnoteText() / getEndnoteText()

A String[] with one entry per note.

getCommentsText()

A String[] with one entry per comment.

getMainTextboxText()

A String[] with the text of the text boxes in the main story.

getTextFromPieces()

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 getText() or getParagraphText() unless speed is critical.

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();
        }
    }
}

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. A Table extends Range, so its numParagraphs() 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 of 24 is 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();
        }
    }
}

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
            }
        }
    }
}

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 overload replaceText(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, \u000B for a soft line break and \r for a new paragraph, and a \r in 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 than write(OutputStream). A HWPFDocument that 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 monthly-report.doc from code

Not possible: no public API creates a document

Generate monthly-report.docx with XWPF, see Word XWPF Basics.

Add a table, a picture or a header to an existing .doc

Not reliably

Convert to .docx first, or fill a template that already contains them.

Edit with full fidelity, round trip without loss

Not guaranteed

Treat .doc as input only; keep the original and write a new file.

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 HWPFDocument per task.

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 .doc

Legacy form fields are present as fields; for structured forms use .docx content controls, see Word Templates & Placeholder Replacement and Importing Data from Word.

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

WordToHtmlConverter

An HTML DOM. Paragraph styles become CSS classes, tables become table elements, pictures go through a PicturesManager.

WordToFoConverter

An XSL-FO DOM that Apache FOP renders to PDF, PostScript or an image.

WordToTextConverter

Plain text, with more control over structure than WordExtractor (it keeps table layout).

flowchart LR A["loan-letter.doc"] --> B["HWPFDocument"] B --> C["WordToHtmlConverter"] B --> D["WordToFoConverter"] B --> E["WordToTextConverter"] C --> F["HTML DOM"] --> G["Transformer"] --> H["letter.html + images"] D --> I["XSL-FO DOM"] --> J["Apache FOP"] --> K["letter.pdf"] E --> L["plain text"]

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);
        }
    }
}

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.

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 the IOUtils.setByteArrayMaxOverride setting 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, ArrayIndexOutOfBoundsException and similar runtime exceptions, not only IOException. 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 .doc can be opened by setting Biff8EncryptionKey.setCurrentUserPassword(password) before opening, which is a thread-local setting; always clear it in a finally block, 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

NoClassDefFoundError: org/apache/poi/hwpf/HWPFDocument

The poi-scratchpad jar is missing. Add it; poi-ooxml does not bring it.

OldWordFileFormatException or UnsupportedOperationException on open

The file is Word 6 or Word 95. Use Word6Extractor, or convert the file.

Text ends with strange characters such as \u0007 or field codes

Cell marks and fields are part of the text. Strip them, or use getText() of WordExtractor.

A replacement hits the wrong place or corrupts the file

A stale range was used after an edit. Re-fetch from doc.getRange() after every change.

Filling a template never returns, or a value shows another field’s value

replaceText(placeholder, value) loops until the placeholder is gone, so a value containing ${...} hangs it or is replaced again. Replace one occurrence at a time with the offset overload and continue after the value, as LoanLetterFiller does.

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 .docx.

IOException: Cannot write to the same stream

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 CharacterRun as in allRunsBold.

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 PicturesManager was set on the converter.

Versions and changes

Version Change for HWPF users

3.x to 4.x

HWPFDocument and the converters keep their API. Stricter limits against malformed files start to appear.

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 poi-scratchpad and is maintained, mostly for bug fixes.

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 Range internals is the best preparation; re-check the change log when 6.0 ships.

Summary

  • Use HWPF to read: WordExtractor for text, the Range hierarchy for structure, PicturesTable for 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 .doc files to .docx (LibreOffice is the pragmatic tool) and do all authoring with XWPF.

References