A PDF form that you are told to print and fill in by pen usually has no form at all. The lines and tick boxes are vector drawings, the same kind of object as a divider or a border, and the file has no AcroForm fields for a reader to type into. The PDF Editor in the osFoundry desktop app has a button for exactly this case (All tools, Prepare a form, Detect form fields), and on a one-page test form it found 14 fields and named each one after its printed label. The editor ships as a compiled module, so there is no source to quote, although the file it saves names MuPDF 1.28.6 as its producer. What follows is the same job rebuilt in about 80 lines of Python with PyMuPDF, compared field by field with the file the app saved, and checked with a second library.
Measured on an Apple M1 with Python 3.12, PyMuPDF 2.1 (MuPDF 1.28.6) and pypdf 7.0.0. The test page is a made-up new patient form (example document), US Letter, generated in English and in Japanese: five labelled lines, three Yes/No pairs of tick boxes, a line for the reason of the visit, and signature and date lines.
What a printed form contains
Before detection, the page has no widgets at all. page.get_drawings() returns everything that was drawn:
import pymupdf
page = pymupdf.open("en.pdf")[0]
print(len(list(page.widgets()))) # 0
for d in page.get_drawings()[:3]:
print(d["type"], d["items"][0][0], d["rect"])
f re Rect(0.0, 0.0, 612.0, 6.0)
s l Rect(160.0, 193.0, 558.0, 193.0)
s l Rect(160.0, 229.0, 558.0, 229.0)
There are 15 drawings on the page. One is a filled rectangle (f), the coloured bar at the top. The other 14 are stroked (s): eight horizontal lines (l) and six 12 by 12 pt squares (re). That is the whole signal. Nothing marks the line at y = 193 as the place for a name; it is a 0.8 pt stroke that happens to start right after the words "Full name".
Pick out the lines and the boxes
Two shape rules cover a typical paper form. A fill-in line is stroked, horizontal and reasonably long. A tick box is a small stroked square. Filled shapes are decoration and are skipped.
import pymupdf
def find_targets(page):
lines, boxes = [], []
for d in page.get_drawings():
if d["type"] != "s": # stroked only: fills are decoration
continue
for item in d["items"]:
if item[0] == "l":
p, q = item[1], item[2]
if abs(p.y - q.y) < 0.5 and abs(q.x - p.x) >= 40:
lines.append(pymupdf.Rect(min(p.x, q.x), p.y, max(p.x, q.x), p.y))
elif item[0] == "re":
r = item[1]
if 8 <= r.width <= 20 and abs(r.width - r.height) < 1:
boxes.append(r)
return lines, boxes
On both test pages this returns 8 lines and 6 boxes.
Name each field after its label
A field called Text1 is useless to whoever reads the form data later, so the name comes from the printed text. For a line, the label is the text on the same baseline that ends to the left of where the line starts, and the closest such text wins. For a box, it is the word just to the right of it.
def text_lines(page):
out = []
for b in page.get_text("dict")["blocks"]:
for ln in b.get("lines", []):
t = "".join(s["text"] for s in ln["spans"]).strip()
if t:
out.append((" ".join(t.split()), pymupdf.Rect(ln["bbox"]))) # U+00A0 -> space
return out
def label_for_line(line, texts):
# the text that sits on the same baseline and ends left of where the line starts
cands = [(line.x0 - r.x1, t) for t, r in texts
if abs(r.y1 - line.y0) < 8 and r.x1 <= line.x0 + 1]
return min(cands)[1] if cands else None
def label_for_box(box, texts):
# the word right after the box on the same row
cands = [(r.x0 - box.x1, t) for t, r in texts
if abs((r.y0 + r.y1) / 2 - (box.y0 + box.y1) / 2) < 6 and 0 <= r.x0 - box.x1 < 30]
return min(cands)[1] if cands else None
The " ".join(t.split()) is there for a reason I found the hard way. Without it, the first name came out as 'Full\xa0name': on this file, get_text returns a no-break space (U+00A0) between words. The field looks right in every viewer and then fails any lookup by "Full name".
Write real AcroForm fields
Each line becomes a 16 pt high text field whose bottom edge sits 1 pt above the line, from where the line starts to where it ends. Each box becomes a checkbox with exactly the box's rectangle. Labels that repeat, such as Yes and No on three rows, get a number on the second and third use.
def unique(name, seen):
n, k = name, 1
while n in seen:
k += 1
n = f"{name}{k}"
seen.add(n)
return n
def add_fields(doc):
page = doc[0]
lines, boxes = find_targets(page)
texts = text_lines(page)
seen, made = set(), []
targets = [(l.y0, l.x0, "text", l) for l in lines] + [(b.y0, b.x0, "box", b) for b in boxes]
for _, _, kind, r in sorted(targets, key=lambda t: (round(t[0]), t[1])):
w = pymupdf.Widget()
if kind == "text":
w.field_type = pymupdf.PDF_WIDGET_TYPE_TEXT
w.rect = pymupdf.Rect(r.x0, r.y0 - 17, r.x1, r.y0 - 1) # 16 pt box sitting on the line
w.field_name = unique(label_for_line(r, texts) or "Field", seen)
w.text_fontsize = 0 # auto size
else:
w.field_type = pymupdf.PDF_WIDGET_TYPE_CHECKBOX
w.rect = r
w.field_name = unique(label_for_box(r, texts) or "Check", seen)
page.add_widget(w)
made.append((kind, w.field_name, [round(v, 1) for v in w.rect]))
return made
('text', 'Full name', [160.0, 176.0, 558.0, 192.0])
('text', 'Date of birth', [160.0, 212.0, 558.0, 228.0])
...
('box', 'Yes', [400.0, 406.0, 412.0, 418.0])
('box', 'No', [480.0, 406.0, 492.0, 418.0])
('box', 'Yes2', [400.0, 436.0, 412.0, 448.0])
...
('text', 'Date', [440.0, 562.0, 558.0, 578.0])
Compare with the app's file
The app's saved files were read back with PyMuPDF (page.widgets(), name and rectangle of each widget) and compared in order with the fields the script wrote.
| Drawings | Lines + boxes | Fields | Same name and rectangle as the app | Size before | After (script) | After (app) | Script time, median | |
|---|---|---|---|---|---|---|---|---|
| English form | 15 | 8 + 6 | 14 | 14 of 14 | 138,967 bytes | 147,336 bytes | 152,252 bytes | 12.7 ms |
| Japanese form | 15 | 8 + 6 | 14 | 14 of 14 | 289,479 bytes | 298,005 bytes | 301,265 bytes | 12.2 ms |
Every name and every rectangle matches, including the 16 pt height, the 1 pt gap above the line and the Yes2/No3 numbering. The app's file also carries the values typed into it during the recording, which is part of why it is larger. The time is the median of 50 runs of open, detect, add the 14 widgets and serialise the document in memory.
Check that the fields are standard
A field only helps if other software can fill it. The file the script wrote was filled with pypdf, a separate pure-Python library, and then read back with PyMuPDF:
import pypdf, pymupdf
w = pypdf.PdfWriter(clone_from="out-en.pdf")
w.update_page_form_field_values(
w.pages[0], {"Date of birth": "03/14/1991", "Phone": "555-014-2290", "No": "/Yes"},
auto_regenerate=False)
w.set_need_appearances_writer(True)
w.write("filled-en.pdf")
print({x.field_name: x.field_value for x in pymupdf.open("filled-en.pdf")[0].widgets()})
The values came back as '03/14/1991', '555-014-2290', 'Yes' for the ticked box and 'Off' for the one next to it. The same check in the other direction, pypdf's get_fields() on the file the app saved, returns all 14 names with the values typed in the recording, from 'Full name': 'Jordan Lee' to 'Date': '11/03/2026'.
Japanese field names need UTF-16
The Japanese form is where the script first went wrong. The names looked right in PyMuPDF, but pypdf listed them like this:
['ã†−咓囓', b'\xe7\x94\x9f\xe5\xb9\xb4\xe6\x9c\x88\xe6\x97\xa5', ...]
The raw objects show why. PyMuPDF had written the name お名前 as /T<E3818AE5908DE5898D>, UTF-8 bytes without a byte order mark. A PDF text string is PDFDocEncoding, UTF-16BE starting with FEFF, or, since PDF 2.0, UTF-8 starting with EFBBBF. UTF-8 without that marker is none of them, so a reader that follows the spec decodes those bytes as PDFDocEncoding and gets mojibake, or gives up and returns raw bytes. The app's file stores the same name as /T<FEFF304A540D524D>, and pypdf reads it as お名前. Rewriting non-ASCII names in that form fixes the script's output:
def utf16_names(doc):
# store non-ASCII names as UTF-16BE with a BOM, the encoding every PDF reader understands
for w in doc[0].widgets():
name = w.field_name
if name.isascii():
continue
kind, ref = doc.xref_get_key(w.xref, "Parent")
x = int(ref.split()[0]) if kind == "xref" else w.xref
doc.xref_set_key(x, "T", "<FEFF" + name.encode("utf-16-be").hex().upper() + ">")
The name lives on the parent field object in PyMuPDF's output (the widget has a /Parent), so that is where /T has to change. After the fix pypdf lists ['お名前', '生年月日', '電話番号', ...], fills 生年月日 and 電話番号, and PyMuPDF reads the values back unchanged.
Where the shape rules break
Four pages I made to test the limits:
| Page | What the script did |
|---|---|
| The test form rasterised at 150 dpi | 0 drawings, 1 image, 0 fields |
| Label printed above the line, not to its left | 1 field, named Field
|
| A large rectangle as the answer area | 0 fields |
| A rule under the word "Total" | 1 field, sitting on top of the word |
A scan has no vector lines at all, so this approach needs a line detector on pixels first. The other three are layout choices that real forms make all the time: labels above the line, boxed answer areas, and table rules that are not meant to be written on. Each needs its own rule, and the last one shows the cost of a loose rule: a field that covers printed text.
Detect form fields is in the PDF Editor of the osFoundry desktop app, free from the download page, and it runs on the computer itself, in line with the app's local-first approach to privacy.

Top comments (0)