pdf-form-proof 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pdf_form_proof/__init__.py +3 -0
- pdf_form_proof/_acroform.py +242 -0
- pdf_form_proof/cli.py +37 -0
- pdf_form_proof/fill.py +195 -0
- pdf_form_proof/legibility.py +262 -0
- pdf_form_proof/server.py +97 -0
- pdf_form_proof-0.1.0.dist-info/METADATA +169 -0
- pdf_form_proof-0.1.0.dist-info/RECORD +11 -0
- pdf_form_proof-0.1.0.dist-info/WHEEL +4 -0
- pdf_form_proof-0.1.0.dist-info/entry_points.txt +5 -0
- pdf_form_proof-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
"""Low-level AcroForm helpers (pypdf). Field names, checkbox on-states,
|
|
2
|
+
form-kind detection, and the /V + /AS checkbox pass.
|
|
3
|
+
|
|
4
|
+
Extracted from a production form-filling server; logic unchanged.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pypdf import PdfReader, PdfWriter
|
|
10
|
+
from pypdf.generic import NameObject
|
|
11
|
+
|
|
12
|
+
# Values treated as "do not check" for checkbox fields. Everything else
|
|
13
|
+
# (True, "true", "Yes", "X", 1, "2", ...) means "set to the ON state".
|
|
14
|
+
_CHECKBOX_FALSY_STRINGS = {"", "0", "off", "false", "no", "none", "unchecked"}
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _checkbox_truthy(val: Any) -> bool:
|
|
18
|
+
if isinstance(val, bool):
|
|
19
|
+
return val
|
|
20
|
+
if val is None:
|
|
21
|
+
return False
|
|
22
|
+
if isinstance(val, (int, float)):
|
|
23
|
+
return val != 0
|
|
24
|
+
return str(val).strip().lower() not in _CHECKBOX_FALSY_STRINGS
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _qualified_name(node) -> str | None:
|
|
28
|
+
"""Fully-qualified field name (parent /T chain joined with '.'),
|
|
29
|
+
mirroring pypdf get_fields() key construction (/TM short-circuits)."""
|
|
30
|
+
try:
|
|
31
|
+
node = node.get_object()
|
|
32
|
+
except Exception:
|
|
33
|
+
pass
|
|
34
|
+
try:
|
|
35
|
+
if "/TM" in node:
|
|
36
|
+
return str(node["/TM"])
|
|
37
|
+
t = str(node["/T"]) if "/T" in node else None
|
|
38
|
+
parent = node.get("/Parent")
|
|
39
|
+
if parent is not None:
|
|
40
|
+
pname = _qualified_name(parent)
|
|
41
|
+
if t is None:
|
|
42
|
+
return pname
|
|
43
|
+
return f"{pname}.{t}" if pname else t
|
|
44
|
+
return t
|
|
45
|
+
except Exception:
|
|
46
|
+
return None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _widget_on_states(annot) -> list:
|
|
50
|
+
"""ON-state names (without leading '/') present in a widget's /AP /N
|
|
51
|
+
(and /D) appearance dictionaries. '/Off' is excluded."""
|
|
52
|
+
states: list = []
|
|
53
|
+
try:
|
|
54
|
+
ap = annot.get("/AP")
|
|
55
|
+
if ap is None:
|
|
56
|
+
return states
|
|
57
|
+
ap = ap.get_object()
|
|
58
|
+
for ap_key in ("/N", "/D"):
|
|
59
|
+
sub = ap.get(ap_key)
|
|
60
|
+
if sub is None:
|
|
61
|
+
continue
|
|
62
|
+
sub = sub.get_object()
|
|
63
|
+
if not hasattr(sub, "keys"):
|
|
64
|
+
continue # appearance stream, not a state dictionary
|
|
65
|
+
for k in sub.keys():
|
|
66
|
+
name = str(k)
|
|
67
|
+
if name == "/Off":
|
|
68
|
+
continue
|
|
69
|
+
name = name[1:] if name.startswith("/") else name
|
|
70
|
+
if name not in states:
|
|
71
|
+
states.append(name)
|
|
72
|
+
except Exception:
|
|
73
|
+
pass
|
|
74
|
+
return states
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _btn_field_names(reader: PdfReader) -> set:
|
|
78
|
+
"""Fully-qualified names of /Btn (checkbox/radio) fields in the form."""
|
|
79
|
+
names = set()
|
|
80
|
+
try:
|
|
81
|
+
fields = reader.get_fields() or {}
|
|
82
|
+
except Exception:
|
|
83
|
+
return names
|
|
84
|
+
for name, fobj in fields.items():
|
|
85
|
+
try:
|
|
86
|
+
if str(fobj.get("/FT")) == "/Btn":
|
|
87
|
+
names.add(name)
|
|
88
|
+
except Exception:
|
|
89
|
+
continue
|
|
90
|
+
return names
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _apply_checkbox_states(writer: PdfWriter, checkbox_values: dict, on_state_map: dict) -> dict:
|
|
94
|
+
"""
|
|
95
|
+
Set BOTH /V (on the field object) and /AS (on the widget annotation) for
|
|
96
|
+
checkbox/radio fields. pypdf's update_page_form_field_values set /V only,
|
|
97
|
+
so checkbox appearances did not render in some viewers.
|
|
98
|
+
|
|
99
|
+
ON-state resolution order per field:
|
|
100
|
+
(a) on_state_map -- an explicit {field: on_state} map from the caller
|
|
101
|
+
(per-field values like "1", "2", "Yes", lowercase "yes");
|
|
102
|
+
(b) else discovery from the widget /AP /N keys (first non-/Off key).
|
|
103
|
+
"""
|
|
104
|
+
applied = []
|
|
105
|
+
unresolved = []
|
|
106
|
+
seen_keys = set()
|
|
107
|
+
pushbutton_flag = 1 << 16 # /Ff bit 17: pushbutton (stateless; skip)
|
|
108
|
+
|
|
109
|
+
for page in writer.pages:
|
|
110
|
+
annots = page.get("/Annots")
|
|
111
|
+
if not annots:
|
|
112
|
+
continue
|
|
113
|
+
for ref in annots:
|
|
114
|
+
try:
|
|
115
|
+
annot = ref.get_object()
|
|
116
|
+
except Exception:
|
|
117
|
+
continue
|
|
118
|
+
if annot.get("/Subtype") != "/Widget":
|
|
119
|
+
continue
|
|
120
|
+
# Resolve the owning field node (where /FT and /V live).
|
|
121
|
+
field_node = annot
|
|
122
|
+
ftype = field_node.get("/FT")
|
|
123
|
+
hops = 0
|
|
124
|
+
while ftype is None and field_node.get("/Parent") is not None and hops < 16:
|
|
125
|
+
field_node = field_node["/Parent"].get_object()
|
|
126
|
+
ftype = field_node.get("/FT")
|
|
127
|
+
hops += 1
|
|
128
|
+
if str(ftype) != "/Btn":
|
|
129
|
+
continue
|
|
130
|
+
try:
|
|
131
|
+
flags = int(field_node.get("/Ff", 0) or 0)
|
|
132
|
+
except Exception:
|
|
133
|
+
flags = 0
|
|
134
|
+
if flags & pushbutton_flag:
|
|
135
|
+
continue
|
|
136
|
+
|
|
137
|
+
fq = (_qualified_name(annot) or "").rstrip(".")
|
|
138
|
+
matched_key = None
|
|
139
|
+
if fq and fq in checkbox_values:
|
|
140
|
+
matched_key = fq
|
|
141
|
+
elif fq:
|
|
142
|
+
# Backslash-insensitive fallback: XFA-shadow leaf names carry
|
|
143
|
+
# literal backslash-dot sequences (e.g. List17\.1[0]).
|
|
144
|
+
fq_plain = fq.replace("\\", "")
|
|
145
|
+
for k in checkbox_values:
|
|
146
|
+
if k.replace("\\", "") == fq_plain:
|
|
147
|
+
matched_key = k
|
|
148
|
+
break
|
|
149
|
+
if matched_key is None:
|
|
150
|
+
continue
|
|
151
|
+
|
|
152
|
+
val = checkbox_values[matched_key]
|
|
153
|
+
widget_states = _widget_on_states(annot)
|
|
154
|
+
if _checkbox_truthy(val):
|
|
155
|
+
target = str(on_state_map.get(matched_key) or "").lstrip("/")
|
|
156
|
+
if not target:
|
|
157
|
+
target = widget_states[0] if widget_states else ""
|
|
158
|
+
if not target:
|
|
159
|
+
if matched_key not in seen_keys:
|
|
160
|
+
unresolved.append(matched_key)
|
|
161
|
+
seen_keys.add(matched_key)
|
|
162
|
+
continue
|
|
163
|
+
state = NameObject("/" + target)
|
|
164
|
+
# /AS only where this widget owns the target state (radio
|
|
165
|
+
# kids each own a single state); other kids go /Off.
|
|
166
|
+
if not widget_states or target in widget_states:
|
|
167
|
+
annot[NameObject("/AS")] = state
|
|
168
|
+
else:
|
|
169
|
+
annot[NameObject("/AS")] = NameObject("/Off")
|
|
170
|
+
field_node[NameObject("/V")] = state
|
|
171
|
+
applied.append({"field": matched_key, "state": "/" + target})
|
|
172
|
+
else:
|
|
173
|
+
annot[NameObject("/AS")] = NameObject("/Off")
|
|
174
|
+
field_node[NameObject("/V")] = NameObject("/Off")
|
|
175
|
+
applied.append({"field": matched_key, "state": "/Off"})
|
|
176
|
+
seen_keys.add(matched_key)
|
|
177
|
+
|
|
178
|
+
no_match = [k for k in checkbox_values if k not in seen_keys]
|
|
179
|
+
return {"applied": applied, "unresolved_on_state": unresolved, "no_widget_match": no_match}
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def detect_form_kind(pdf_path: str) -> str:
|
|
183
|
+
"""
|
|
184
|
+
Return 'acroform', 'xfa', or 'none' based on PDF structure.
|
|
185
|
+
XFA: has /AcroForm/XFA entry (Adobe LiveCycle dynamic forms).
|
|
186
|
+
AcroForm: has /AcroForm/Fields with non-empty fields list.
|
|
187
|
+
"""
|
|
188
|
+
try:
|
|
189
|
+
reader = PdfReader(pdf_path)
|
|
190
|
+
except Exception:
|
|
191
|
+
return "none"
|
|
192
|
+
root = reader.trailer.get("/Root", {})
|
|
193
|
+
acroform = None
|
|
194
|
+
try:
|
|
195
|
+
acroform = root.get("/AcroForm")
|
|
196
|
+
except Exception:
|
|
197
|
+
acroform = None
|
|
198
|
+
if not acroform:
|
|
199
|
+
return "none"
|
|
200
|
+
try:
|
|
201
|
+
acro_obj = acroform.get_object() if hasattr(acroform, "get_object") else acroform
|
|
202
|
+
except Exception:
|
|
203
|
+
acro_obj = acroform
|
|
204
|
+
has_xfa = False
|
|
205
|
+
has_fields = False
|
|
206
|
+
try:
|
|
207
|
+
if "/XFA" in acro_obj:
|
|
208
|
+
has_xfa = True
|
|
209
|
+
if "/Fields" in acro_obj:
|
|
210
|
+
fields = acro_obj.get("/Fields")
|
|
211
|
+
if fields and len(fields) > 0:
|
|
212
|
+
has_fields = True
|
|
213
|
+
except Exception:
|
|
214
|
+
pass
|
|
215
|
+
if has_xfa and not has_fields:
|
|
216
|
+
return "xfa"
|
|
217
|
+
if has_fields and not has_xfa:
|
|
218
|
+
return "acroform"
|
|
219
|
+
if has_fields and has_xfa:
|
|
220
|
+
# Hybrid XFA -- pypdf may fill the AcroForm shadow fields successfully
|
|
221
|
+
return "acroform"
|
|
222
|
+
return "none"
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def list_acroform_fields(pdf_path: str) -> list[dict]:
|
|
226
|
+
"""
|
|
227
|
+
Walk AcroForm fields and return [{name, type, value, flags, is_required}, ...].
|
|
228
|
+
For XFA-only forms, returns empty list (caller should switch to xfa_handler).
|
|
229
|
+
"""
|
|
230
|
+
reader = PdfReader(pdf_path)
|
|
231
|
+
fields = reader.get_fields() or {}
|
|
232
|
+
out = []
|
|
233
|
+
for name, fobj in fields.items():
|
|
234
|
+
ftype = fobj.get("/FT")
|
|
235
|
+
out.append({
|
|
236
|
+
"name": name,
|
|
237
|
+
"type": str(ftype) if ftype else "unknown",
|
|
238
|
+
"value": fobj.get("/V"),
|
|
239
|
+
"flags": fobj.get("/Ff"),
|
|
240
|
+
"is_required": bool(fobj.get("/Ff") and int(fobj.get("/Ff", 0)) & 2),
|
|
241
|
+
})
|
|
242
|
+
return out
|
pdf_form_proof/cli.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Command-line entry points: pdf-form-inspect, pdf-form-fill, pdf-form-verify."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .fill import fill_form, inspect_form
|
|
10
|
+
from .legibility import main as verify_main
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def inspect_main(argv=None) -> int:
|
|
14
|
+
ap = argparse.ArgumentParser(description="List fillable fields in a PDF form.")
|
|
15
|
+
ap.add_argument("pdf")
|
|
16
|
+
a = ap.parse_args(argv)
|
|
17
|
+
print(json.dumps(inspect_form(a.pdf), indent=2))
|
|
18
|
+
return 0
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def fill_main(argv=None) -> int:
|
|
22
|
+
ap = argparse.ArgumentParser(description="Fill a PDF form from a JSON file of {field: value}.")
|
|
23
|
+
ap.add_argument("source_pdf")
|
|
24
|
+
ap.add_argument("output_pdf")
|
|
25
|
+
ap.add_argument("values_json")
|
|
26
|
+
ap.add_argument("--loose", action="store_true", help="skip unknown field names instead of refusing")
|
|
27
|
+
ap.add_argument("--finalize", action="store_true")
|
|
28
|
+
a = ap.parse_args(argv)
|
|
29
|
+
with open(a.values_json, encoding="utf-8") as f:
|
|
30
|
+
values = json.load(f)
|
|
31
|
+
res = fill_form(a.source_pdf, a.output_pdf, values, strict=not a.loose, finalize=a.finalize)
|
|
32
|
+
print(json.dumps(res, indent=2))
|
|
33
|
+
return 0 if res.get("status") == "ok" else (2 if res.get("status") == "refused" else 1)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
if __name__ == "__main__":
|
|
37
|
+
sys.exit(verify_main())
|
pdf_form_proof/fill.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
"""Inspect and fill AcroForm PDF forms, refusing anything it cannot apply.
|
|
2
|
+
|
|
3
|
+
Rules this module keeps:
|
|
4
|
+
- The source PDF is never modified. Output goes to a new path, and an
|
|
5
|
+
existing output file is never overwritten.
|
|
6
|
+
- Strict by default: if any requested field name does not exist in the
|
|
7
|
+
form, nothing is written and the unknown names are returned. A partial
|
|
8
|
+
fill is never hidden behind a success message.
|
|
9
|
+
- Checkbox and radio fields get both /V and the widget /AS set, so the
|
|
10
|
+
check mark renders instead of only existing in the data.
|
|
11
|
+
- Every written file is returned with its SHA-256.
|
|
12
|
+
|
|
13
|
+
Limit: XFA-only forms (Adobe LiveCycle dynamic forms with no AcroForm
|
|
14
|
+
fields) are detected and refused; this module does not fill XFA.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import hashlib
|
|
20
|
+
import logging
|
|
21
|
+
import os
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from pypdf import PdfReader, PdfWriter
|
|
26
|
+
|
|
27
|
+
from ._acroform import (
|
|
28
|
+
_apply_checkbox_states,
|
|
29
|
+
_btn_field_names,
|
|
30
|
+
_widget_on_states,
|
|
31
|
+
_qualified_name,
|
|
32
|
+
detect_form_kind,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
# pypdf logs a warning per page with no fields; keep stdio clean for MCP.
|
|
36
|
+
logging.getLogger("pypdf").setLevel(logging.ERROR)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def sha256_file(path: str) -> str:
|
|
40
|
+
h = hashlib.sha256()
|
|
41
|
+
with open(path, "rb") as f:
|
|
42
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
43
|
+
h.update(chunk)
|
|
44
|
+
return h.hexdigest()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _button_on_states(reader: PdfReader) -> dict[str, list[str]]:
|
|
48
|
+
"""{qualified field name: [on-state names]} for checkbox/radio widgets."""
|
|
49
|
+
out: dict[str, list[str]] = {}
|
|
50
|
+
for page in reader.pages:
|
|
51
|
+
for ref in page.get("/Annots") or []:
|
|
52
|
+
try:
|
|
53
|
+
annot = ref.get_object()
|
|
54
|
+
except Exception:
|
|
55
|
+
continue
|
|
56
|
+
if annot.get("/Subtype") != "/Widget":
|
|
57
|
+
continue
|
|
58
|
+
states = _widget_on_states(annot)
|
|
59
|
+
if not states:
|
|
60
|
+
continue
|
|
61
|
+
name = (_qualified_name(annot) or "").rstrip(".")
|
|
62
|
+
if not name:
|
|
63
|
+
continue
|
|
64
|
+
for s in states:
|
|
65
|
+
out.setdefault(name, [])
|
|
66
|
+
if s not in out[name]:
|
|
67
|
+
out[name].append(s)
|
|
68
|
+
return out
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def inspect_form(pdf_path: str) -> dict[str, Any]:
|
|
72
|
+
"""Return the form kind and every fillable field with its type and,
|
|
73
|
+
for checkboxes and radios, the on-state values that will check them."""
|
|
74
|
+
kind = detect_form_kind(pdf_path)
|
|
75
|
+
if kind != "acroform":
|
|
76
|
+
return {"pdf_path": pdf_path, "kind": kind, "field_count": 0, "fields": [],
|
|
77
|
+
"note": ("XFA-only form: this tool does not fill XFA."
|
|
78
|
+
if kind == "xfa" else "No AcroForm fields found.")}
|
|
79
|
+
reader = PdfReader(pdf_path)
|
|
80
|
+
fields = reader.get_fields() or {}
|
|
81
|
+
on_states = _button_on_states(reader)
|
|
82
|
+
out = []
|
|
83
|
+
for name, fobj in fields.items():
|
|
84
|
+
ftype = fobj.get("/FT")
|
|
85
|
+
if ftype is None:
|
|
86
|
+
continue # container node, not a fillable field
|
|
87
|
+
entry = {
|
|
88
|
+
"name": name,
|
|
89
|
+
"type": {"/Tx": "text", "/Btn": "button", "/Ch": "choice",
|
|
90
|
+
"/Sig": "signature"}.get(str(ftype), str(ftype)),
|
|
91
|
+
"value": None if fobj.get("/V") is None else str(fobj.get("/V")),
|
|
92
|
+
"required": bool(fobj.get("/Ff") and int(fobj.get("/Ff", 0)) & 2),
|
|
93
|
+
}
|
|
94
|
+
if str(ftype) == "/Btn":
|
|
95
|
+
entry["on_states"] = ["/" + s for s in on_states.get(name, [])]
|
|
96
|
+
if str(ftype) == "/Ch" and fobj.get("/Opt") is not None:
|
|
97
|
+
entry["options"] = [str(o) for o in fobj.get("/Opt")]
|
|
98
|
+
out.append(entry)
|
|
99
|
+
return {"pdf_path": pdf_path, "kind": kind, "field_count": len(out), "fields": out}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def fill_form(source_pdf_path: str, output_path: str, values: dict[str, Any],
|
|
103
|
+
strict: bool = True, finalize: bool = False) -> dict[str, Any]:
|
|
104
|
+
"""Fill fields by exact qualified name.
|
|
105
|
+
|
|
106
|
+
values: {field name: value}. Text fields take a string. Checkbox/radio
|
|
107
|
+
fields take an on-state ("/1", "/Yes") or True to check, and "", False,
|
|
108
|
+
or "Off" to clear.
|
|
109
|
+
|
|
110
|
+
finalize=True removes any /XFA packet and the /NeedAppearances flag so
|
|
111
|
+
viewers render the values from their own appearance streams. It does NOT
|
|
112
|
+
flatten the form: fields stay editable.
|
|
113
|
+
"""
|
|
114
|
+
if not os.path.isfile(source_pdf_path):
|
|
115
|
+
return {"status": "refused", "reason": f"no such file: {source_pdf_path}"}
|
|
116
|
+
if os.path.abspath(source_pdf_path) == os.path.abspath(output_path):
|
|
117
|
+
return {"status": "refused", "reason": "output_path equals source; the source is never modified"}
|
|
118
|
+
if os.path.exists(output_path):
|
|
119
|
+
return {"status": "refused", "reason": f"output exists, not overwritten: {output_path}"}
|
|
120
|
+
kind = detect_form_kind(source_pdf_path)
|
|
121
|
+
if kind != "acroform":
|
|
122
|
+
return {"status": "refused", "reason": f"form kind is {kind!r}; only AcroForm forms are filled"}
|
|
123
|
+
|
|
124
|
+
reader = PdfReader(source_pdf_path)
|
|
125
|
+
known = set((reader.get_fields() or {}).keys())
|
|
126
|
+
unknown = sorted(k for k in values if k not in known)
|
|
127
|
+
if unknown and strict:
|
|
128
|
+
return {"status": "refused", "reason": "field names not in this form; nothing written",
|
|
129
|
+
"unknown_fields": unknown,
|
|
130
|
+
"hint": "run pdf_form_inspect and copy names exactly"}
|
|
131
|
+
|
|
132
|
+
writer = PdfWriter(clone_from=reader)
|
|
133
|
+
btn_names = _btn_field_names(reader)
|
|
134
|
+
checkbox_values: dict = {}
|
|
135
|
+
text_values: dict = {}
|
|
136
|
+
for name, val in values.items():
|
|
137
|
+
if name not in known:
|
|
138
|
+
continue
|
|
139
|
+
if name in btn_names:
|
|
140
|
+
if isinstance(val, str) and val.strip().lower() in ("off", "/off"):
|
|
141
|
+
val = ""
|
|
142
|
+
checkbox_values[name] = val
|
|
143
|
+
else:
|
|
144
|
+
text_values[name] = "" if val is None else str(val)
|
|
145
|
+
|
|
146
|
+
pages_touched = 0
|
|
147
|
+
if text_values:
|
|
148
|
+
for page in writer.pages:
|
|
149
|
+
try:
|
|
150
|
+
writer.update_page_form_field_values(page, text_values)
|
|
151
|
+
pages_touched += 1
|
|
152
|
+
except Exception:
|
|
153
|
+
continue
|
|
154
|
+
|
|
155
|
+
on_map = {k: str(v).lstrip("/") for k, v in checkbox_values.items()
|
|
156
|
+
if isinstance(v, str) and v.startswith("/")}
|
|
157
|
+
cb = _apply_checkbox_states(writer, checkbox_values, on_map)
|
|
158
|
+
|
|
159
|
+
finalize_changes = []
|
|
160
|
+
if finalize:
|
|
161
|
+
try:
|
|
162
|
+
acro = writer._root_object.get("/AcroForm")
|
|
163
|
+
if acro is not None:
|
|
164
|
+
acro = acro.get_object() if hasattr(acro, "get_object") else acro
|
|
165
|
+
if "/XFA" in acro:
|
|
166
|
+
del acro["/XFA"]
|
|
167
|
+
finalize_changes.append("removed /XFA")
|
|
168
|
+
if "/NeedAppearances" in acro:
|
|
169
|
+
del acro["/NeedAppearances"]
|
|
170
|
+
finalize_changes.append("removed /NeedAppearances")
|
|
171
|
+
except Exception as e: # report, do not hide
|
|
172
|
+
finalize_changes.append(f"finalize error: {e}")
|
|
173
|
+
|
|
174
|
+
Path(output_path).parent.mkdir(parents=True, exist_ok=True)
|
|
175
|
+
with open(output_path, "wb") as f:
|
|
176
|
+
writer.write(f)
|
|
177
|
+
|
|
178
|
+
# Read back: every text value must be present on the written file.
|
|
179
|
+
back = PdfReader(output_path).get_fields() or {}
|
|
180
|
+
mismatched = [k for k, v in text_values.items()
|
|
181
|
+
if str(back.get(k, {}).get("/V", "")) != v]
|
|
182
|
+
status = "ok" if not (mismatched or cb["unresolved_on_state"] or cb["no_widget_match"]) else "incomplete"
|
|
183
|
+
return {
|
|
184
|
+
"status": status,
|
|
185
|
+
"output_path": output_path,
|
|
186
|
+
"sha256": sha256_file(output_path),
|
|
187
|
+
"text_fields_set": len(text_values) - len(mismatched),
|
|
188
|
+
"text_fields_not_read_back": mismatched,
|
|
189
|
+
"checkboxes_set": cb["applied"],
|
|
190
|
+
"checkbox_unresolved_on_state": cb["unresolved_on_state"],
|
|
191
|
+
"checkbox_no_widget_match": cb["no_widget_match"],
|
|
192
|
+
"unknown_fields_skipped": unknown,
|
|
193
|
+
"finalize": finalize_changes if finalize else "not requested",
|
|
194
|
+
"next_step": "run pdf_form_verify on the output to prove the values render",
|
|
195
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""Prove that filled values are visible on the rendered page, not only
|
|
2
|
+
present in the PDF's hidden text layer.
|
|
3
|
+
|
|
4
|
+
Why: a PDF can carry a value in its data while the page a person sees (or
|
|
5
|
+
prints, or a reviewer opens) shows nothing, or shows it clipped. Every
|
|
6
|
+
text-based check passes on such a file. This check compares two channels:
|
|
7
|
+
|
|
8
|
+
in text layer seen by OCR of the render verdict
|
|
9
|
+
------------- -------------------------- -----------------------------
|
|
10
|
+
yes yes VISIBLE
|
|
11
|
+
yes no INVISIBLE (in the file, not on the page)
|
|
12
|
+
no yes RENDER-ONLY (on the page, not in the data)
|
|
13
|
+
no no MISSING
|
|
14
|
+
|
|
15
|
+
Region scoping: pass a region (x0,y0,x1,y1 as fractions of the page) and only
|
|
16
|
+
that band is OCRd, so a copy of the value elsewhere on the page cannot make it
|
|
17
|
+
pass. With a region, "auto" mode also demands that every word the text layer
|
|
18
|
+
places inside the region is legible in the render, so the caller does not
|
|
19
|
+
have to remember which values to check.
|
|
20
|
+
|
|
21
|
+
Fail-closed rules: if pdftotext, pdftoppm or tesseract fails, the result is
|
|
22
|
+
REFUSE with the tool's own error, never a finding about the document. An
|
|
23
|
+
empty OCR result from a broken engine is not evidence a value is missing.
|
|
24
|
+
|
|
25
|
+
Limit: this proves legibility only. It says nothing about whether the values
|
|
26
|
+
are correct, complete, or appropriate for the form's purpose.
|
|
27
|
+
|
|
28
|
+
Requires the poppler utilities (pdftotext, pdftoppm) and the tesseract
|
|
29
|
+
binary on PATH. Region scoping also needs Pillow.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import argparse
|
|
35
|
+
import glob
|
|
36
|
+
import os
|
|
37
|
+
import re
|
|
38
|
+
import shutil
|
|
39
|
+
import subprocess
|
|
40
|
+
import sys
|
|
41
|
+
import tempfile
|
|
42
|
+
from typing import Any
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def norm(s: str) -> str:
|
|
46
|
+
"""Collapse to a comparable form: OCR confuses case, spacing and punctuation."""
|
|
47
|
+
s = s.upper().replace("&", "AND")
|
|
48
|
+
return re.sub(r"[^A-Z0-9]", "", s)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class _Refusal(str):
|
|
52
|
+
"""An empty result that carries the reason it is empty."""
|
|
53
|
+
|
|
54
|
+
def __new__(cls, reason):
|
|
55
|
+
s = super().__new__(cls, "")
|
|
56
|
+
s.reason = reason
|
|
57
|
+
return s
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _missing_binaries() -> list[str]:
|
|
61
|
+
return [b for b in ("pdftotext", "pdftoppm", "tesseract") if shutil.which(b) is None]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def region_words(pdf: str, page: int, region) -> list[str] | None:
|
|
65
|
+
"""Words the text layer places fully inside the region (the auto set)."""
|
|
66
|
+
try:
|
|
67
|
+
out = subprocess.run(["pdftotext", "-bbox", "-f", str(page), "-l", str(page), pdf, "-"],
|
|
68
|
+
capture_output=True, timeout=120)
|
|
69
|
+
except Exception:
|
|
70
|
+
return None
|
|
71
|
+
if out.returncode != 0:
|
|
72
|
+
return None
|
|
73
|
+
xml = out.stdout.decode("utf-8", "replace")
|
|
74
|
+
pm = re.search(r'<page width="([\d.]+)" height="([\d.]+)"', xml)
|
|
75
|
+
if not pm:
|
|
76
|
+
return None
|
|
77
|
+
PW, PH = float(pm.group(1)), float(pm.group(2))
|
|
78
|
+
x0, y0, x1, y1 = region
|
|
79
|
+
X0, Y0, X1, Y1 = x0 * PW, y0 * PH, x1 * PW, y1 * PH
|
|
80
|
+
words = []
|
|
81
|
+
for wx0, wy0, wx1, wy1, w in re.findall(
|
|
82
|
+
r'<word xMin="([\d.]+)" yMin="([\d.]+)" xMax="([\d.]+)" yMax="([\d.]+)">([^<]*)</word>', xml):
|
|
83
|
+
# Full containment: a word straddling the edge would be sliced by the
|
|
84
|
+
# crop and reported invisible when it is only half-photographed.
|
|
85
|
+
if X0 <= float(wx0) and float(wx1) <= X1 and Y0 <= float(wy0) and float(wy1) <= Y1:
|
|
86
|
+
t = w.replace("&", "&").replace("<", "<").replace(">", ">").strip()
|
|
87
|
+
if t:
|
|
88
|
+
words.append(t)
|
|
89
|
+
return words
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def text_layer(pdf: str, page: int):
|
|
93
|
+
try:
|
|
94
|
+
out = subprocess.run(["pdftotext", "-layout", "-f", str(page), "-l", str(page), pdf, "-"],
|
|
95
|
+
capture_output=True, timeout=120)
|
|
96
|
+
except Exception as e:
|
|
97
|
+
return _Refusal(f"pdftotext could not run: {e}")
|
|
98
|
+
if out.returncode != 0:
|
|
99
|
+
return _Refusal(f"pdftotext exited {out.returncode}")
|
|
100
|
+
return out.stdout.decode("utf-8", "replace")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def render_ocr(pdf: str, page: int, dpi: int = 300, region=None):
|
|
104
|
+
"""Render the page and OCR it. Returns (text or _Refusal or None, png path)."""
|
|
105
|
+
cache = os.path.expanduser("~/.cache/pdf-form-proof")
|
|
106
|
+
os.makedirs(cache, exist_ok=True)
|
|
107
|
+
for old in glob.glob(os.path.join(cache, "render_*")):
|
|
108
|
+
shutil.rmtree(old, ignore_errors=True)
|
|
109
|
+
d = tempfile.mkdtemp(prefix="render_", dir=cache)
|
|
110
|
+
stem = os.path.join(d, "p")
|
|
111
|
+
rr = subprocess.run(["pdftoppm", "-png", "-r", str(dpi), "-f", str(page), "-l", str(page), pdf, stem],
|
|
112
|
+
capture_output=True, timeout=300)
|
|
113
|
+
pngs = [os.path.join(d, f) for f in sorted(os.listdir(d)) if f.endswith(".png")]
|
|
114
|
+
if rr.returncode != 0:
|
|
115
|
+
e = next((x for x in rr.stderr.decode("utf-8", "replace").splitlines() if x.strip()), "(no stderr)")
|
|
116
|
+
return _Refusal(f"pdftoppm exited {rr.returncode}; the page was never rendered: {e.strip()[:120]}"), None
|
|
117
|
+
if not pngs:
|
|
118
|
+
return _Refusal(f"pdftoppm exited 0 but produced no image for page {page}"), None
|
|
119
|
+
png = pngs[0]
|
|
120
|
+
if region:
|
|
121
|
+
try:
|
|
122
|
+
from PIL import Image
|
|
123
|
+
except Exception:
|
|
124
|
+
return None, png # caller refuses: no silent whole-page fallback
|
|
125
|
+
img = Image.open(png)
|
|
126
|
+
W, H = img.size
|
|
127
|
+
x0, y0, x1, y1 = region
|
|
128
|
+
px, py = 0.02, 0.01 # pad the crop so a boundary glyph keeps its whole body
|
|
129
|
+
img = img.crop((max(0, int((x0 - px) * W)), max(0, int((y0 - py) * H)),
|
|
130
|
+
min(W, int((x1 + px) * W)), min(H, int((y1 + py) * H))))
|
|
131
|
+
img.save(png)
|
|
132
|
+
# The tesseract binary is used directly; a Python wrapper was observed to
|
|
133
|
+
# return an empty string with no error on one interpreter.
|
|
134
|
+
r = subprocess.run(["tesseract", png, "stdout"], capture_output=True, timeout=300)
|
|
135
|
+
if r.returncode != 0:
|
|
136
|
+
e = next((x for x in r.stderr.decode("utf-8", "replace").splitlines() if x.strip()), "(no stderr)")
|
|
137
|
+
return _Refusal(f"tesseract exited {r.returncode}; OCR never read the page: {e.strip()[:120]}"), png
|
|
138
|
+
return r.stdout.decode("utf-8", "replace"), png
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def parse_region(region):
|
|
142
|
+
if region is None or (isinstance(region, str) and region.strip().lower() in ("", "page")):
|
|
143
|
+
return None
|
|
144
|
+
if isinstance(region, str):
|
|
145
|
+
region = [float(x) for x in region.split(",")]
|
|
146
|
+
region = tuple(float(x) for x in region)
|
|
147
|
+
if len(region) != 4 or not all(0.0 <= v <= 1.0 for v in region):
|
|
148
|
+
raise ValueError("region must be four fractions x0,y0,x1,y1 between 0 and 1")
|
|
149
|
+
return region
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def check_legibility(pdf: str, require: list[str] | None = None, page: int = 1,
|
|
153
|
+
region=None, auto: bool = True, dpi: int = 300,
|
|
154
|
+
ignore_words: list[str] | None = None,
|
|
155
|
+
keep_render: str | None = None) -> dict[str, Any]:
|
|
156
|
+
"""Return {"verdict": PASS|REFUSE, "rows": [...], ...}. Exit-code mapping
|
|
157
|
+
for the CLI: PASS 0, a finding against the document 1, a tool failure 2."""
|
|
158
|
+
require = [r for r in (require or []) if r and r.strip()]
|
|
159
|
+
if not os.path.isfile(pdf):
|
|
160
|
+
return {"verdict": "REFUSE", "kind": "usage", "reason": f"no such file: {pdf}"}
|
|
161
|
+
missing = _missing_binaries()
|
|
162
|
+
if missing:
|
|
163
|
+
return {"verdict": "REFUSE", "kind": "tool_failure",
|
|
164
|
+
"reason": "missing required programs: " + ", ".join(missing),
|
|
165
|
+
"install": "macOS: brew install poppler tesseract; Debian/Ubuntu: apt install poppler-utils tesseract-ocr"}
|
|
166
|
+
try:
|
|
167
|
+
reg = parse_region(region)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return {"verdict": "REFUSE", "kind": "usage", "reason": str(e)}
|
|
170
|
+
|
|
171
|
+
tl_raw = text_layer(pdf, page)
|
|
172
|
+
if isinstance(tl_raw, _Refusal):
|
|
173
|
+
return {"verdict": "REFUSE", "kind": "tool_failure", "reason": tl_raw.reason}
|
|
174
|
+
ocr_raw, png = render_ocr(pdf, page, dpi, reg)
|
|
175
|
+
if isinstance(ocr_raw, _Refusal):
|
|
176
|
+
return {"verdict": "REFUSE", "kind": "tool_failure", "reason": ocr_raw.reason,
|
|
177
|
+
"note": "no legibility finding is made; an empty OCR result is not evidence"}
|
|
178
|
+
if ocr_raw is None:
|
|
179
|
+
return {"verdict": "REFUSE", "kind": "tool_failure",
|
|
180
|
+
"reason": "region scoping needs Pillow (pip install pillow), or pass region='page'"}
|
|
181
|
+
|
|
182
|
+
auto_words: list[str] = []
|
|
183
|
+
if auto and reg:
|
|
184
|
+
got = region_words(pdf, page, reg)
|
|
185
|
+
if got is None:
|
|
186
|
+
return {"verdict": "REFUSE", "kind": "tool_failure",
|
|
187
|
+
"reason": "could not read the text layer to derive the auto requirement set"}
|
|
188
|
+
skip = {w.lower() for w in (ignore_words or [])}
|
|
189
|
+
auto_words = [w for w in got if w.lower() not in skip]
|
|
190
|
+
|
|
191
|
+
checks = [(w, "auto") for w in auto_words] + [(w, "required") for w in require]
|
|
192
|
+
if not checks:
|
|
193
|
+
return {"verdict": "REFUSE", "kind": "usage",
|
|
194
|
+
"reason": "nothing to check: give values in 'require', or a region with text for auto mode"}
|
|
195
|
+
|
|
196
|
+
tl, ocr = norm(tl_raw), norm(ocr_raw)
|
|
197
|
+
rows, refuse = [], False
|
|
198
|
+
for want, origin in checks:
|
|
199
|
+
n = norm(want)
|
|
200
|
+
if not n:
|
|
201
|
+
continue
|
|
202
|
+
in_text, in_ocr = n in tl, n in ocr
|
|
203
|
+
if in_text and in_ocr:
|
|
204
|
+
v = "VISIBLE"
|
|
205
|
+
elif in_text:
|
|
206
|
+
v = "INVISIBLE"; refuse = True
|
|
207
|
+
elif in_ocr:
|
|
208
|
+
v = "RENDER-ONLY"; refuse = True
|
|
209
|
+
else:
|
|
210
|
+
v = "MISSING"; refuse = True
|
|
211
|
+
rows.append({"value": want, "source": origin, "in_text_layer": in_text,
|
|
212
|
+
"in_render": in_ocr, "verdict": v})
|
|
213
|
+
kept = None
|
|
214
|
+
if keep_render and png:
|
|
215
|
+
try:
|
|
216
|
+
shutil.copy2(png, keep_render)
|
|
217
|
+
kept = keep_render
|
|
218
|
+
except Exception:
|
|
219
|
+
kept = None
|
|
220
|
+
return {
|
|
221
|
+
"verdict": "REFUSE" if refuse else "PASS",
|
|
222
|
+
"kind": "finding" if refuse else "pass",
|
|
223
|
+
"file": pdf, "page": page, "dpi": dpi,
|
|
224
|
+
"scope": f"region {reg}" if reg else "whole page",
|
|
225
|
+
"rows": rows,
|
|
226
|
+
"auto_values": len(auto_words), "required_values": len(require),
|
|
227
|
+
"render_kept": kept,
|
|
228
|
+
"limit": "legibility only; says nothing about whether values are correct",
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def main(argv=None) -> int:
|
|
233
|
+
ap = argparse.ArgumentParser(description="Prove filled PDF values are visible on the rendered page.")
|
|
234
|
+
ap.add_argument("pdf")
|
|
235
|
+
ap.add_argument("--require", action="append", default=[], help="a value that must be legible")
|
|
236
|
+
ap.add_argument("--page", type=int, default=1)
|
|
237
|
+
ap.add_argument("--dpi", type=int, default=300)
|
|
238
|
+
ap.add_argument("--region", default="page", help="x0,y0,x1,y1 as page fractions, or 'page'")
|
|
239
|
+
ap.add_argument("--no-auto", action="store_true", help="only check --require values")
|
|
240
|
+
ap.add_argument("--keep-render", default=None, help="copy the rendered PNG here")
|
|
241
|
+
a = ap.parse_args(argv)
|
|
242
|
+
res = check_legibility(a.pdf, a.require, a.page, a.region, not a.no_auto, a.dpi,
|
|
243
|
+
keep_render=a.keep_render)
|
|
244
|
+
print(f"PDF FORM PROOF - legibility check verdict: {res['verdict']}")
|
|
245
|
+
for k in ("reason", "install", "note"):
|
|
246
|
+
if k in res:
|
|
247
|
+
print(f" {k}: {res[k]}")
|
|
248
|
+
if res.get("rows"):
|
|
249
|
+
print(f" file: {os.path.basename(a.pdf)} page {res['page']} at {res['dpi']} dpi scope: {res['scope']}")
|
|
250
|
+
w = max(len(r["value"]) for r in res["rows"])
|
|
251
|
+
print(f" {'VALUE':<{w}} SOURCE TEXT RENDER VERDICT")
|
|
252
|
+
for r in res["rows"]:
|
|
253
|
+
print(f" {r['value']:<{w}} {r['source']:<8} {'yes' if r['in_text_layer'] else 'no':<4} "
|
|
254
|
+
f"{'yes' if r['in_render'] else 'no':<6} {r['verdict']}")
|
|
255
|
+
print(f" limit: {res['limit']}")
|
|
256
|
+
if res["verdict"] == "PASS":
|
|
257
|
+
return 0
|
|
258
|
+
return 1 if res.get("kind") == "finding" else 2
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
if __name__ == "__main__":
|
|
262
|
+
sys.exit(main())
|
pdf_form_proof/server.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""MCP server (stdio) for pdf-form-proof.
|
|
2
|
+
|
|
3
|
+
Tools:
|
|
4
|
+
pdf_form_inspect - list every fillable field, its type, and checkbox on-states
|
|
5
|
+
pdf_form_fill - fill by exact field name; refuses unknown names; never overwrites
|
|
6
|
+
pdf_form_verify - prove the values are visible on the rendered page (OCR vs text layer)
|
|
7
|
+
pdf_sha256 - SHA-256 of a file, for a record of exactly what was produced
|
|
8
|
+
|
|
9
|
+
Everything runs locally. No network calls, no API keys, no telemetry.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from mcp.server.fastmcp import FastMCP
|
|
17
|
+
from mcp.types import ToolAnnotations
|
|
18
|
+
|
|
19
|
+
from . import __version__
|
|
20
|
+
from .fill import fill_form, inspect_form, sha256_file
|
|
21
|
+
from .legibility import check_legibility
|
|
22
|
+
|
|
23
|
+
mcp = FastMCP(
|
|
24
|
+
"pdf-form-proof",
|
|
25
|
+
instructions=(
|
|
26
|
+
"Fill fillable (AcroForm) PDF forms and prove the result. Workflow: "
|
|
27
|
+
"1) pdf_form_inspect to get exact field names and checkbox on-states; "
|
|
28
|
+
"2) pdf_form_fill with those exact names (unknown names are refused, nothing written); "
|
|
29
|
+
"3) pdf_form_verify with the filled values to confirm each one is visible on the "
|
|
30
|
+
"rendered page, not only stored in the file. Report REFUSE verdicts to the user as-is. "
|
|
31
|
+
"This tool fills forms; it does not give legal, tax, or benefits advice."
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool(
|
|
37
|
+
title="Inspect PDF form fields",
|
|
38
|
+
annotations=ToolAnnotations(title="Inspect PDF form fields", readOnlyHint=True,
|
|
39
|
+
destructiveHint=False, openWorldHint=False),
|
|
40
|
+
)
|
|
41
|
+
def pdf_form_inspect(pdf_path: str) -> dict[str, Any]:
|
|
42
|
+
"""List every fillable field in a PDF: exact name, type (text, button, choice,
|
|
43
|
+
signature), current value, required flag, and for checkboxes/radios the on-state
|
|
44
|
+
values that check them. Use the names exactly as returned when filling."""
|
|
45
|
+
return inspect_form(pdf_path)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool(
|
|
49
|
+
title="Fill PDF form",
|
|
50
|
+
annotations=ToolAnnotations(title="Fill PDF form", readOnlyHint=False,
|
|
51
|
+
destructiveHint=False, idempotentHint=False, openWorldHint=False),
|
|
52
|
+
)
|
|
53
|
+
def pdf_form_fill(source_pdf_path: str, output_path: str, values: dict[str, Any],
|
|
54
|
+
strict: bool = True, finalize: bool = False) -> dict[str, Any]:
|
|
55
|
+
"""Fill an AcroForm PDF and write a NEW file (the source is never modified and an
|
|
56
|
+
existing output is never overwritten). values maps exact field names to values:
|
|
57
|
+
strings for text fields; an on-state such as "/1" or "/Yes" (or true) to check a box,
|
|
58
|
+
"" or false to clear it. strict=true refuses the whole fill if any name is unknown.
|
|
59
|
+
finalize=true removes the XFA packet and NeedAppearances flag so viewers render the
|
|
60
|
+
values from their own appearance streams (fields stay editable; this is not flattening).
|
|
61
|
+
Returns the output SHA-256 and a read-back of every text value."""
|
|
62
|
+
return fill_form(source_pdf_path, output_path, values, strict=strict, finalize=finalize)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
@mcp.tool(
|
|
66
|
+
title="Verify filled values are visible",
|
|
67
|
+
annotations=ToolAnnotations(title="Verify filled values are visible", readOnlyHint=True,
|
|
68
|
+
destructiveHint=False, openWorldHint=False),
|
|
69
|
+
)
|
|
70
|
+
def pdf_form_verify(pdf_path: str, values: list[str] | None = None, page: int = 1,
|
|
71
|
+
region: str = "page", auto: bool = True, dpi: int = 300) -> dict[str, Any]:
|
|
72
|
+
"""Render one page, OCR it, and compare with the PDF text layer. Each value gets a
|
|
73
|
+
verdict: VISIBLE, INVISIBLE (in the file but not on the page), RENDER-ONLY, or MISSING.
|
|
74
|
+
region is "page" or "x0,y0,x1,y1" as page fractions; with a region and auto=true every
|
|
75
|
+
word the text layer places in that region must also be legible. A tool failure
|
|
76
|
+
(missing poppler or tesseract, render error) returns REFUSE with the reason and makes
|
|
77
|
+
no finding about the document. Proves legibility only, not correctness."""
|
|
78
|
+
return check_legibility(pdf_path, require=values or [], page=page, region=region,
|
|
79
|
+
auto=auto, dpi=dpi)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@mcp.tool(
|
|
83
|
+
title="SHA-256 of a file",
|
|
84
|
+
annotations=ToolAnnotations(title="SHA-256 of a file", readOnlyHint=True,
|
|
85
|
+
destructiveHint=False, openWorldHint=False),
|
|
86
|
+
)
|
|
87
|
+
def pdf_sha256(path: str) -> dict[str, Any]:
|
|
88
|
+
"""Return the SHA-256 digest of a file so the exact output can be recorded."""
|
|
89
|
+
return {"path": path, "sha256": sha256_file(path), "server_version": __version__}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def main() -> None:
|
|
93
|
+
mcp.run()
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
main()
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pdf-form-proof
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Fill fillable PDF forms, then prove every value is visible on the rendered page. MCP server and CLI.
|
|
5
|
+
Project-URL: Homepage, https://github.com/EssenceAlignment/pdf-form-proof
|
|
6
|
+
Project-URL: Issues, https://github.com/EssenceAlignment/pdf-form-proof/issues
|
|
7
|
+
Author: EssenceAlignment
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: acroform,form-filling,mcp,nonprofit,ocr,pdf,pdf-form,verification
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Office/Business
|
|
14
|
+
Requires-Python: >=3.10
|
|
15
|
+
Requires-Dist: mcp<2,>=1.10
|
|
16
|
+
Requires-Dist: pillow>=10.0
|
|
17
|
+
Requires-Dist: pypdf>=4.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pdf-form-proof
|
|
21
|
+
|
|
22
|
+
Fill a fillable PDF form, then prove every value actually shows on the page.
|
|
23
|
+
|
|
24
|
+
mcp-name: io.github.EssenceAlignment/pdf-form-proof
|
|
25
|
+
|
|
26
|
+
A PDF can hold a value in its data while the page a person sees, prints, or
|
|
27
|
+
uploads shows nothing, or shows it cut off. Most form tools stop at "the field
|
|
28
|
+
was set." This one keeps going: it renders the page, reads it back with OCR
|
|
29
|
+
(optical character recognition, software that reads text from an image), and
|
|
30
|
+
compares that with the text stored in the file. Each value comes back VISIBLE,
|
|
31
|
+
INVISIBLE, RENDER-ONLY, or MISSING.
|
|
32
|
+
|
|
33
|
+
It works as an MCP server (MCP, the Model Context Protocol, is the plug-in
|
|
34
|
+
standard that lets AI apps such as Claude call outside tools), as a Claude
|
|
35
|
+
plugin, as a Gemini CLI extension, and as plain command-line tools.
|
|
36
|
+
|
|
37
|
+
## What it does
|
|
38
|
+
|
|
39
|
+
| Tool | What it does | Changes files? |
|
|
40
|
+
|---|---|---|
|
|
41
|
+
| `pdf_form_inspect` | Lists every fillable field: exact name, type, current value, required flag, and the values that check each checkbox or radio button | No |
|
|
42
|
+
| `pdf_form_fill` | Fills fields by exact name and writes a new PDF. Refuses the whole fill if any name is not in the form. Never modifies the source, never overwrites an existing file. Returns the new file's SHA-256 and reads every text value back | Writes one new file |
|
|
43
|
+
| `pdf_form_verify` | Renders a page, OCRs it, and gives each value a verdict. Can be limited to one region of the page so a copy of the value elsewhere cannot make it pass | No |
|
|
44
|
+
| `pdf_sha256` | SHA-256 fingerprint of a file, for a record of exactly what was produced | No |
|
|
45
|
+
|
|
46
|
+
Every tool carries MCP annotations: `readOnlyHint` and `destructiveHint` are set on all four.
|
|
47
|
+
|
|
48
|
+
## Who it is for
|
|
49
|
+
|
|
50
|
+
Anyone who fills the same official PDFs again and again: small nonprofits and
|
|
51
|
+
community organizations (vendor forms such as the IRS W-9, grant and funder
|
|
52
|
+
forms, program intake and consent forms), operations staff, and builders who
|
|
53
|
+
want an AI assistant to fill forms without hiding a partial or invisible fill.
|
|
54
|
+
|
|
55
|
+
## Install
|
|
56
|
+
|
|
57
|
+
You need Python 3.10 or newer and `uv` (https://docs.astral.sh/uv/). The verify
|
|
58
|
+
tool also needs two free programs:
|
|
59
|
+
|
|
60
|
+
- macOS: `brew install poppler tesseract`
|
|
61
|
+
- Debian or Ubuntu: `sudo apt install poppler-utils tesseract-ocr`
|
|
62
|
+
|
|
63
|
+
### Claude Desktop (MCP server)
|
|
64
|
+
|
|
65
|
+
Add this to `claude_desktop_config.json`:
|
|
66
|
+
|
|
67
|
+
```json
|
|
68
|
+
{
|
|
69
|
+
"mcpServers": {
|
|
70
|
+
"pdf-form-proof": {
|
|
71
|
+
"command": "uvx",
|
|
72
|
+
"args": ["pdf-form-proof"]
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Or install the `.mcpb` file from the latest GitHub release: double-click it, or
|
|
79
|
+
use Settings > Extensions > Advanced settings > Install Extension.
|
|
80
|
+
|
|
81
|
+
### Claude Code (plugin)
|
|
82
|
+
|
|
83
|
+
```
|
|
84
|
+
claude plugin marketplace add EssenceAlignment/pdf-form-proof
|
|
85
|
+
claude plugin install pdf-form-proof@pdf-form-proof
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### Gemini CLI (extension)
|
|
89
|
+
|
|
90
|
+
```
|
|
91
|
+
gemini extensions install https://github.com/EssenceAlignment/pdf-form-proof
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
### Command line
|
|
95
|
+
|
|
96
|
+
```
|
|
97
|
+
uvx --from pdf-form-proof pdf-form-inspect form.pdf
|
|
98
|
+
uvx --from pdf-form-proof pdf-form-fill form.pdf filled.pdf values.json
|
|
99
|
+
uvx --from pdf-form-proof pdf-form-verify filled.pdf --require "Sample Community Food Bank"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`values.json` maps exact field names (from `pdf-form-inspect`) to values:
|
|
103
|
+
|
|
104
|
+
```json
|
|
105
|
+
{
|
|
106
|
+
"topmostSubform[0].Page1[0].f1_01[0]": "Sample Community Food Bank",
|
|
107
|
+
"topmostSubform[0].Page1[0].Boxes3a-b_ReadOrder[0].c1_1[0]": "/1"
|
|
108
|
+
}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Text fields take a string. A checkbox or radio takes one of its on-state values
|
|
112
|
+
(shown by inspect, for example "/1" or "/Yes"), or an empty string to clear it.
|
|
113
|
+
|
|
114
|
+
Exit codes: 0 success; 1 a finding (verify found a value that is not visible,
|
|
115
|
+
or a fill could not be read back); 2 refused (unknown field names, a missing
|
|
116
|
+
file, an existing output, or a tool such as tesseract failed).
|
|
117
|
+
|
|
118
|
+
## Example
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
$ pdf-form-verify filled.pdf --require "Sample Community Food Bank" --require "Sample Pantry Program"
|
|
122
|
+
PDF FORM PROOF - legibility check verdict: PASS
|
|
123
|
+
file: filled.pdf page 1 at 300 dpi scope: whole page
|
|
124
|
+
VALUE SOURCE TEXT RENDER VERDICT
|
|
125
|
+
Sample Community Food Bank required yes yes VISIBLE
|
|
126
|
+
Sample Pantry Program required yes yes VISIBLE
|
|
127
|
+
limit: legibility only; says nothing about whether values are correct
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
## Limits (read these)
|
|
131
|
+
|
|
132
|
+
- AcroForm forms only. XFA-only forms (Adobe LiveCycle dynamic forms) are
|
|
133
|
+
detected and refused, not filled.
|
|
134
|
+
- `finalize` removes the XFA packet and the NeedAppearances flag so viewers draw
|
|
135
|
+
the values from their own appearance streams. It does not flatten the form;
|
|
136
|
+
fields stay editable.
|
|
137
|
+
- Verify proves legibility, not correctness. It cannot tell you whether a value
|
|
138
|
+
is the right answer for the form.
|
|
139
|
+
- OCR can misread small, stylized, or handwritten-style fonts. A REFUSE verdict
|
|
140
|
+
means "look at the page," not "the file is broken." A tool failure (missing
|
|
141
|
+
poppler or tesseract, a render error) is reported as a tool failure, never as
|
|
142
|
+
a finding about your document.
|
|
143
|
+
- Signatures are not created. Signature fields are listed by inspect and left alone.
|
|
144
|
+
- This tool fills forms. It does not give legal, tax, benefits, or other
|
|
145
|
+
professional advice, and it does not decide what belongs in any field.
|
|
146
|
+
|
|
147
|
+
## Privacy policy
|
|
148
|
+
|
|
149
|
+
pdf-form-proof runs entirely on your own computer.
|
|
150
|
+
|
|
151
|
+
- Data collection: none. It reads only the PDF files you name and writes only
|
|
152
|
+
the output path you name.
|
|
153
|
+
- Usage and storage: rendered page images are kept in `~/.cache/pdf-form-proof`
|
|
154
|
+
only until the next verify run, which deletes them.
|
|
155
|
+
- Third-party sharing: none. It makes no network calls and sends no telemetry.
|
|
156
|
+
- Retention: nothing is retained beyond the files you create.
|
|
157
|
+
- Contact: open an issue at https://github.com/EssenceAlignment/pdf-form-proof/issues
|
|
158
|
+
|
|
159
|
+
## Origin
|
|
160
|
+
|
|
161
|
+
The fill engine and the visible-on-the-page check were built for a production
|
|
162
|
+
document pipeline after a filled form passed every text-based check and was
|
|
163
|
+
still returned because a value could not be seen on the page. The two rules
|
|
164
|
+
that came out of that: never hide a partial fill, and check the page a person
|
|
165
|
+
sees, not only the data in the file.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
MIT. See `LICENSE`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
pdf_form_proof/__init__.py,sha256=hfq_9_Y9ooxdAk13GraNyPTiea8ZrixrmCSLlZZFEp0,109
|
|
2
|
+
pdf_form_proof/_acroform.py,sha256=TjQl-YFcHwt_ClfNwfyb93qtkTWe6NDOWCtBjZ97JIU,8538
|
|
3
|
+
pdf_form_proof/cli.py,sha256=_EkDQ34zXzYTxY8Ftsh5Kj0sBSwx1f5YoYf_ajUYGS8,1281
|
|
4
|
+
pdf_form_proof/fill.py,sha256=caBqAxcJR8EEWAoPGWzSoPVUwu006A9jlKNdMutyUAg,7937
|
|
5
|
+
pdf_form_proof/legibility.py,sha256=o1-1BvoEaJmUztzqzklEd49Nm6NuYSe4C5xTC4WN5tI,11735
|
|
6
|
+
pdf_form_proof/server.py,sha256=TizEJKhEVoU0SZL5RZIjsVJutnsXnMMbF6RRNqfdxbI,4501
|
|
7
|
+
pdf_form_proof-0.1.0.dist-info/METADATA,sha256=iFMpxkdq9BNdpgyc_wOjjXLNHP_O5sWtTlo1oxyB3fE,6797
|
|
8
|
+
pdf_form_proof-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
pdf_form_proof-0.1.0.dist-info/entry_points.txt,sha256=zjjAIheRLuJHvRmUHfetpLpSuaYdCU4SL9o9un9qQrg,207
|
|
10
|
+
pdf_form_proof-0.1.0.dist-info/licenses/LICENSE,sha256=DigQ3iN0PR99HtC7Uc_mpZwtOj4-n4ddoYWt0JJiYg8,1073
|
|
11
|
+
pdf_form_proof-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 EssenceAlignment
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|