dsh-office-kit 0.2.0 → 0.3.0

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.
Files changed (197) hide show
  1. package/CAPABILITY.md +147 -68
  2. package/README.md +3 -1
  3. package/assets/skills/excel-live-control/SKILL.md +234 -0
  4. package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
  5. package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
  6. package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
  7. package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
  8. package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
  9. package/assets/skills/excel-live-control/references/office-automation.md +207 -0
  10. package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
  11. package/assets/skills/excel-live-control/style_guidelines.md +87 -0
  12. package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
  13. package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
  14. package/assets/skills/office-documents/SKILL.md +90 -77
  15. package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
  16. package/assets/skills/office-documents/ooxml/comments.md +40 -0
  17. package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
  18. package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
  19. package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
  20. package/assets/skills/office-documents/references/docx-structure.md +83 -0
  21. package/assets/skills/office-documents/references/template-elicitation.md +49 -0
  22. package/assets/skills/office-documents/references/writing-quality.md +49 -0
  23. package/assets/skills/office-documents/render_docx.py +92 -0
  24. package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
  25. package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
  26. package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
  27. package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
  28. package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
  29. package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
  30. package/assets/skills/office-documents/scripts/comments_add.py +136 -0
  31. package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
  32. package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
  33. package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
  34. package/assets/skills/office-documents/scripts/content_controls.py +192 -0
  35. package/assets/skills/office-documents/scripts/core_properties.py +124 -0
  36. package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
  37. package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
  38. package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
  39. package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
  40. package/assets/skills/office-documents/scripts/fields_report.py +59 -0
  41. package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
  42. package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
  43. package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
  44. package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
  45. package/assets/skills/office-documents/scripts/images_audit.py +80 -0
  46. package/assets/skills/office-documents/scripts/insert_note.py +126 -0
  47. package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
  48. package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
  49. package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
  50. package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
  51. package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
  52. package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
  53. package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
  54. package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
  55. package/assets/skills/office-documents/scripts/replace_text.py +154 -0
  56. package/assets/skills/office-documents/scripts/section_audit.py +91 -0
  57. package/assets/skills/office-documents/scripts/set_protection.py +92 -0
  58. package/assets/skills/office-documents/scripts/style_lint.py +111 -0
  59. package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
  60. package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
  61. package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
  62. package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
  63. package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
  64. package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
  65. package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
  66. package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
  67. package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
  68. package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
  69. package/assets/skills/office-documents/tasks/create_edit.md +107 -0
  70. package/assets/skills/office-documents/tasks/equations.md +59 -0
  71. package/assets/skills/office-documents/tasks/fields_update.md +44 -0
  72. package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
  73. package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
  74. package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
  75. package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
  76. package/assets/skills/office-documents/tasks/images_figures.md +63 -0
  77. package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
  78. package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
  79. package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
  80. package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
  81. package/assets/skills/office-documents/tasks/read_review.md +50 -0
  82. package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
  83. package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
  84. package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
  85. package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
  86. package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
  87. package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
  88. package/assets/skills/office-documents/tasks/verify_render.md +50 -0
  89. package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
  90. package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
  91. package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
  92. package/assets/skills/office-pdf/SKILL.md +73 -56
  93. package/assets/skills/office-pdf/references/form-fields.md +56 -0
  94. package/assets/skills/office-pdf/references/geometry.md +44 -0
  95. package/assets/skills/office-pdf/references/layout-quality.md +41 -0
  96. package/assets/skills/office-pdf/references/limits.md +58 -0
  97. package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
  98. package/assets/skills/office-pdf/scripts/forms.py +556 -0
  99. package/assets/skills/office-pdf/scripts/metadata.py +161 -0
  100. package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
  101. package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
  102. package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
  103. package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
  104. package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
  105. package/assets/skills/office-pdf/tasks/annotations.md +98 -0
  106. package/assets/skills/office-pdf/tasks/create.md +75 -0
  107. package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
  108. package/assets/skills/office-pdf/tasks/forms.md +67 -0
  109. package/assets/skills/office-pdf/tasks/read.md +62 -0
  110. package/assets/skills/office-pdf/tasks/redact.md +72 -0
  111. package/assets/skills/office-pdf/tasks/stamp.md +60 -0
  112. package/assets/skills/office-presentations/SKILL.md +141 -87
  113. package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
  114. package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
  115. package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
  116. package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
  117. package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
  118. package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
  119. package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
  120. package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
  121. package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
  122. package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
  123. package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
  124. package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
  125. package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
  126. package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
  127. package/assets/skills/office-presentations/recipes/README.md +71 -0
  128. package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
  129. package/assets/skills/office-presentations/recipes/connectors.md +83 -0
  130. package/assets/skills/office-presentations/recipes/images.md +80 -0
  131. package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
  132. package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
  133. package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
  134. package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
  135. package/assets/skills/office-presentations/recipes/tables.md +88 -0
  136. package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
  137. package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
  138. package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
  139. package/assets/skills/office-presentations/references/finalization.md +63 -0
  140. package/assets/skills/office-presentations/references/native-bullets.md +78 -0
  141. package/assets/skills/office-presentations/references/native-evidence.md +67 -0
  142. package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
  143. package/assets/skills/office-presentations/references/templates.md +76 -0
  144. package/assets/skills/office-presentations/routing/google_slides.md +45 -0
  145. package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
  146. package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
  147. package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
  148. package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
  149. package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
  150. package/assets/skills/office-presentations/tasks/template-following.md +132 -0
  151. package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
  152. package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
  153. package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
  154. package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
  155. package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
  156. package/assets/skills/office-spreadsheets/SKILL.md +105 -101
  157. package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
  158. package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
  159. package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
  160. package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
  161. package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
  162. package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
  163. package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
  164. package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
  165. package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
  166. package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
  167. package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
  168. package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
  169. package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
  170. package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
  171. package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
  172. package/assets/skills/office-spreadsheets/references/charts.md +63 -0
  173. package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
  174. package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
  175. package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
  176. package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
  177. package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
  178. package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
  179. package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
  180. package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
  181. package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
  182. package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
  183. package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
  184. package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
  185. package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
  186. package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
  187. package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
  188. package/assets/skills/shared/annotation-recipes.md +252 -0
  189. package/assets/skills/shared/check_runtime.py +253 -0
  190. package/assets/skills/shared/common-workflow.md +139 -0
  191. package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
  192. package/dist/client.js +4 -4
  193. package/dist/client.js.map +1 -1
  194. package/dist/index.js +239 -89
  195. package/package.json +1 -1
  196. package/postinstall.mjs +92 -82
  197. package/python/office_cli.py +171 -246
