DEV Community

Cover image for Why Exporting Word (.docx) to PDF Strips Form Fields (And How AcroForm Widget Reconstruction Works)
Maciej S.
Maciej S.

Posted on Fully Autonomous

Why Exporting Word (.docx) to PDF Strips Form Fields (And How AcroForm Widget Reconstruction Works)

If you build an intake form, contract template, or HR onboarding sheet in Microsoft Word using Developer → Content Controls (Plain Text, Check Box, Combo Box, or Date Picker) and export it via File → Save As → PDF or Microsoft Print to PDF, the resulting PDF looks right on screen—until someone tries to type into it.

Every text box, checkbox, and underline is dead geometry. If you inspect the PDF's root /Catalog dictionary, /AcroForm is completely absent.

This post explains what actually happens inside the OOXML-to-PDF conversion pipeline, how ISO 32000-1 interactive form dictionaries (/AcroForm and /Subtype /Widget annotations) differ from page content streams, and how to programmatically reconstruct interactive fields from a flattened Word PDF using Python (pypdf) or browser-based layout tools.


1. What Word Stores in .docx vs. What Lands in the PDF Stream

A .docx archive is a ZIP container of Open Packaging Conventions (OPC) XML files. When you insert modern Content Controls in Word, they are stored inside word/document.xml as <w:sdt> (Structured Document Tag) elements:

<!-- word/document.xml inside the .docx ZIP archive -->
<w:sdt>
  <w:sdtPr>
    <w:alias w:val="Applicant Full Name"/>
    <w:tag w:val="applicant_full_name"/>
    <w:id w:val="104829104"/>
    <w:text/>
  </w:sdtPr>
  <w:sdtContent>
    <w:p>
      <w:r>
        <w:t>_________________________</w:t>
      </w:r>
    </w:p>
  </w:sdtContent>
</w:sdt>

<w:sdt>
  <w:sdtPr>
    <w:alias w:val="US Citizen"/>
    <w:tag w:val="is_us_citizen"/>
    <w:lcid w:val="0409"/>
    <w14:checkbox>
      <w14:checked w14:val="0"/>
      <w14:checkedState w14:val="2612" w14:font="MS Gothic"/>
      <w14:uncheckedState w14:val="2610" w14:font="MS Gothic"/>
    </w14:checkbox>
  </w:sdtPr>
  <w:sdtContent>
    <w:r>
      <w:t>☐</w:t>
    </w:r>
  </w:sdtContent>
</w:sdt>
Enter fullscreen mode Exit fullscreen mode

Notice what happens during Word's native PDF export:

  1. Print-driver architecture: Microsoft Print to PDF routes the document through the Windows XPS/GDI print subsystem, which only understands visual glyph runs, vector paths, and raster bitmaps. Semantic tags like <w:sdtPr> and <w:tag> are discarded before the PDF writer ever sees the page.
  2. Built-in Exporter (Save As PDF): Even when "Document structure tags for accessibility" is enabled, Word maps <w:sdtContent> into the /StructTreeRoot tagged-PDF tree as standard <P> or <Span> nodes—not interactive /Annot objects.
  3. Visual fallback: The placeholder underscores (_____) or Unicode ballot box (U+2610 ☐) inside <w:sdtContent> are emitted directly into the page's /Contents stream as raw PostScript-style drawing operators:
BT
  /F1 11 Tf
  72.00 684.50 Td
  (Applicant Full Name: _________________________) Tj
ET
Enter fullscreen mode Exit fullscreen mode

At that point, the PDF viewer only sees static text operators (BT ... Tj ET) or vector line segments (m, l, S). There is nothing in the PDF object graph for a user to focus or type into.


2. Anatomy of a Real Fillable PDF (/AcroForm + /Widget Annotations)

