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.
@@ -0,0 +1,3 @@
1
+ """pdf-form-proof: fill fillable PDF forms, then prove every value is on the page."""
2
+
3
+ __version__ = "0.1.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("&amp;", "&").replace("&lt;", "<").replace("&gt;", ">").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())
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,5 @@
1
+ [console_scripts]
2
+ pdf-form-fill = pdf_form_proof.cli:fill_main
3
+ pdf-form-inspect = pdf_form_proof.cli:inspect_main
4
+ pdf-form-proof = pdf_form_proof.server:main
5
+ pdf-form-verify = pdf_form_proof.legibility:main
@@ -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.