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>
Notice what happens during Word's native PDF export:
-
Print-driver architecture:
Microsoft Print to PDFroutes 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. -
Built-in Exporter (
Save As PDF): Even when "Document structure tags for accessibility" is enabled, Word maps<w:sdtContent>into the/StructTreeRoottagged-PDF tree as standard<P>or<Span>nodes—not interactive/Annotobjects. -
Visual fallback: The placeholder underscores (
_____) or Unicode ballot box (U+2610 ☐) inside<w:sdtContent>are emitted directly into the page's/Contentsstream as raw PostScript-style drawing operators:
BT
/F1 11 Tf
72.00 684.50 Td
(Applicant Full Name: _________________________) Tj
ET
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:
-
The Document-Level
/AcroFormDictionary (attached to the/Catalogroot 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/Fontdictionaries (/Helv,/ZaDb) for appearance generation.
-
-
Page-Level
/Subtype /WidgetAnnotations (referenced in each page's/Annotsarray):- 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
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 widthwand heighthextends downward. -
PDF User Space (
/MediaBox): Origin(0, 0)is at the bottom-left of the page (unless/Rotateis90,180, or270). 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]
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 }
}
]
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])
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:
-
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. -
Vector / Underscore Pass:
- Inspect page content streams for horizontal stroke segments (
x1 y m x2 y l Swhere|y2 - y1| < 0.5andx2 - x1 > 30), or - Match runs of 4+ consecutive underscores (
_{4,}) or Unicode box characters (☐,□,☑) in extracted text spans.
- Inspect page content streams for horizontal stroke segments (
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:
-
Unique
/TField Names: Never reuse the same/Tname for two distinct inputs unless you explicitly want synchronized duplicate fields (in AcroForm, widgets sharing the same/Tinherit the same/Vvalue automatically). -
/F 4Print Flag Set: Confirm bit 3 of/Fis enabled on every/Widgetannotation so printed and flattened copies retain user input. -
Explicit
/DAFont Resource: Always reference a standard 14 PDF font (/Helv,/Cour,/TiRo,/ZaDb) in/DAand declare it in/AcroForm/DR/Fontso viewers without system font substitution do not crash when generating appearance streams. - 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.