@@ -0,0 +1,556 @@
1
+ #!/usr/bin/env python3
2
+ """Inspect and fill AcroForm fields in a PDF, then re-check what was written.
3
+
4
+ A widget annotation can paint one value while the canonical field tree holds a
5
+ different one, so this tool treats the file as two views of the same data and
6
+ compares them after saving. `inspect` reports both views, plus orphaned widgets
7
+ and name collisions. `fill` repairs orphans, writes values, can flatten, then
8
+ reopens the result and fails loudly when a value or an appearance is missing.
9
+
10
+ Exit codes: 0 success, 1 bad arguments, 2 unreadable input or write failure,
11
+ 3 refused (missing fields, ambiguous widgets, unsafe overwrite),
12
+ 4 written file failed verification.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import sys
20
+ from pathlib import Path
21
+
22
+ for _stream in (sys.stdout, sys.stderr):
23
+ try:
24
+ _stream.reconfigure(encoding="utf-8", errors="replace")
25
+ except (AttributeError, ValueError):
26
+ pass
27
+
28
+ try:
29
+ from pypdf.generic import IndirectObject, NameObject
30
+ except ImportError: # pragma: no cover - pypdf is a required dependency
31
+ IndirectObject = None # type: ignore[assignment]
32
+ NameObject = None # type: ignore[assignment]
33
+
34
+
35
+ def emit(value, out=None):
36
+ text = json.dumps(value, ensure_ascii=False, indent=2, default=str)
37
+ if out:
38
+ target = Path(out).expanduser()
39
+ target.parent.mkdir(parents=True, exist_ok=True)
40
+ target.write_text(text + "\n", encoding="utf-8")
41
+ print(text)
42
+
43
+
44
+ def fail(message, code=1, detail=None):
45
+ payload = {"ok": False, "error": message}
46
+ if detail is not None:
47
+ payload["detail"] = detail
48
+ print(json.dumps(payload, ensure_ascii=False, indent=2, default=str), file=sys.stderr)
49
+ raise SystemExit(code)
50
+
51
+
52
+ def ref_id(node):
53
+ if isinstance(node, IndirectObject):
54
+ return node.idnum
55
+ return None
56
+
57
+
58
+ def load_values(spec):
59
+ if spec == "-":
60
+ raw = sys.stdin.read()
61
+ elif spec.strip().startswith(("{", "[")):
62
+ raw = spec
63
+ else:
64
+ path = Path(spec).expanduser()
65
+ if not path.is_file():
66
+ fail(f"values file not found: {path}", code=2)
67
+ raw = path.read_text(encoding="utf-8-sig")
68
+ try:
69
+ data = json.loads(raw)
70
+ except json.JSONDecodeError as exc:
71
+ fail(f"invalid values JSON: {exc}")
72
+ if isinstance(data, dict) and isinstance(data.get("fields"), dict):
73
+ data = data["fields"]
74
+ if not isinstance(data, dict):
75
+ fail('values must be a JSON object of {field name: value}')
76
+ return data
77
+
78
+
79
+ def field_tree(reader):
80
+ """Walk /AcroForm/Fields and return terminal fields with their references."""
81
+ entries = []
82
+ try:
83
+ root = reader.trailer["/Root"]
84
+ acroform = root.get("/AcroForm")
85
+ except Exception:
86
+ acroform = None
87
+ if acroform is None:
88
+ return entries
89
+ fields = acroform.get_object().get("/Fields") or []
90
+ for node in fields:
91
+ walk_field(node, "", entries, 0)
92
+ return entries
93
+
94
+
95
+ def walk_field(node, prefix, entries, depth):
96
+ if depth > 12 or node is None:
97
+ return
98
+ obj = node.get_object()
99
+ tag = obj.get("/T")
100
+ name = prefix
101
+ if tag is not None:
102
+ name = f"{prefix}.{tag}" if prefix else str(tag)
103
+ raw_kids = list(obj.get("/Kids") or [])
104
+ kids = [kid.get_object() for kid in raw_kids]
105
+ child_fields = [kid for kid in kids if kid.get("/T") is not None]
106
+ if obj.get("/FT") is not None and not child_fields:
107
+ widgets = [ref_id(kid) for kid, kid_obj in zip(raw_kids, kids) if kid_obj.get("/Subtype") == "/Widget"]
108
+ if not widgets and obj.get("/Subtype") == "/Widget":
109
+ widgets = [ref_id(node)]
110
+ entries.append(
111
+ {
112
+ "name": name,
113
+ "ref": ref_id(node),
114
+ "type": str(obj.get("/FT")) if obj.get("/FT") is not None else None,
115
+ "value": scalar(obj.get("/V")),
116
+ "default": scalar(obj.get("/DV")),
117
+ "flags": scalar(obj.get("/Ff")),
118
+ "options": [str(option) for option in (obj.get("/Opt") or [])],
119
+ "widget_refs": widgets,
120
+ "node": obj,
121
+ }
122
+ )
123
+ for kid_node, kid in zip(raw_kids, kids):
124
+ if kid.get("/T") is not None:
125
+ walk_field(kid_node, name, entries, depth + 1)
126
+
127
+
128
+ def scalar(value):
129
+ if value is None:
130
+ return None
131
+ try:
132
+ resolved = value.get_object()
133
+ except AttributeError:
134
+ resolved = value
135
+ if isinstance(resolved, (list, tuple)):
136
+ return [scalar(item) for item in resolved]
137
+ if hasattr(resolved, "get_data"):
138
+ return str(resolved)
139
+ text = str(resolved)
140
+ return text
141
+
142
+
143
+ def widget_name(widget):
144
+ node = widget
145
+ for _ in range(8):
146
+ tag = node.get("/T")
147
+ if tag is not None:
148
+ return str(tag)
149
+ parent = node.get("/Parent")
150
+ if parent is None:
151
+ return None
152
+ node = parent.get_object()
153
+ return None
154
+
155
+
156
+ def inherited(widget, key):
157
+ node = widget
158
+ for _ in range(8):
159
+ value = node.get(key)
160
+ if value is not None:
161
+ return value
162
+ parent = node.get("/Parent")
163
+ if parent is None:
164
+ return None
165
+ node = parent.get_object()
166
+ return None
167
+
168
+
169
+ def appearance(widget):
170
+ ap = widget.get("/AP")
171
+ if ap is None:
172
+ return "missing", []
173
+ normal = ap.get_object().get("/N")
174
+ if normal is None:
175
+ return "missing", []
176
+ normal = normal.get_object()
177
+ if hasattr(normal, "get_data"):
178
+ return ("stream" if normal.get_data() else "empty_stream"), []
179
+ if hasattr(normal, "keys"):
180
+ states = [str(key) for key in normal.keys()]
181
+ current = str(widget.get("/AS") or inherited(widget, "/V") or "")
182
+ entry = normal.get(current) if current else None
183
+ if entry is None:
184
+ return "states_without_current", states
185
+ entry = entry.get_object()
186
+ length = len(entry.get_data()) if hasattr(entry, "get_data") else 0
187
+ return ("states" if length else "states_empty_stream"), states
188
+ return "unknown", []
189
+
190
+
191
+ def inspect_document(reader):
192
+ entries = field_tree(reader)
193
+ names = [entry["name"] for entry in entries]
194
+ canonical = {entry["ref"]: entry for entry in entries if entry["ref"] is not None}
195
+
196
+ widgets = []
197
+ for page_number, page in enumerate(reader.pages, start=1):
198
+ for annotation in page.get("/Annots") or []:
199
+ obj = annotation.get_object()
200
+ if obj.get("/Subtype") != "/Widget":
201
+ continue
202
+ state, states = appearance(obj)
203
+ widgets.append(
204
+ {
205
+ "page": page_number,
206
+ "ref": ref_id(annotation),
207
+ "name": widget_name(obj),
208
+ "parent_ref": ref_id(obj.get("/Parent")) if obj.get("/Parent") is not None else None,
209
+ "rect": [round(float(value), 2) for value in (obj.get("/Rect") or [])],
210
+ "value": scalar(obj.get("/AS")) if obj.get("/AS") is not None else scalar(inherited(obj, "/V")),
211
+ "field_type": str(inherited(obj, "/FT")) if inherited(obj, "/FT") is not None else None,
212
+ "state": scalar(obj.get("/AS")),
213
+ "appearance": state,
214
+ "appearance_states": states,
215
+ "_obj": obj,
216
+ }
217
+ )
218
+
219
+ orphans = []
220
+ ambiguous = []
221
+ seen_widget_refs = {}
222
+ for widget in widgets:
223
+ name = widget["name"]
224
+ has_parent = widget["parent_ref"] is not None
225
+ if name is None:
226
+ orphans.append({"page": widget["page"], "ref": widget["ref"], "reason": "widget has no /T and no /Parent that names it"})
227
+ continue
228
+ if not has_parent and (widget["ref"] not in canonical):
229
+ orphans.append(
230
+ {
231
+ "page": widget["page"],
232
+ "ref": widget["ref"],
233
+ "name": name,
234
+ "reason": "widget is not reachable from /AcroForm/Fields",
235
+ }
236
+ )
237
+ if not has_parent and name in names:
238
+ field = next((entry for entry in entries if entry["name"] == name), None)
239
+ if field is not None and field["ref"] != widget["ref"]:
240
+ ambiguous.append(
241
+ {
242
+ "name": name,
243
+ "widget_ref": widget["ref"],
244
+ "field_ref": field["ref"],
245
+ "detail": "widget and canonical field share a name but are separate objects without a /Parent link",
246
+ }
247
+ )
248
+ seen_widget_refs.setdefault(widget["ref"], name)
249
+
250
+ duplicates = sorted({name for name in names if names.count(name) > 1})
251
+ for name in duplicates:
252
+ ambiguous.append({"name": name, "detail": "more than one top-level field carries this name"})
253
+
254
+ need_appearances = False
255
+ try:
256
+ acroform = reader.trailer["/Root"].get("/AcroForm")
257
+ if acroform is not None:
258
+ need_appearances = bool(acroform.get_object().get("/NeedAppearances", False))
259
+ except Exception:
260
+ need_appearances = False
261
+
262
+ return {
263
+ "field_count": len(entries),
264
+ "widget_count": len(widgets),
265
+ "fields": [{key: value for key, value in entry.items() if key != "node"} for entry in entries],
266
+ "widgets": [{key: value for key, value in widget.items() if key != "_obj"} for widget in widgets],
267
+ "orphans": orphans,
268
+ "ambiguous": ambiguous,
269
+ "need_appearances": need_appearances,
270
+ }
271
+
272
+
273
+ def normalise(value):
274
+ if isinstance(value, bool):
275
+ return "Yes" if value else "Off"
276
+ text = "" if value is None else str(value)
277
+ return text[1:] if text.startswith("/") else text
278
+
279
+
280
+ def values_for_field(field, value):
281
+ if isinstance(value, bool):
282
+ return "/Yes" if value else "/Off"
283
+ if isinstance(value, (list, tuple)):
284
+ return [str(item) for item in value]
285
+ return str(value)
286
+
287
+
288
+ def write_fill(args, values):
289
+ from pypdf import PdfReader, PdfWriter
290
+
291
+ reader = PdfReader(str(args.input))
292
+ report = inspect_document(reader)
293
+ if report["ambiguous"] and not (args.allow_ambiguous or args.flatten):
294
+ report.update(
295
+ {
296
+ "ok": False,
297
+ "error": "refusing to fill: the widget and field views disagree",
298
+ "hint": "inspect the report, then rerun with --flatten for a static result or --allow-ambiguous if you are certain",
299
+ }
300
+ )
301
+ emit(report, args.json_out)
302
+ raise SystemExit(3)
303
+
304
+ writer = PdfWriter()
305
+ writer.clone_document_from_reader(reader)
306
+
307
+ reattached = 0
308
+ if not args.no_reattach:
309
+ reattached = len(writer.reattach_fields() or [])
310
+
311
+ fields = writer.get_fields() or {}
312
+ expected = {name: values_for_field(fields[name], value) for name, value in values.items() if name in fields}
313
+ missing = sorted(set(values) - set(fields))
314
+ if missing:
315
+ result = {
316
+ "ok": False,
317
+ "error": "fields named in the values file do not exist in this PDF",
318
+ "missing": missing,
319
+ "available": sorted(fields),
320
+ "input": str(args.input),
321
+ }
322
+ emit(result, args.json_out)
323
+ raise SystemExit(3)
324
+
325
+ payload = dict(values)
326
+ if args.flatten:
327
+ current = {
328
+ name: field.get("/V", "/Off" if field.get("/FT") == "/Btn" else "")
329
+ for name, field in fields.items()
330
+ }
331
+ current.update(payload)
332
+ payload = current
333
+ payload = {name: values_for_field(fields[name], value) for name, value in payload.items()}
334
+
335
+ writer.update_page_form_field_values(None, payload, auto_regenerate=False, flatten=args.flatten)
336
+ sync_button_states(writer)
337
+ if args.flatten:
338
+ writer.remove_annotations(subtypes="/Widget")
339
+ writer.root_object.pop(NameObject("/AcroForm"), None)
340
+
341
+ args.out.parent.mkdir(parents=True, exist_ok=True)
342
+ with open(args.out, "wb") as handle:
343
+ writer.write(handle)
344
+
345
+ checks = verify(args.out, expected, flatten=args.flatten)
346
+ rendered = render(args.out, args.render_dir, args.dpi)
347
+
348
+ result = {
349
+ "ok": all(check["ok"] for check in checks),
350
+ "input": str(args.input),
351
+ "output": str(args.out),
352
+ "flatten": args.flatten,
353
+ "reattached_fields": reattached,
354
+ "written": expected,
355
+ "checks": checks,
356
+ "rendered_pages": rendered,
357
+ "before": {
358
+ "field_count": report["field_count"],
359
+ "widget_count": report["widget_count"],
360
+ "orphans": report["orphans"],
361
+ "ambiguous": report["ambiguous"],
362
+ },
363
+ }
364
+ emit(result, args.json_out)
365
+ if not result["ok"]:
366
+ raise SystemExit(4)
367
+
368
+
369
+ def sync_button_states(writer):
370
+ """Point each button widget's /AS at the field /V, clearing stale kid values."""
371
+ for page in writer.pages:
372
+ for annotation in page.get("/Annots") or []:
373
+ widget = annotation.get_object()
374
+ if widget.get("/Subtype") != "/Widget" or inherited(widget, "/FT") != "/Btn":
375
+ continue
376
+ parent = widget.get("/Parent")
377
+ source = parent.get_object() if parent is not None else widget
378
+ value = source.get("/V") if source.get("/FT") == "/Btn" else None
379
+ normal = widget.get("/AP")
380
+ if normal is not None:
381
+ normal = normal.get_object().get("/N")
382
+ if normal is None or hasattr(normal, "get_data"):
383
+ continue
384
+ normal = normal.get_object()
385
+ raw = str(value) if value is not None else None
386
+ candidates = [] if raw is None else [raw, raw if raw.startswith("/") else f"/{raw}"]
387
+ chosen = "/Off"
388
+ for candidate in candidates:
389
+ if normal.get(candidate) is not None:
390
+ chosen = candidate if candidate.startswith("/") else f"/{candidate}"
391
+ break
392
+ widget[NameObject("/AS")] = NameObject(chosen)
393
+ if "/V" in widget and parent is not None:
394
+ del widget["/V"]
395
+
396
+
397
+ def verify(path, expected, flatten):
398
+ from pypdf import PdfReader
399
+
400
+ reader = PdfReader(str(path))
401
+ checks = []
402
+ if not flatten:
403
+ fields = reader.get_fields() or {}
404
+ for name, wanted in expected.items():
405
+ found = fields.get(name)
406
+ if found is None:
407
+ checks.append({"check": f"field {name} present", "ok": False, "expected": wanted, "actual": None})
408
+ continue
409
+ actual = scalar(found.get("/V"))
410
+ checks.append(
411
+ {
412
+ "check": f"field {name} /V",
413
+ "ok": normalise(actual) == normalise(wanted),
414
+ "expected": normalise(wanted),
415
+ "actual": normalise(actual),
416
+ }
417
+ )
418
+
419
+ view = inspect_document(reader)
420
+ widgets = view["widgets"]
421
+ if flatten:
422
+ checks.append({"check": "no widgets remain", "ok": len(widgets) == 0, "actual": len(widgets)})
423
+ checks.append(
424
+ {
425
+ "check": "no form fields remain",
426
+ "ok": not (reader.get_fields() or {}),
427
+ "actual": sorted(reader.get_fields() or {}),
428
+ }
429
+ )
430
+ checks.append(
431
+ {
432
+ "check": "/AcroForm removed",
433
+ "ok": "/AcroForm" not in reader.trailer["/Root"],
434
+ "actual": "/AcroForm" in reader.trailer["/Root"],
435
+ }
436
+ )
437
+ else:
438
+ grouped = {}
439
+ for widget in widgets:
440
+ grouped.setdefault(widget["name"], []).append(widget)
441
+ for name, members in grouped.items():
442
+ if name not in expected:
443
+ continue
444
+ wanted = normalise(expected[name])
445
+ for widget in members:
446
+ checks.append(
447
+ {
448
+ "check": f"widget {name} has an appearance stream",
449
+ "ok": widget["appearance"] in {"stream", "states"},
450
+ "page": widget["page"],
451
+ "appearance": widget["appearance"],
452
+ "states": widget["appearance_states"],
453
+ }
454
+ )
455
+ if members[0]["field_type"] == "/Btn":
456
+ painted = [normalise(widget["value"]) for widget in members]
457
+ if wanted == "Off":
458
+ ok = all(state == "Off" for state in painted)
459
+ else:
460
+ ok = wanted in painted
461
+ checks.append(
462
+ {
463
+ "check": f"button group {name} paints {wanted}",
464
+ "ok": ok,
465
+ "actual": painted,
466
+ }
467
+ )
468
+ else:
469
+ for widget in members:
470
+ effective = normalise(widget["value"])
471
+ checks.append(
472
+ {
473
+ "check": f"widget {name} agrees with field /V",
474
+ "ok": effective == wanted,
475
+ "page": widget["page"],
476
+ "actual": effective,
477
+ }
478
+ )
479
+ return checks
480
+
481
+
482
+ def render(path, out_dir, dpi):
483
+ if not out_dir:
484
+ return []
485
+ try:
486
+ import pymupdf
487
+ except ImportError: # pragma: no cover
488
+ import fitz as pymupdf # type: ignore[no-redef]
489
+ out_dir.mkdir(parents=True, exist_ok=True)
490
+ zoom = dpi / 72.0
491
+ pages = []
492
+ with pymupdf.open(str(path)) as document:
493
+ for index, page in enumerate(document, start=1):
494
+ target = out_dir / f"page-{index}.png"
495
+ page.get_pixmap(matrix=pymupdf.Matrix(zoom, zoom), alpha=False).save(str(target))
496
+ pages.append(str(target))
497
+ return pages
498
+
499
+
500
+ def main():
501
+ parser = argparse.ArgumentParser(
502
+ description="Inspect or fill AcroForm fields, then verify the written values and appearances.",
503
+ epilog="Example: forms.py fill --input form.pdf --out form-filled.pdf --values values.json",
504
+ )
505
+ commands = parser.add_subparsers(dest="command", required=True)
506
+
507
+ inspect_parser = commands.add_parser("inspect", help="Report fields, widgets, orphans, and collisions")
508
+ inspect_parser.add_argument("--input", required=True, help="PDF file to inspect")
509
+ inspect_parser.add_argument("--json-out", help="Also write the JSON report to this path")
510
+
511
+ fill_parser = commands.add_parser("fill", help="Write values, optionally flatten, then verify")
512
+ fill_parser.add_argument("--input", required=True, help="Source PDF; it is never modified")
513
+ fill_parser.add_argument("--out", required=True, help="Destination PDF")
514
+ fill_parser.add_argument(
515
+ "--values",
516
+ required=True,
517
+ help='JSON object of {field: value}; accepts a file path, inline JSON, or "-" for stdin',
518
+ )
519
+ fill_parser.add_argument("--flatten", action="store_true", help="Paint values and remove every widget")
520
+ fill_parser.add_argument("--no-reattach", action="store_true", help="Do not repair widgets missing from /AcroForm/Fields")
521
+ fill_parser.add_argument("--allow-ambiguous", action="store_true", help="Fill even when widget and field views disagree")
522
+ fill_parser.add_argument("--render-dir", help="Rasterise the result here for a visual check")
523
+ fill_parser.add_argument("--dpi", type=int, default=110, help="Render resolution (default: 110)")
524
+ fill_parser.add_argument("--json-out", help="Also write the JSON report to this path")
525
+
526
+ args = parser.parse_args()
527
+ if args.command == "inspect":
528
+ from pypdf import PdfReader
529
+
530
+ source = Path(args.input).expanduser()
531
+ if not source.is_file():
532
+ fail(f"input not found: {source}", code=2)
533
+ try:
534
+ reader = PdfReader(str(source))
535
+ except Exception as exc:
536
+ fail(f"cannot open PDF: {exc}", code=2)
537
+ report = inspect_document(reader)
538
+ report.update({"ok": True, "input": str(source), "page_count": len(reader.pages)})
539
+ emit(report, args.json_out)
540
+ return
541
+
542
+ source = Path(args.input).expanduser()
543
+ if not source.is_file():
544
+ fail(f"input not found: {source}", code=2)
545
+ args.input = source
546
+ args.out = Path(args.out).expanduser()
547
+ if args.out.resolve() == source.resolve():
548
+ fail("refusing to overwrite the source PDF; choose a different --out", code=3)
549
+ if args.dpi < 24 or args.dpi > 600:
550
+ fail("--dpi must be between 24 and 600")
551
+ args.render_dir = Path(args.render_dir).expanduser() if args.render_dir else None
552
+ write_fill(args, load_values(args.values))
553
+
554
+
555
+ if __name__ == "__main__":
556
+ main()