dsh-office-kit 0.1.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.
- package/CAPABILITY.md +147 -68
- package/LICENSE.md +21 -21
- package/README.md +108 -106
- package/assets/skills/excel-live-control/SKILL.md +234 -0
- package/assets/skills/excel-live-control/container_tools/live_excel.py +1589 -0
- package/assets/skills/excel-live-control/domain_guidance/financial_models.md +86 -0
- package/assets/skills/excel-live-control/domain_guidance/healthcare.md +58 -0
- package/assets/skills/excel-live-control/domain_guidance/marketing_advertising.md +53 -0
- package/assets/skills/excel-live-control/domain_guidance/scientific_research.md +53 -0
- package/assets/skills/excel-live-control/references/office-automation.md +207 -0
- package/assets/skills/excel-live-control/references/read_only_qna.md +59 -0
- package/assets/skills/excel-live-control/style_guidelines.md +87 -0
- package/assets/skills/excel-live-control/workflows/create_workflows.md +78 -0
- package/assets/skills/excel-live-control/workflows/edit_workflows.md +72 -0
- package/assets/skills/office-documents/SKILL.md +90 -77
- package/assets/skills/office-documents/examples/end_to_end_smoke_test.md +86 -0
- package/assets/skills/office-documents/ooxml/comments.md +40 -0
- package/assets/skills/office-documents/ooxml/hyperlinks_and_fields.md +66 -0
- package/assets/skills/office-documents/ooxml/rels_and_content_types.md +62 -0
- package/assets/skills/office-documents/ooxml/tracked_changes.md +53 -0
- package/assets/skills/office-documents/references/docx-structure.md +83 -0
- package/assets/skills/office-documents/references/template-elicitation.md +49 -0
- package/assets/skills/office-documents/references/writing-quality.md +49 -0
- package/assets/skills/office-documents/render_docx.py +92 -0
- package/assets/skills/office-documents/scripts/a11y_audit.py +194 -0
- package/assets/skills/office-documents/scripts/accept_tracked_changes.py +151 -0
- package/assets/skills/office-documents/scripts/add_tracked_replacements.py +148 -0
- package/assets/skills/office-documents/scripts/append_blocks.py +108 -0
- package/assets/skills/office-documents/scripts/apply_template_styles.py +104 -0
- package/assets/skills/office-documents/scripts/captions_and_crossrefs.py +102 -0
- package/assets/skills/office-documents/scripts/comments_add.py +136 -0
- package/assets/skills/office-documents/scripts/comments_apply_patch.py +114 -0
- package/assets/skills/office-documents/scripts/comments_extract.py +85 -0
- package/assets/skills/office-documents/scripts/comments_strip.py +74 -0
- package/assets/skills/office-documents/scripts/content_controls.py +192 -0
- package/assets/skills/office-documents/scripts/core_properties.py +124 -0
- package/assets/skills/office-documents/scripts/docx_kit.py +587 -0
- package/assets/skills/office-documents/scripts/docx_ooxml_patch.py +225 -0
- package/assets/skills/office-documents/scripts/docx_table_to_csv.py +99 -0
- package/assets/skills/office-documents/scripts/fields_materialize.py +66 -0
- package/assets/skills/office-documents/scripts/fields_report.py +59 -0
- package/assets/skills/office-documents/scripts/flatten_ref_fields.py +62 -0
- package/assets/skills/office-documents/scripts/footnotes_report.py +77 -0
- package/assets/skills/office-documents/scripts/google_docs_title_sanitize.py +96 -0
- package/assets/skills/office-documents/scripts/heading_audit.py +81 -0
- package/assets/skills/office-documents/scripts/images_audit.py +80 -0
- package/assets/skills/office-documents/scripts/insert_note.py +126 -0
- package/assets/skills/office-documents/scripts/insert_ref_fields.py +90 -0
- package/assets/skills/office-documents/scripts/insert_toc.py +84 -0
- package/assets/skills/office-documents/scripts/internal_nav.py +146 -0
- package/assets/skills/office-documents/scripts/make_fixtures.py +169 -0
- package/assets/skills/office-documents/scripts/merge_docx_append.py +239 -0
- package/assets/skills/office-documents/scripts/privacy_scrub.py +131 -0
- package/assets/skills/office-documents/scripts/redact_docx.py +113 -0
- package/assets/skills/office-documents/scripts/render_and_diff.py +101 -0
- package/assets/skills/office-documents/scripts/replace_text.py +154 -0
- package/assets/skills/office-documents/scripts/section_audit.py +91 -0
- package/assets/skills/office-documents/scripts/set_protection.py +92 -0
- package/assets/skills/office-documents/scripts/style_lint.py +111 -0
- package/assets/skills/office-documents/scripts/style_normalize.py +103 -0
- package/assets/skills/office-documents/scripts/table_geometry.py +167 -0
- package/assets/skills/office-documents/scripts/watermark_add.py +48 -0
- package/assets/skills/office-documents/scripts/watermark_audit_remove.py +67 -0
- package/assets/skills/office-documents/scripts/xlsx_to_docx_table.py +117 -0
- package/assets/skills/office-documents/tasks/accessibility_a11y.md +45 -0
- package/assets/skills/office-documents/tasks/captions_crossrefs.md +49 -0
- package/assets/skills/office-documents/tasks/clean_tracked_changes.md +49 -0
- package/assets/skills/office-documents/tasks/comments_manage.md +59 -0
- package/assets/skills/office-documents/tasks/compare_diff.md +40 -0
- package/assets/skills/office-documents/tasks/create_edit.md +107 -0
- package/assets/skills/office-documents/tasks/equations.md +59 -0
- package/assets/skills/office-documents/tasks/fields_update.md +44 -0
- package/assets/skills/office-documents/tasks/fixtures_edge_cases.md +50 -0
- package/assets/skills/office-documents/tasks/footnotes_endnotes.md +38 -0
- package/assets/skills/office-documents/tasks/forms_content_controls.md +48 -0
- package/assets/skills/office-documents/tasks/headings_numbering.md +55 -0
- package/assets/skills/office-documents/tasks/images_figures.md +63 -0
- package/assets/skills/office-documents/tasks/multi_doc_merge.md +43 -0
- package/assets/skills/office-documents/tasks/navigation_internal_links.md +38 -0
- package/assets/skills/office-documents/tasks/privacy_scrub_metadata.md +45 -0
- package/assets/skills/office-documents/tasks/protection_restrict_editing.md +41 -0
- package/assets/skills/office-documents/tasks/read_review.md +50 -0
- package/assets/skills/office-documents/tasks/redaction_anonymization.md +42 -0
- package/assets/skills/office-documents/tasks/sections_layout.md +54 -0
- package/assets/skills/office-documents/tasks/style_lint_normalize.md +44 -0
- package/assets/skills/office-documents/tasks/tables_spreadsheets.md +64 -0
- package/assets/skills/office-documents/tasks/templates_style_packs.md +53 -0
- package/assets/skills/office-documents/tasks/toc_workflow.md +39 -0
- package/assets/skills/office-documents/tasks/verify_render.md +50 -0
- package/assets/skills/office-documents/tasks/watermarks_background.md +35 -0
- package/assets/skills/office-documents/troubleshooting/libreoffice_headless.md +47 -0
- package/assets/skills/office-documents/troubleshooting/run_splitting.md +36 -0
- package/assets/skills/office-pdf/SKILL.md +73 -56
- package/assets/skills/office-pdf/references/form-fields.md +56 -0
- package/assets/skills/office-pdf/references/geometry.md +44 -0
- package/assets/skills/office-pdf/references/layout-quality.md +41 -0
- package/assets/skills/office-pdf/references/limits.md +58 -0
- package/assets/skills/office-pdf/scripts/extract_text.py +172 -0
- package/assets/skills/office-pdf/scripts/forms.py +556 -0
- package/assets/skills/office-pdf/scripts/metadata.py +161 -0
- package/assets/skills/office-pdf/scripts/page_ops.py +198 -0
- package/assets/skills/office-pdf/scripts/redact_region.py +347 -0
- package/assets/skills/office-pdf/scripts/render_pages.py +207 -0
- package/assets/skills/office-pdf/scripts/split_pages.py +155 -0
- package/assets/skills/office-pdf/scripts/stamp_overlay.py +199 -0
- package/assets/skills/office-pdf/tasks/annotations.md +98 -0
- package/assets/skills/office-pdf/tasks/create.md +75 -0
- package/assets/skills/office-pdf/tasks/edit-pages.md +71 -0
- package/assets/skills/office-pdf/tasks/forms.md +67 -0
- package/assets/skills/office-pdf/tasks/read.md +62 -0
- package/assets/skills/office-pdf/tasks/redact.md +72 -0
- package/assets/skills/office-pdf/tasks/stamp.md +60 -0
- package/assets/skills/office-presentations/SKILL.md +141 -87
- package/assets/skills/office-presentations/container_tools/add_slides.py +136 -0
- package/assets/skills/office-presentations/container_tools/core_properties.py +120 -0
- package/assets/skills/office-presentations/container_tools/create_montage.py +126 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_layout_geometry.py +285 -0
- package/assets/skills/office-presentations/container_tools/inspect_presentation_package_integrity.py +157 -0
- package/assets/skills/office-presentations/container_tools/native_chart_title_gate.py +144 -0
- package/assets/skills/office-presentations/container_tools/native_quantitative_chart_gate.py +199 -0
- package/assets/skills/office-presentations/container_tools/native_table_arithmetic_gate.py +212 -0
- package/assets/skills/office-presentations/container_tools/ooxml_helpers.py +345 -0
- package/assets/skills/office-presentations/container_tools/preview_render.py +375 -0
- package/assets/skills/office-presentations/container_tools/render_slides.py +139 -0
- package/assets/skills/office-presentations/container_tools/replace_text.py +168 -0
- package/assets/skills/office-presentations/container_tools/template_source_fidelity_gate.py +229 -0
- package/assets/skills/office-presentations/domain_guidance/finance.md +66 -0
- package/assets/skills/office-presentations/recipes/README.md +71 -0
- package/assets/skills/office-presentations/recipes/autolayout.md +88 -0
- package/assets/skills/office-presentations/recipes/connectors.md +83 -0
- package/assets/skills/office-presentations/recipes/images.md +80 -0
- package/assets/skills/office-presentations/recipes/master-and-layouts.md +127 -0
- package/assets/skills/office-presentations/recipes/native-charts.md +87 -0
- package/assets/skills/office-presentations/recipes/rich-text-runs.md +86 -0
- package/assets/skills/office-presentations/recipes/speaker-notes.md +65 -0
- package/assets/skills/office-presentations/recipes/tables.md +88 -0
- package/assets/skills/office-presentations/recipes/textbox-at-coordinate.md +71 -0
- package/assets/skills/office-presentations/recipes/theme-tokens.md +87 -0
- package/assets/skills/office-presentations/references/authoring-workflow.md +65 -0
- package/assets/skills/office-presentations/references/finalization.md +63 -0
- package/assets/skills/office-presentations/references/native-bullets.md +78 -0
- package/assets/skills/office-presentations/references/native-evidence.md +67 -0
- package/assets/skills/office-presentations/references/style-guidelines.md +66 -0
- package/assets/skills/office-presentations/references/templates.md +76 -0
- package/assets/skills/office-presentations/routing/google_slides.md +45 -0
- package/assets/skills/office-presentations/tasks/apply-annotations.md +83 -0
- package/assets/skills/office-presentations/tasks/charts-tables-notes.md +79 -0
- package/assets/skills/office-presentations/tasks/create-deck.md +106 -0
- package/assets/skills/office-presentations/tasks/edit-deck.md +93 -0
- package/assets/skills/office-presentations/tasks/inspect-and-verify.md +107 -0
- package/assets/skills/office-presentations/tasks/template-following.md +132 -0
- package/assets/skills/office-presentations/template_following_scripts/check_template_fidelity.py +176 -0
- package/assets/skills/office-presentations/template_following_scripts/inspect_template_deck.py +154 -0
- package/assets/skills/office-presentations/template_following_scripts/make_contact_sheet.py +111 -0
- package/assets/skills/office-presentations/template_following_scripts/prepare_template_starter_deck.py +239 -0
- package/assets/skills/office-presentations/template_following_scripts/validate_template_plan.py +185 -0
- package/assets/skills/office-spreadsheets/SKILL.md +105 -101
- package/assets/skills/office-spreadsheets/container_tools/add_chart.py +236 -0
- package/assets/skills/office-spreadsheets/container_tools/add_image.py +107 -0
- package/assets/skills/office-spreadsheets/container_tools/append_rows.py +122 -0
- package/assets/skills/office-spreadsheets/container_tools/diff_workbooks.py +306 -0
- package/assets/skills/office-spreadsheets/container_tools/inspect_workbook.py +398 -0
- package/assets/skills/office-spreadsheets/container_tools/recalc_gate.py +275 -0
- package/assets/skills/office-spreadsheets/container_tools/set_cells.py +135 -0
- package/assets/skills/office-spreadsheets/container_tools/sheet_ops.py +134 -0
- package/assets/skills/office-spreadsheets/container_tools/style_cells.py +133 -0
- package/assets/skills/office-spreadsheets/recipes/charts.md +66 -0
- package/assets/skills/office-spreadsheets/recipes/conditional-formats.md +69 -0
- package/assets/skills/office-spreadsheets/recipes/data-tables.md +58 -0
- package/assets/skills/office-spreadsheets/recipes/named-ranges.md +41 -0
- package/assets/skills/office-spreadsheets/recipes/number-formats.md +72 -0
- package/assets/skills/office-spreadsheets/recipes/sparklines.md +43 -0
- package/assets/skills/office-spreadsheets/references/charts.md +63 -0
- package/assets/skills/office-spreadsheets/references/domain/financial-models.md +40 -0
- package/assets/skills/office-spreadsheets/references/domain/healthcare.md +22 -0
- package/assets/skills/office-spreadsheets/references/domain/marketing.md +28 -0
- package/assets/skills/office-spreadsheets/references/domain/research.md +22 -0
- package/assets/skills/office-spreadsheets/references/formula-patterns.md +78 -0
- package/assets/skills/office-spreadsheets/references/style-guidelines.md +69 -0
- package/assets/skills/office-spreadsheets/references/workbook-structure.md +60 -0
- package/assets/skills/office-spreadsheets/routing/google-sheets.md +36 -0
- package/assets/skills/office-spreadsheets/tasks/analyze-workbook.md +37 -0
- package/assets/skills/office-spreadsheets/tasks/annotations.md +51 -0
- package/assets/skills/office-spreadsheets/tasks/create-workbook.md +66 -0
- package/assets/skills/office-spreadsheets/tasks/diff-workbooks.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/edit-workbook.md +69 -0
- package/assets/skills/office-spreadsheets/tasks/inspect-workbook.md +38 -0
- package/assets/skills/office-spreadsheets/tasks/recalc-and-check.md +57 -0
- package/assets/skills/shared/annotation-recipes.md +252 -0
- package/assets/skills/shared/check_runtime.py +253 -0
- package/assets/skills/shared/common-workflow.md +139 -0
- package/assets/skills/shared/scripts/ensure_raster_image.py +187 -0
- package/dist/client.js +4 -4
- package/dist/client.js.map +1 -1
- package/dist/index.js +239 -89
- package/package.json +146 -149
- package/postinstall.mjs +92 -82
- 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()
|