In ISO 32000-1 (PDF 1.7, §12.7), interactive form fields do not live inside the page's /Contents drawing stream. Instead, they require two linked structures in the PDF cross-reference table:

  1. The Document-Level /AcroForm Dictionary (attached to the /Catalog root object):
    • /Fields: An array of indirect object references ([ 15 0 R 16 0 R ... ]) pointing to the root fields of the document.
    • /NeedAppearances: A boolean (true) instructing the viewer to regenerate visual glyph streams when field values change, or /DR (Default Resources) providing /Font dictionaries (/Helv, /ZaDb) for appearance generation.
  2. Page-Level /Subtype /Widget Annotations (referenced in each page's /Annots array):
    • In most single-widget AcroForm fields, the field dictionary and the widget annotation dictionary are merged into a single PDF object.

Here is what a valid interactive text field and checkbox actually look like in raw PDF syntax:

% Root Catalog
1 0 obj
<<
  /Type /Catalog
  /Pages 2 0 R
  /AcroForm <<
    /Fields [ 15 0 R 16 0 R ]
    /NeedAppearances true
    /DA (/Helv 10 Tf 0 g)
    /DR <<
      /Font <<
        /Helv << /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>
        /ZaDb << /Type /Font /Subtype /Type1 /BaseFont /ZapfDingbats >>
      >>
    >>
  >>
>>
endobj

% Merged Field + Widget Annotation for a Text Input
15 0 obj
<<
  /Type /Annot
  /Subtype /Widget
  /FT /Tx
  /P 3 0 R
  /T (applicant_full_name)
  /TU (Applicant Full Name)
  /V ()
  /DV ()
  /Ff 0
  /Rect [ 185.0 682.0 410.0 698.0 ]
  /DA (/Helv 10 Tf 0 0 0 rg)
>>
endobj

% Merged Field + Widget Annotation for a Checkbox
16 0 obj
<<
  /Type /Annot
  /Subtype /Widget
  /FT /Btn
  /P 3 0 R
  /T (is_us_citizen)
  /TU (US Citizen)
  /V /Off
  /AS /Off
  /Ff 0
  /Rect [ 185.0 654.0 197.0 666.0 ]
  /DA (/ZaDb 10 Tf 0 g)
>>
endobj
Enter fullscreen mode Exit fullscreen mode

Key Dictionary Keys Engineers Need to Know

Key Type Meaning in ISO 32000-1 §12.7
/FT Name Field Type: /Tx (Text), /Btn (Button: checkbox, radio, pushbutton), /Ch (Choice: dropdown/listbox), /Sig (Signature).
/T String Partial field name (applicant_full_name). Period-delimited names (applicant.name) are formed by /Parent / /Kids hierarchies.
/TU String Alternate field name displayed as a tooltip and announced by screen readers.
/Rect Array [llx lly urx ury] in default user space units (1/72 inch points), with origin (0, 0) at the bottom-left corner of the page /MediaBox.
/Ff Integer 32-bit field flags bitmask. Bit 1 (1): ReadOnly; Bit 2 (2): Required; Bit 13 (4096): Multiline text; Bit 16 (32768): Radio button; Bit 17 (65536): Pushbutton.
/V & /AS Any / Name /V holds the field's value; /AS holds the active Appearance State name (e.g., /Yes or /Off for checkboxes) selecting which stream in /AP << /N << ... >> >> to render.

If you want to quickly verify whether a PDF exported from Word or LibreOffice actually contains /AcroForm entries or was flattened into static vectors, you can inspect PDF AcroForm fields in the browser to view its /Fields count, field types, and names without uploading it to an external server.


3. Coordinate Space Mismatch: DOM/Word Top-Left vs. PDF Bottom-Left

When reconstructing widgets on top of a flattened Word PDF, the most common bug is vertical inversion.

  • Word / HTML Canvas / Browser DOM: Origin (0, 0) is at the top-left of the page. A box at (x, y) with width w and height h extends downward.
  • PDF User Space (/MediaBox): Origin (0, 0) is at the bottom-left of the page (unless /Rotate is 90, 180, or 270). A standard US Letter page has /MediaBox [0 0 612 792].

To convert a bounding box detected in top-left screen coordinates (x_top, y_top, width, height) (measured at 72 DPI scale) into a PDF /Rect [llx, lly, urx, ury]:

def dom_box_to_pdf_rect(
    x_top: float,
    y_top: float,
    width: float,
    height: float,
    page_height: float = 792.0,
) -> list[float]:
    """
    Convert a top-left origin bounding box (x, y, w, h) into an ISO 32000-1
    bottom-left origin PDF /Rect [llx, lly, urx, ury].
    """
    llx = round(x_top, 2)
    ury = round(page_height - y_top, 2)
    urx = round(x_top + width, 2)
    lly = round(page_height - (y_top + height), 2)
    return [llx, lly, urx, ury]
Enter fullscreen mode Exit fullscreen mode

If the page has a non-zero /Rotate entry in its page dictionary (common when scanned pages or landscape tables are merged into a Word export), you must also apply the inverse rotation matrix before writing /Rect, or set the annotation's /MK << /R 90 >> appearance characteristics dictionary so the text baseline aligns with the visual text run.


4. Reconstructing AcroForm Widgets in Python (pypdf)

Suppose you have a flattened PDF exported from Word (onboarding_flattened.pdf) and a JSON field manifest defining where the interactive widgets belong—either extracted from the .docx layout, detected via OCR/line-segment heuristics, or mapped once in a visual editor.

Here is a sample fields_manifest.json:

[
  {
    "page": 0,
    "name": "applicant_full_name",
    "tooltip": "Applicant Full Name",
    "type": "text",
    "multiline": false,
    "rect_top_left": { "x": 185.0, "y": 94.0, "width": 225.0, "height": 16.0 }
  },
  {
    "page": 0,
    "name": "employment_history_summary",
    "tooltip": "Employment History Summary (Past 5 Years)",
    "type": "text",
    "multiline": true,
    "rect_top_left": { "x": 72.0, "y": 140.0, "width": 468.0, "height": 64.0 }
  },
  {
    "page": 0,
    "name": "is_us_citizen",
    "tooltip": "U.S. Citizen",
    "type": "checkbox",
    "rect_top_left": { "x": 185.0, "y": 126.0, "width": 12.0, "height": 12.0 }
  }
]
Enter fullscreen mode Exit fullscreen mode

And here is a complete, dependency-light Python script using pypdf that constructs the /AcroForm dictionary, registers default font resources (/Helv and /ZaDb), creates /Subtype /Widget annotations with proper /Ff bitmasks, and attaches them to both the page /Annots array and /Catalog/AcroForm/Fields:

#!/usr/bin/env python3
"""
reconstruct_acroform.py
Inject interactive AcroForm /Widget annotations into a flattened Word-exported PDF.
Requires: pip install pypdf
"""

import json
import sys
from pathlib import Path
from pypdf import PdfReader, PdfWriter
from pypdf.generic import (
    ArrayObject,
    BooleanObject,
    DictionaryObject,
    FloatObject,
    NameObject,
    NumberObject,
    TextStringObject,
)

FF_MULTILINE = 1 << 12  # Bit 13 (4096) in 1-indexed ISO 32000-1 spec


def ensure_acroform(writer: PdfWriter) -> DictionaryObject:
    root = writer._root_object
    if NameObject("/AcroForm") not in root:
        helv_font = DictionaryObject({
            NameObject("/Type"): NameObject("/Font"),
            NameObject("/Subtype"): NameObject("/Type1"),
            NameObject("/BaseFont"): NameObject("/Helvetica"),
        })
        zadb_font = DictionaryObject({
            NameObject("/Type"): NameObject("/Font"),
            NameObject("/Subtype"): NameObject("/Type1"),
            NameObject("/BaseFont"): NameObject("/ZapfDingbats"),
        })
        acroform = DictionaryObject({
            NameObject("/Fields"): ArrayObject(),
            NameObject("/NeedAppearances"): BooleanObject(True),
            NameObject("/DA"): TextStringObject("/Helv 10 Tf 0 g"),
            NameObject("/DR"): DictionaryObject({
                NameObject("/Font"): DictionaryObject({
                    NameObject("/Helv"): writer._add_object(helv_font),
                    NameObject("/ZaDb"): writer._add_object(zadb_font),
                })
            }),
        })
        root[NameObject("/AcroForm")] = writer._add_object(acroform)
    return root[NameObject("/AcroForm")].get_object()


def add_widget(writer: PdfWriter, spec: dict) -> None:
    acroform = ensure_acroform(writer)
    page = writer.pages[spec["page"]]
    page_height = float(page.mediabox.height)

    box = spec["rect_top_left"]
    llx = box["x"]
    ury = page_height - box["y"]
    urx = box["x"] + box["width"]
    lly = page_height - (box["y"] + box["height"])

    rect = ArrayObject([
        FloatObject(round(llx, 2)),
        FloatObject(round(lly, 2)),
        FloatObject(round(urx, 2)),
        FloatObject(round(ury, 2)),
    ])

    is_checkbox = spec.get("type") == "checkbox"
    ff_flags = FF_MULTILINE if spec.get("multiline") else 0

    widget = DictionaryObject({
        NameObject("/Type"): NameObject("/Annot"),
        NameObject("/Subtype"): NameObject("/Widget"),
        NameObject("/FT"): NameObject("/Btn" if is_checkbox else "/Tx"),
        NameObject("/P"): page.indirect_reference,
        NameObject("/T"): TextStringObject(spec["name"]),
        NameObject("/TU"): TextStringObject(spec.get("tooltip", spec["name"])),
        NameObject("/Rect"): rect,
        NameObject("/F"): NumberObject(4),  # Print flag (bit 3) so filled values print
        NameObject("/Ff"): NumberObject(ff_flags),
        NameObject("/DA"): TextStringObject(
            "/ZaDb 10 Tf 0 g" if is_checkbox else "/Helv 10 Tf 0 0 0 rg"
        ),
    })

    if is_checkbox:
        widget[NameObject("/V")] = NameObject("/Off")
        widget[NameObject("/AS")] = NameObject("/Off")
    else:
        widget[NameObject("/V")] = TextStringObject("")
        widget[NameObject("/DV")] = TextStringObject("")

    widget_ref = writer._add_object(widget)

    if NameObject("/Annots") not in page:
        page[NameObject("/Annots")] = ArrayObject()
    page[NameObject("/Annots")].append(widget_ref)
    acroform[NameObject("/Fields")].append(widget_ref)


def main(input_pdf: str, manifest_json: str, output_pdf: str) -> None:
    reader = PdfReader(input_pdf)
    writer = PdfWriter()
    writer.append(reader)

    specs = json.loads(Path(manifest_json).read_text(encoding="utf-8"))
    for spec in specs:
        add_widget(writer, spec)

    with open(output_pdf, "wb") as out_file:
        writer.write(out_file)
    print(f"Injected {len(specs)} interactive AcroForm widgets into {output_pdf}")


if __name__ == "__main__":
    if len(sys.argv) != 4:
        print("Usage: python reconstruct_acroform.py input.pdf fields_manifest.json output.pdf")
        sys.exit(1)
    main(sys.argv[1], sys.argv[2], sys.argv[3])
Enter fullscreen mode Exit fullscreen mode

Notice /F 4 on the annotation dictionary: that is the Annotation Flags entry (ISO 32000-1 §12.5.3, bit 3 = Print), which is distinct from /Ff (Field Flags, §12.7.3.1). If you omit /F 4, a user can fill out the reconstructed PDF on screen, hit Ctrl+P, and get a blank printout because the viewer skips non-printable annotations during rasterization.


5. Detecting Candidate Lines and Anchor Labels Before Placing Widgets

When automating this across dozens of existing Word-exported templates, manually measuring [llx, lly, urx, ury] coordinates in points is tedious. A practical pipeline combines two passes:

  1. Text Anchor Pass: Locate label tokens ("Date of Birth:", "Signature:", "EIN:", "Phone:") and their baseline coordinates across pages. If you are auditing a large multi-page agreement first to locate every clause or field prompt, you can search a PDF for a word across all pages to find exact page numbers and context occurrences before placing fields.
  2. Vector / Underscore Pass:
    • Inspect page content streams for horizontal stroke segments (x1 y m x2 y l S where |y2 - y1| < 0.5 and x2 - x1 > 30), or
    • Match runs of 4+ consecutive underscores (_{4,}) or Unicode box characters (☐, □, ☑) in extracted text spans.

When you only need to turn a flattened export into an interactive document without writing custom pypdf coordinate scripts, you can convert Word to a fillable PDF or convert a PDF to a fillable form online by placing text fields, checkboxes, dates, and signature boxes visually on the canvas and exporting a clean PDF. And if someone sends you an already-exported static PDF that just needs quick inputs added and filled immediately, you can make a PDF fillable free directly in the browser (see also the interactive form converter workspace).


6. Checklist for Production-Safe Fillable PDFs

Whether you generate AcroForm fields in Python, LibreOffice Writer (which does export Content Controls as /Widget annotations when "Create PDF form" is enabled), or a browser PDF editor, verify these four details before shipping a template to end users or an automated filling pipeline:

  1. Unique /T Field Names: Never reuse the same /T name for two distinct inputs unless you explicitly want synchronized duplicate fields (in AcroForm, widgets sharing the same /T inherit the same /V value automatically).
  2. /F 4 Print Flag Set: Confirm bit 3 of /F is enabled on every /Widget annotation so printed and flattened copies retain user input.
  3. Explicit /DA Font Resource: Always reference a standard 14 PDF font (/Helv, /Cour, /TiRo, /ZaDb) in /DA and declare it in /AcroForm/DR/Font so viewers without system font substitution do not crash when generating appearance streams.
  4. Test Long Unicode Strings & Flattening: Fill the reconstructed fields with non-ASCII characters (diacritics, typographic quotes) and test rendering in Chrome PDFium, Mozilla PDF.js, and macOS Preview before locking the template.

Disclosure: I am the solo developer of JustFill.

AI disclosure: AI coding agents drafted and revised this article and verified the code examples above.

Top comments (0)