Un formulaire PDF qu'on vous demande d'imprimer et de remplir au stylo ne contient en général aucun formulaire. Les lignes et les cases à cocher sont des dessins vectoriels, le même type d'objet qu'un trait de séparation ou un cadre, et le fichier n'a aucun champ AcroForm dans lequel un lecteur pourrait écrire. Le PDF Editor de l'application de bureau osFoundry a un bouton prévu exactement pour ce cas (All tools, Prepare a form, Detect form fields). Sur un formulaire de test d'une page, il a trouvé 14 champs et a nommé chacun d'après son libellé imprimé. L'éditeur est distribué sous forme de module compilé, il n'y a donc pas de code source à citer, même si le fichier qu'il enregistre indique MuPDF 1.28.6 comme producteur. La suite reconstruit le même travail en environ 80 lignes de Python avec PyMuPDF, le compare champ par champ au fichier enregistré par l'application, puis le vérifie avec une seconde bibliothèque.
Mesures faites sur un Apple M1 avec Python 3.12, PyMuPDF 2.1 (MuPDF 1.28.6) et pypdf 7.0.0. La page de test est une fiche fictive de nouveau patient (document d'exemple), au format US Letter, générée en anglais et en japonais : cinq lignes avec libellé, trois paires de cases Oui/Non, une ligne pour le motif de la visite, et des lignes pour la signature et la date.
Ce que contient un formulaire à imprimer
Avant la détection, la page n'a aucun widget. page.get_drawings() renvoie tout ce qui a été dessiné :
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)
La page compte 15 dessins. L'un est un rectangle plein (f), la bande de couleur en haut. Les 14 autres sont des tracés (s) : huit lignes horizontales (l) et six carrés de 12 sur 12 pt (re). C'est tout le signal disponible. Rien n'indique que la ligne à y = 193 est l'endroit où écrire un nom ; c'est un trait de 0.8 pt qui commence juste après les mots "Full name".
Repérer les lignes et les cases
Pour un formulaire papier classique, deux règles de forme suffisent. Une ligne à remplir est un tracé horizontal et assez long. Une case à cocher est un petit carré tracé. Les formes pleines sont de la décoration et sont ignorées.
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
Sur les deux pages de test, cela renvoie 8 lignes et 6 cases.
Nommer chaque champ d'après son libellé
Un champ nommé Text1 ne sert à rien à celui qui lira plus tard les données du formulaire, donc le nom vient du texte imprimé. Pour une ligne, le libellé est le texte situé sur la même ligne de base et qui se termine à gauche du début de la ligne ; le plus proche l'emporte. Pour une case, c'est le mot juste à sa droite.
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
Le " ".join(t.split()) est là pour une raison que j'ai découverte à mes dépens. Sans lui, le premier nom sortait sous la forme 'Full\xa0name' : sur ce fichier, get_text renvoie une espace insécable (U+00A0) entre les mots. Le champ s'affiche correctement dans tous les lecteurs, puis toute recherche sur "Full name" échoue.
Écrire de vrais champs AcroForm
Chaque ligne devient un champ texte de 16 pt de haut dont le bord inférieur se trouve 1 pt au-dessus de la ligne, du début à la fin de celle-ci. Chaque case devient une case à cocher avec exactement le rectangle de la case. Les libellés qui se répètent, comme Yes et No sur trois rangées, reçoivent un numéro à partir de la deuxième occurrence.
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])
Comparaison avec le fichier de l'application
Les fichiers enregistrés par l'application ont été relus avec PyMuPDF (page.widgets(), nom et rectangle de chaque widget) et comparés dans l'ordre aux champs écrits par le script.
| Dessins | Lignes + cases | Champs | Même nom et même rectangle que l'application | Taille avant | Après (script) | Après (application) | Temps du script, médiane | |
|---|---|---|---|---|---|---|---|---|
| Formulaire anglais | 15 | 8 + 6 | 14 | 14 sur 14 | 138,967 octets | 147,336 octets | 152,252 octets | 12.7 ms |
| Formulaire japonais | 15 | 8 + 6 | 14 | 14 sur 14 | 289,479 octets | 298,005 octets | 301,265 octets | 12.2 ms |
Tous les noms et tous les rectangles correspondent, y compris la hauteur de 16 pt, l'écart de 1 pt au-dessus de la ligne et la numérotation Yes2/No3. Le fichier de l'application contient aussi les valeurs saisies pendant l'enregistrement, ce qui explique en partie sa taille plus grande. Le temps est la médiane de 50 exécutions comprenant l'ouverture, la détection, l'ajout des 14 widgets et la sérialisation du document en mémoire.
Vérifier que les champs sont standard
Un champ n'est utile que si un autre logiciel peut le remplir. Le fichier écrit par le script a été rempli avec pypdf, une bibliothèque distincte en pur Python, puis relu avec 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()})
Les valeurs sont revenues sous la forme '03/14/1991', '555-014-2290', 'Yes' pour la case cochée et 'Off' pour sa voisine. Le même test dans l'autre sens, get_fields() de pypdf sur le fichier enregistré par l'application, renvoie les 14 noms avec les valeurs saisies pendant l'enregistrement, de 'Full name': 'Jordan Lee' à 'Date': '11/03/2026'.
Les noms de champs japonais demandent de l'UTF-16
C'est sur le formulaire japonais que le script s'est trompé pour la première fois. Dans PyMuPDF, les noms semblaient corrects, mais pypdf les listait ainsi :
['ã†−咓囓', b'\xe7\x94\x9f\xe5\xb9\xb4\xe6\x9c\x88\xe6\x97\xa5', ...]
Les objets bruts expliquent pourquoi. PyMuPDF avait écrit le nom お名前 sous la forme /T<E3818AE5908DE5898D>, c'est-à-dire des octets UTF-8 sans indicateur d'ordre des octets. Une chaîne de texte PDF est en PDFDocEncoding, en UTF-16BE commençant par FEFF ou, depuis PDF 2.0, en UTF-8 commençant par EFBBBF. De l'UTF-8 sans ce marqueur n'est rien de tout cela, donc un lecteur qui suit la spécification décode ces octets comme du PDFDocEncoding et obtient du charabia, ou abandonne et renvoie les octets bruts. Le fichier de l'application stocke le même nom sous la forme /T<FEFF304A540D524D>, et pypdf le lit comme お名前. Réécrire les noms non ASCII sous cette forme corrige la sortie du script :
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() + ">")
Dans la sortie de PyMuPDF, le nom se trouve sur l'objet champ parent (le widget a un /Parent), c'est donc là que /T doit être modifié. Après la correction, pypdf liste ['お名前', '生年月日', '電話番号', ...], remplit 生年月日 et 電話番号, et PyMuPDF relit les valeurs sans changement.
Là où les règles de forme cassent
Quatre pages que j'ai préparées pour tester les limites :
| Page | Ce qu'a fait le script |
|---|---|
| Le formulaire de test rastérisé à 150 dpi | 0 dessin, 1 image, 0 champ |
| Libellé imprimé au-dessus de la ligne, pas à sa gauche | 1 champ, nommé Field
|
| Un grand rectangle comme zone de réponse | 0 champ |
| Un trait sous le mot "Total" | 1 champ, posé sur le mot |
Un scan n'a aucune ligne vectorielle, cette approche a donc d'abord besoin d'un détecteur de lignes sur les pixels. Les trois autres cas sont des choix de mise en page que les vrais formulaires font tout le temps : libellés au-dessus de la ligne, zones de réponse encadrées et traits de tableau sur lesquels on n'est pas censé écrire. Chacun demande sa propre règle, et le dernier montre le prix d'une règle trop permissive : un champ qui recouvre du texte imprimé.
Detect form fields se trouve dans le PDF Editor de l'application de bureau osFoundry, gratuite depuis la page de téléchargement, et fonctionne sur l'ordinateur lui-même, conformément à l'approche local-first de la confidentialité sur laquelle l'application est construite.

Top comments (0)