mercury-agent 0.5.0 → 0.5.2
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/README.md +452 -451
- package/container/Dockerfile +127 -127
- package/container/Dockerfile.base +109 -109
- package/container/Dockerfile.power +17 -17
- package/container/agent-package.json +8 -8
- package/container/build.sh +54 -54
- package/docs/ROADMAP.md +3 -4
- package/docs/archive/summarization/2026-07-02-recent-dev-summary.md +71 -0
- package/docs/auth/dashboard.md +28 -28
- package/docs/auth/overview.md +109 -109
- package/docs/auth/whatsapp.md +173 -173
- package/docs/authoring-profiles.md +174 -174
- package/docs/configuration.md +54 -54
- package/docs/container-lifecycle.md +349 -349
- package/docs/{bugs/bwrap-privileged-linux-docker.md → debug/major/2026-07-02-bwrap-privileged-linux-docker.md} +21 -1
- package/docs/{bugs/e2big-prompt-delivery-fix.md → debug/major/2026-07-02-e2big-prompt-delivery-fix.md} +27 -1
- package/docs/{bugs/registry-pull-no-local-fallback.md → debug/major/2026-07-02-registry-pull-no-local-fallback.md} +27 -1
- package/docs/debug/summarization/2026-07-02-common-bug-patterns.md +72 -0
- package/docs/deployment.md +199 -199
- package/docs/extensions.md +375 -375
- package/docs/graceful-shutdown.md +62 -62
- package/docs/kb-distillation.md +77 -77
- package/docs/media/overview.md +140 -140
- package/docs/media/whatsapp.md +171 -171
- package/docs/memory.md +137 -137
- package/docs/permissions.md +217 -217
- package/docs/pipeline.md +228 -228
- package/docs/prd-chat-memory.md +76 -76
- package/docs/prd-config-load.md +82 -82
- package/docs/rate-limiting.md +229 -229
- package/docs/runbooks/publish-checklist.md +9 -1
- package/docs/scheduler.md +288 -288
- package/docs/setup-discord.md +100 -100
- package/docs/setup-slack.md +119 -119
- package/docs/setup-whatsapp.md +94 -94
- package/docs/subagents.md +166 -166
- package/docs/web-search.md +62 -62
- package/examples/extensions/README.md +12 -12
- package/examples/extensions/charts/index.ts +13 -13
- package/examples/extensions/charts/skill/SKILL.md +98 -98
- package/examples/extensions/gws/README.md +52 -52
- package/examples/extensions/gws/skill/SKILL.md +57 -57
- package/examples/extensions/gws/skill/references/calendar.md +101 -101
- package/examples/extensions/gws/skill/references/docs.md +65 -65
- package/examples/extensions/gws/skill/references/drive.md +79 -79
- package/examples/extensions/gws/skill/references/gmail.md +85 -85
- package/examples/extensions/gws/skill/references/sheets.md +60 -60
- package/examples/extensions/napkin/skill/SKILL.md +728 -728
- package/examples/extensions/pdf/skill/LICENSE.txt +30 -30
- package/examples/extensions/pdf/skill/SKILL.md +314 -314
- package/examples/extensions/pdf/skill/forms.md +294 -294
- package/examples/extensions/pdf/skill/reference.md +611 -611
- package/examples/extensions/pdf/skill/scripts/check_bounding_boxes.py +65 -65
- package/examples/extensions/pdf/skill/scripts/check_fillable_fields.py +11 -11
- package/examples/extensions/pdf/skill/scripts/convert_pdf_to_images.py +33 -33
- package/examples/extensions/pdf/skill/scripts/create_validation_image.py +37 -37
- package/examples/extensions/pdf/skill/scripts/extract_form_field_info.py +122 -122
- package/examples/extensions/pdf/skill/scripts/extract_form_structure.py +115 -115
- package/examples/extensions/pdf/skill/scripts/fill_fillable_fields.py +98 -98
- package/examples/extensions/pdf/skill/scripts/fill_pdf_form_with_annotations.py +107 -107
- package/examples/extensions/permission-guard/index.ts +65 -65
- package/examples/extensions/pinchtab/skill/SKILL.md +224 -224
- package/examples/extensions/pinchtab/skill/TRUST.md +69 -69
- package/examples/extensions/pinchtab/skill/references/api.md +297 -297
- package/examples/extensions/pinchtab/skill/references/env.md +45 -45
- package/examples/extensions/pinchtab/skill/references/profiles.md +107 -107
- package/examples/extensions/tradestation/host/refresh.ts +102 -102
- package/examples/extensions/tradestation/index.ts +153 -153
- package/examples/extensions/tradestation/skill/SKILL.md +67 -67
- package/examples/extensions/voice-synth/index.ts +94 -94
- package/examples/extensions/voice-synth/skill/SKILL.md +38 -38
- package/examples/extensions/voice-transcribe/requirements.txt +8 -8
- package/examples/extensions/voice-transcribe/scripts/transcribe.py +179 -179
- package/examples/extensions/voice-transcribe/skill/SKILL.md +53 -53
- package/examples/extensions/yahoo-mail/cli/package.json +13 -13
- package/examples/extensions/yahoo-mail/skill/SKILL.md +78 -78
- package/package.json +106 -106
- package/resources/agents/explore.md +50 -50
- package/resources/agents/worker.md +24 -24
- package/resources/connection-env-vars.json +25 -25
- package/resources/pi-extensions/subagent/agents.ts +126 -126
- package/resources/pi-extensions/subagent/index.ts +964 -964
- package/resources/profiles/coding/AGENTS.md +43 -43
- package/resources/profiles/coding/mercury-profile.yaml +15 -15
- package/resources/profiles/general/AGENTS.md +31 -31
- package/resources/profiles/general/mercury-profile.yaml +15 -15
- package/resources/profiles/research/AGENTS.md +40 -40
- package/resources/profiles/research/mercury-profile.yaml +15 -15
- package/resources/skills/config/SKILL.md +25 -25
- package/resources/skills/context/SKILL.md +33 -33
- package/resources/skills/conversation-recap/SKILL.md +19 -19
- package/resources/skills/mutes/SKILL.md +31 -31
- package/resources/skills/permissions/SKILL.md +19 -19
- package/resources/skills/preferences/SKILL.md +31 -31
- package/resources/skills/recall/SKILL.md +24 -24
- package/resources/skills/roles/SKILL.md +18 -18
- package/resources/skills/spaces/SKILL.md +18 -18
- package/resources/skills/tasks/SKILL.md +45 -45
- package/resources/templates/AGENTS.md +157 -157
- package/resources/templates/env.template +38 -38
- package/resources/templates/mercury.example.yaml +99 -99
- package/src/agent/container-entry.ts +1 -1
- package/src/agent/container-runner.ts +1345 -1346
- package/src/cli/mercury.ts +39 -7
- package/src/cli/mrctl.ts +636 -636
- package/src/config-file.ts +540 -540
- package/src/config.ts +339 -339
- package/src/core/api.ts +125 -125
- package/src/core/caller-token.ts +101 -101
- package/src/core/permissions.ts +228 -228
- package/src/core/profiles.ts +271 -271
- package/src/core/routes/capability.ts +70 -70
- package/src/core/routes/index.ts +15 -15
- package/src/core/runtime.ts +1530 -1530
- package/src/dashboard/index.html +729 -729
- package/src/extensions/api.ts +273 -273
- package/src/extensions/loader.ts +286 -286
- package/src/extensions/types.ts +517 -517
- package/src/main.ts +605 -605
- package/docs/pending-updates/applicative-profiles.md +0 -15
- package/docs/pending-updates/caller-bound-capability-token.md +0 -15
- package/docs/pending-updates/dm-auto-space.md +0 -15
- /package/docs/archive/{2026-07-01-applicative-profiles.md → applicative-profiles/2026-07-01-applicative-profiles.md} +0 -0
- /package/docs/archive/{2026-07-01-caller-bound-capability-token.md → applicative-profiles/2026-07-01-caller-bound-capability-token.md} +0 -0
- /package/docs/archive/{2026-07-01-dm-auto-space.md → applicative-profiles/2026-07-01-dm-auto-space.md} +0 -0
|
@@ -1,65 +1,65 @@
|
|
|
1
|
-
from dataclasses import dataclass
|
|
2
|
-
import json
|
|
3
|
-
import sys
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@dataclass
|
|
9
|
-
class RectAndField:
|
|
10
|
-
rect: list[float]
|
|
11
|
-
rect_type: str
|
|
12
|
-
field: dict
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
def get_bounding_box_messages(fields_json_stream) -> list[str]:
|
|
16
|
-
messages = []
|
|
17
|
-
fields = json.load(fields_json_stream)
|
|
18
|
-
messages.append(f"Read {len(fields['form_fields'])} fields")
|
|
19
|
-
|
|
20
|
-
def rects_intersect(r1, r2):
|
|
21
|
-
disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0]
|
|
22
|
-
disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1]
|
|
23
|
-
return not (disjoint_horizontal or disjoint_vertical)
|
|
24
|
-
|
|
25
|
-
rects_and_fields = []
|
|
26
|
-
for f in fields["form_fields"]:
|
|
27
|
-
rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f))
|
|
28
|
-
rects_and_fields.append(RectAndField(f["entry_bounding_box"], "entry", f))
|
|
29
|
-
|
|
30
|
-
has_error = False
|
|
31
|
-
for i, ri in enumerate(rects_and_fields):
|
|
32
|
-
for j in range(i + 1, len(rects_and_fields)):
|
|
33
|
-
rj = rects_and_fields[j]
|
|
34
|
-
if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect):
|
|
35
|
-
has_error = True
|
|
36
|
-
if ri.field is rj.field:
|
|
37
|
-
messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})")
|
|
38
|
-
else:
|
|
39
|
-
messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})")
|
|
40
|
-
if len(messages) >= 20:
|
|
41
|
-
messages.append("Aborting further checks; fix bounding boxes and try again")
|
|
42
|
-
return messages
|
|
43
|
-
if ri.rect_type == "entry":
|
|
44
|
-
if "entry_text" in ri.field:
|
|
45
|
-
font_size = ri.field["entry_text"].get("font_size", 14)
|
|
46
|
-
entry_height = ri.rect[3] - ri.rect[1]
|
|
47
|
-
if entry_height < font_size:
|
|
48
|
-
has_error = True
|
|
49
|
-
messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.")
|
|
50
|
-
if len(messages) >= 20:
|
|
51
|
-
messages.append("Aborting further checks; fix bounding boxes and try again")
|
|
52
|
-
return messages
|
|
53
|
-
|
|
54
|
-
if not has_error:
|
|
55
|
-
messages.append("SUCCESS: All bounding boxes are valid")
|
|
56
|
-
return messages
|
|
57
|
-
|
|
58
|
-
if __name__ == "__main__":
|
|
59
|
-
if len(sys.argv) != 2:
|
|
60
|
-
print("Usage: check_bounding_boxes.py [fields.json]")
|
|
61
|
-
sys.exit(1)
|
|
62
|
-
with open(sys.argv[1]) as f:
|
|
63
|
-
messages = get_bounding_box_messages(f)
|
|
64
|
-
for msg in messages:
|
|
65
|
-
print(msg)
|
|
1
|
+
from dataclasses import dataclass
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass
|
|
9
|
+
class RectAndField:
|
|
10
|
+
rect: list[float]
|
|
11
|
+
rect_type: str
|
|
12
|
+
field: dict
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def get_bounding_box_messages(fields_json_stream) -> list[str]:
|
|
16
|
+
messages = []
|
|
17
|
+
fields = json.load(fields_json_stream)
|
|
18
|
+
messages.append(f"Read {len(fields['form_fields'])} fields")
|
|
19
|
+
|
|
20
|
+
def rects_intersect(r1, r2):
|
|
21
|
+
disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0]
|
|
22
|
+
disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1]
|
|
23
|
+
return not (disjoint_horizontal or disjoint_vertical)
|
|
24
|
+
|
|
25
|
+
rects_and_fields = []
|
|
26
|
+
for f in fields["form_fields"]:
|
|
27
|
+
rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f))
|
|
28
|
+
rects_and_fields.append(RectAndField(f["entry_bounding_box"], "entry", f))
|
|
29
|
+
|
|
30
|
+
has_error = False
|
|
31
|
+
for i, ri in enumerate(rects_and_fields):
|
|
32
|
+
for j in range(i + 1, len(rects_and_fields)):
|
|
33
|
+
rj = rects_and_fields[j]
|
|
34
|
+
if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect):
|
|
35
|
+
has_error = True
|
|
36
|
+
if ri.field is rj.field:
|
|
37
|
+
messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})")
|
|
38
|
+
else:
|
|
39
|
+
messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})")
|
|
40
|
+
if len(messages) >= 20:
|
|
41
|
+
messages.append("Aborting further checks; fix bounding boxes and try again")
|
|
42
|
+
return messages
|
|
43
|
+
if ri.rect_type == "entry":
|
|
44
|
+
if "entry_text" in ri.field:
|
|
45
|
+
font_size = ri.field["entry_text"].get("font_size", 14)
|
|
46
|
+
entry_height = ri.rect[3] - ri.rect[1]
|
|
47
|
+
if entry_height < font_size:
|
|
48
|
+
has_error = True
|
|
49
|
+
messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.")
|
|
50
|
+
if len(messages) >= 20:
|
|
51
|
+
messages.append("Aborting further checks; fix bounding boxes and try again")
|
|
52
|
+
return messages
|
|
53
|
+
|
|
54
|
+
if not has_error:
|
|
55
|
+
messages.append("SUCCESS: All bounding boxes are valid")
|
|
56
|
+
return messages
|
|
57
|
+
|
|
58
|
+
if __name__ == "__main__":
|
|
59
|
+
if len(sys.argv) != 2:
|
|
60
|
+
print("Usage: check_bounding_boxes.py [fields.json]")
|
|
61
|
+
sys.exit(1)
|
|
62
|
+
with open(sys.argv[1]) as f:
|
|
63
|
+
messages = get_bounding_box_messages(f)
|
|
64
|
+
for msg in messages:
|
|
65
|
+
print(msg)
|
|
@@ -1,11 +1,11 @@
|
|
|
1
|
-
import sys
|
|
2
|
-
from pypdf import PdfReader
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
reader = PdfReader(sys.argv[1])
|
|
8
|
-
if (reader.get_fields()):
|
|
9
|
-
print("This PDF has fillable form fields")
|
|
10
|
-
else:
|
|
11
|
-
print("This PDF does not have fillable form fields; you will need to visually determine where to enter data")
|
|
1
|
+
import sys
|
|
2
|
+
from pypdf import PdfReader
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
reader = PdfReader(sys.argv[1])
|
|
8
|
+
if (reader.get_fields()):
|
|
9
|
+
print("This PDF has fillable form fields")
|
|
10
|
+
else:
|
|
11
|
+
print("This PDF does not have fillable form fields; you will need to visually determine where to enter data")
|
|
@@ -1,33 +1,33 @@
|
|
|
1
|
-
import os
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
from pdf2image import convert_from_path
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def convert(pdf_path, output_dir, max_dim=1000):
|
|
10
|
-
images = convert_from_path(pdf_path, dpi=200)
|
|
11
|
-
|
|
12
|
-
for i, image in enumerate(images):
|
|
13
|
-
width, height = image.size
|
|
14
|
-
if width > max_dim or height > max_dim:
|
|
15
|
-
scale_factor = min(max_dim / width, max_dim / height)
|
|
16
|
-
new_width = int(width * scale_factor)
|
|
17
|
-
new_height = int(height * scale_factor)
|
|
18
|
-
image = image.resize((new_width, new_height))
|
|
19
|
-
|
|
20
|
-
image_path = os.path.join(output_dir, f"page_{i+1}.png")
|
|
21
|
-
image.save(image_path)
|
|
22
|
-
print(f"Saved page {i+1} as {image_path} (size: {image.size})")
|
|
23
|
-
|
|
24
|
-
print(f"Converted {len(images)} pages to PNG images")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
if __name__ == "__main__":
|
|
28
|
-
if len(sys.argv) != 3:
|
|
29
|
-
print("Usage: convert_pdf_to_images.py [input pdf] [output directory]")
|
|
30
|
-
sys.exit(1)
|
|
31
|
-
pdf_path = sys.argv[1]
|
|
32
|
-
output_directory = sys.argv[2]
|
|
33
|
-
convert(pdf_path, output_directory)
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from pdf2image import convert_from_path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def convert(pdf_path, output_dir, max_dim=1000):
|
|
10
|
+
images = convert_from_path(pdf_path, dpi=200)
|
|
11
|
+
|
|
12
|
+
for i, image in enumerate(images):
|
|
13
|
+
width, height = image.size
|
|
14
|
+
if width > max_dim or height > max_dim:
|
|
15
|
+
scale_factor = min(max_dim / width, max_dim / height)
|
|
16
|
+
new_width = int(width * scale_factor)
|
|
17
|
+
new_height = int(height * scale_factor)
|
|
18
|
+
image = image.resize((new_width, new_height))
|
|
19
|
+
|
|
20
|
+
image_path = os.path.join(output_dir, f"page_{i+1}.png")
|
|
21
|
+
image.save(image_path)
|
|
22
|
+
print(f"Saved page {i+1} as {image_path} (size: {image.size})")
|
|
23
|
+
|
|
24
|
+
print(f"Converted {len(images)} pages to PNG images")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
if __name__ == "__main__":
|
|
28
|
+
if len(sys.argv) != 3:
|
|
29
|
+
print("Usage: convert_pdf_to_images.py [input pdf] [output directory]")
|
|
30
|
+
sys.exit(1)
|
|
31
|
+
pdf_path = sys.argv[1]
|
|
32
|
+
output_directory = sys.argv[2]
|
|
33
|
+
convert(pdf_path, output_directory)
|
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
from PIL import Image, ImageDraw
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def create_validation_image(page_number, fields_json_path, input_path, output_path):
|
|
10
|
-
with open(fields_json_path, 'r') as f:
|
|
11
|
-
data = json.load(f)
|
|
12
|
-
|
|
13
|
-
img = Image.open(input_path)
|
|
14
|
-
draw = ImageDraw.Draw(img)
|
|
15
|
-
num_boxes = 0
|
|
16
|
-
|
|
17
|
-
for field in data["form_fields"]:
|
|
18
|
-
if field["page_number"] == page_number:
|
|
19
|
-
entry_box = field['entry_bounding_box']
|
|
20
|
-
label_box = field['label_bounding_box']
|
|
21
|
-
draw.rectangle(entry_box, outline='red', width=2)
|
|
22
|
-
draw.rectangle(label_box, outline='blue', width=2)
|
|
23
|
-
num_boxes += 2
|
|
24
|
-
|
|
25
|
-
img.save(output_path)
|
|
26
|
-
print(f"Created validation image at {output_path} with {num_boxes} bounding boxes")
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
if __name__ == "__main__":
|
|
30
|
-
if len(sys.argv) != 5:
|
|
31
|
-
print("Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]")
|
|
32
|
-
sys.exit(1)
|
|
33
|
-
page_number = int(sys.argv[1])
|
|
34
|
-
fields_json_path = sys.argv[2]
|
|
35
|
-
input_image_path = sys.argv[3]
|
|
36
|
-
output_image_path = sys.argv[4]
|
|
37
|
-
create_validation_image(page_number, fields_json_path, input_image_path, output_image_path)
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from PIL import Image, ImageDraw
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def create_validation_image(page_number, fields_json_path, input_path, output_path):
|
|
10
|
+
with open(fields_json_path, 'r') as f:
|
|
11
|
+
data = json.load(f)
|
|
12
|
+
|
|
13
|
+
img = Image.open(input_path)
|
|
14
|
+
draw = ImageDraw.Draw(img)
|
|
15
|
+
num_boxes = 0
|
|
16
|
+
|
|
17
|
+
for field in data["form_fields"]:
|
|
18
|
+
if field["page_number"] == page_number:
|
|
19
|
+
entry_box = field['entry_bounding_box']
|
|
20
|
+
label_box = field['label_bounding_box']
|
|
21
|
+
draw.rectangle(entry_box, outline='red', width=2)
|
|
22
|
+
draw.rectangle(label_box, outline='blue', width=2)
|
|
23
|
+
num_boxes += 2
|
|
24
|
+
|
|
25
|
+
img.save(output_path)
|
|
26
|
+
print(f"Created validation image at {output_path} with {num_boxes} bounding boxes")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
if __name__ == "__main__":
|
|
30
|
+
if len(sys.argv) != 5:
|
|
31
|
+
print("Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]")
|
|
32
|
+
sys.exit(1)
|
|
33
|
+
page_number = int(sys.argv[1])
|
|
34
|
+
fields_json_path = sys.argv[2]
|
|
35
|
+
input_image_path = sys.argv[3]
|
|
36
|
+
output_image_path = sys.argv[4]
|
|
37
|
+
create_validation_image(page_number, fields_json_path, input_image_path, output_image_path)
|
|
@@ -1,122 +1,122 @@
|
|
|
1
|
-
import json
|
|
2
|
-
import sys
|
|
3
|
-
|
|
4
|
-
from pypdf import PdfReader
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def get_full_annotation_field_id(annotation):
|
|
10
|
-
components = []
|
|
11
|
-
while annotation:
|
|
12
|
-
field_name = annotation.get('/T')
|
|
13
|
-
if field_name:
|
|
14
|
-
components.append(field_name)
|
|
15
|
-
annotation = annotation.get('/Parent')
|
|
16
|
-
return ".".join(reversed(components)) if components else None
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def make_field_dict(field, field_id):
|
|
20
|
-
field_dict = {"field_id": field_id}
|
|
21
|
-
ft = field.get('/FT')
|
|
22
|
-
if ft == "/Tx":
|
|
23
|
-
field_dict["type"] = "text"
|
|
24
|
-
elif ft == "/Btn":
|
|
25
|
-
field_dict["type"] = "checkbox"
|
|
26
|
-
states = field.get("/_States_", [])
|
|
27
|
-
if len(states) == 2:
|
|
28
|
-
if "/Off" in states:
|
|
29
|
-
field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1]
|
|
30
|
-
field_dict["unchecked_value"] = "/Off"
|
|
31
|
-
else:
|
|
32
|
-
print(f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results.")
|
|
33
|
-
field_dict["checked_value"] = states[0]
|
|
34
|
-
field_dict["unchecked_value"] = states[1]
|
|
35
|
-
elif ft == "/Ch":
|
|
36
|
-
field_dict["type"] = "choice"
|
|
37
|
-
states = field.get("/_States_", [])
|
|
38
|
-
field_dict["choice_options"] = [{
|
|
39
|
-
"value": state[0],
|
|
40
|
-
"text": state[1],
|
|
41
|
-
} for state in states]
|
|
42
|
-
else:
|
|
43
|
-
field_dict["type"] = f"unknown ({ft})"
|
|
44
|
-
return field_dict
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def get_field_info(reader: PdfReader):
|
|
48
|
-
fields = reader.get_fields()
|
|
49
|
-
|
|
50
|
-
field_info_by_id = {}
|
|
51
|
-
possible_radio_names = set()
|
|
52
|
-
|
|
53
|
-
for field_id, field in fields.items():
|
|
54
|
-
if field.get("/Kids"):
|
|
55
|
-
if field.get("/FT") == "/Btn":
|
|
56
|
-
possible_radio_names.add(field_id)
|
|
57
|
-
continue
|
|
58
|
-
field_info_by_id[field_id] = make_field_dict(field, field_id)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
radio_fields_by_id = {}
|
|
62
|
-
|
|
63
|
-
for page_index, page in enumerate(reader.pages):
|
|
64
|
-
annotations = page.get('/Annots', [])
|
|
65
|
-
for ann in annotations:
|
|
66
|
-
field_id = get_full_annotation_field_id(ann)
|
|
67
|
-
if field_id in field_info_by_id:
|
|
68
|
-
field_info_by_id[field_id]["page"] = page_index + 1
|
|
69
|
-
field_info_by_id[field_id]["rect"] = ann.get('/Rect')
|
|
70
|
-
elif field_id in possible_radio_names:
|
|
71
|
-
try:
|
|
72
|
-
on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"]
|
|
73
|
-
except KeyError:
|
|
74
|
-
continue
|
|
75
|
-
if len(on_values) == 1:
|
|
76
|
-
rect = ann.get("/Rect")
|
|
77
|
-
if field_id not in radio_fields_by_id:
|
|
78
|
-
radio_fields_by_id[field_id] = {
|
|
79
|
-
"field_id": field_id,
|
|
80
|
-
"type": "radio_group",
|
|
81
|
-
"page": page_index + 1,
|
|
82
|
-
"radio_options": [],
|
|
83
|
-
}
|
|
84
|
-
radio_fields_by_id[field_id]["radio_options"].append({
|
|
85
|
-
"value": on_values[0],
|
|
86
|
-
"rect": rect,
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
fields_with_location = []
|
|
90
|
-
for field_info in field_info_by_id.values():
|
|
91
|
-
if "page" in field_info:
|
|
92
|
-
fields_with_location.append(field_info)
|
|
93
|
-
else:
|
|
94
|
-
print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring")
|
|
95
|
-
|
|
96
|
-
def sort_key(f):
|
|
97
|
-
if "radio_options" in f:
|
|
98
|
-
rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0]
|
|
99
|
-
else:
|
|
100
|
-
rect = f.get("rect") or [0, 0, 0, 0]
|
|
101
|
-
adjusted_position = [-rect[1], rect[0]]
|
|
102
|
-
return [f.get("page"), adjusted_position]
|
|
103
|
-
|
|
104
|
-
sorted_fields = fields_with_location + list(radio_fields_by_id.values())
|
|
105
|
-
sorted_fields.sort(key=sort_key)
|
|
106
|
-
|
|
107
|
-
return sorted_fields
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
def write_field_info(pdf_path: str, json_output_path: str):
|
|
111
|
-
reader = PdfReader(pdf_path)
|
|
112
|
-
field_info = get_field_info(reader)
|
|
113
|
-
with open(json_output_path, "w") as f:
|
|
114
|
-
json.dump(field_info, f, indent=2)
|
|
115
|
-
print(f"Wrote {len(field_info)} fields to {json_output_path}")
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
if __name__ == "__main__":
|
|
119
|
-
if len(sys.argv) != 3:
|
|
120
|
-
print("Usage: extract_form_field_info.py [input pdf] [output json]")
|
|
121
|
-
sys.exit(1)
|
|
122
|
-
write_field_info(sys.argv[1], sys.argv[2])
|
|
1
|
+
import json
|
|
2
|
+
import sys
|
|
3
|
+
|
|
4
|
+
from pypdf import PdfReader
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def get_full_annotation_field_id(annotation):
|
|
10
|
+
components = []
|
|
11
|
+
while annotation:
|
|
12
|
+
field_name = annotation.get('/T')
|
|
13
|
+
if field_name:
|
|
14
|
+
components.append(field_name)
|
|
15
|
+
annotation = annotation.get('/Parent')
|
|
16
|
+
return ".".join(reversed(components)) if components else None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_field_dict(field, field_id):
|
|
20
|
+
field_dict = {"field_id": field_id}
|
|
21
|
+
ft = field.get('/FT')
|
|
22
|
+
if ft == "/Tx":
|
|
23
|
+
field_dict["type"] = "text"
|
|
24
|
+
elif ft == "/Btn":
|
|
25
|
+
field_dict["type"] = "checkbox"
|
|
26
|
+
states = field.get("/_States_", [])
|
|
27
|
+
if len(states) == 2:
|
|
28
|
+
if "/Off" in states:
|
|
29
|
+
field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1]
|
|
30
|
+
field_dict["unchecked_value"] = "/Off"
|
|
31
|
+
else:
|
|
32
|
+
print(f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results.")
|
|
33
|
+
field_dict["checked_value"] = states[0]
|
|
34
|
+
field_dict["unchecked_value"] = states[1]
|
|
35
|
+
elif ft == "/Ch":
|
|
36
|
+
field_dict["type"] = "choice"
|
|
37
|
+
states = field.get("/_States_", [])
|
|
38
|
+
field_dict["choice_options"] = [{
|
|
39
|
+
"value": state[0],
|
|
40
|
+
"text": state[1],
|
|
41
|
+
} for state in states]
|
|
42
|
+
else:
|
|
43
|
+
field_dict["type"] = f"unknown ({ft})"
|
|
44
|
+
return field_dict
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def get_field_info(reader: PdfReader):
|
|
48
|
+
fields = reader.get_fields()
|
|
49
|
+
|
|
50
|
+
field_info_by_id = {}
|
|
51
|
+
possible_radio_names = set()
|
|
52
|
+
|
|
53
|
+
for field_id, field in fields.items():
|
|
54
|
+
if field.get("/Kids"):
|
|
55
|
+
if field.get("/FT") == "/Btn":
|
|
56
|
+
possible_radio_names.add(field_id)
|
|
57
|
+
continue
|
|
58
|
+
field_info_by_id[field_id] = make_field_dict(field, field_id)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
radio_fields_by_id = {}
|
|
62
|
+
|
|
63
|
+
for page_index, page in enumerate(reader.pages):
|
|
64
|
+
annotations = page.get('/Annots', [])
|
|
65
|
+
for ann in annotations:
|
|
66
|
+
field_id = get_full_annotation_field_id(ann)
|
|
67
|
+
if field_id in field_info_by_id:
|
|
68
|
+
field_info_by_id[field_id]["page"] = page_index + 1
|
|
69
|
+
field_info_by_id[field_id]["rect"] = ann.get('/Rect')
|
|
70
|
+
elif field_id in possible_radio_names:
|
|
71
|
+
try:
|
|
72
|
+
on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"]
|
|
73
|
+
except KeyError:
|
|
74
|
+
continue
|
|
75
|
+
if len(on_values) == 1:
|
|
76
|
+
rect = ann.get("/Rect")
|
|
77
|
+
if field_id not in radio_fields_by_id:
|
|
78
|
+
radio_fields_by_id[field_id] = {
|
|
79
|
+
"field_id": field_id,
|
|
80
|
+
"type": "radio_group",
|
|
81
|
+
"page": page_index + 1,
|
|
82
|
+
"radio_options": [],
|
|
83
|
+
}
|
|
84
|
+
radio_fields_by_id[field_id]["radio_options"].append({
|
|
85
|
+
"value": on_values[0],
|
|
86
|
+
"rect": rect,
|
|
87
|
+
})
|
|
88
|
+
|
|
89
|
+
fields_with_location = []
|
|
90
|
+
for field_info in field_info_by_id.values():
|
|
91
|
+
if "page" in field_info:
|
|
92
|
+
fields_with_location.append(field_info)
|
|
93
|
+
else:
|
|
94
|
+
print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring")
|
|
95
|
+
|
|
96
|
+
def sort_key(f):
|
|
97
|
+
if "radio_options" in f:
|
|
98
|
+
rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0]
|
|
99
|
+
else:
|
|
100
|
+
rect = f.get("rect") or [0, 0, 0, 0]
|
|
101
|
+
adjusted_position = [-rect[1], rect[0]]
|
|
102
|
+
return [f.get("page"), adjusted_position]
|
|
103
|
+
|
|
104
|
+
sorted_fields = fields_with_location + list(radio_fields_by_id.values())
|
|
105
|
+
sorted_fields.sort(key=sort_key)
|
|
106
|
+
|
|
107
|
+
return sorted_fields
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def write_field_info(pdf_path: str, json_output_path: str):
|
|
111
|
+
reader = PdfReader(pdf_path)
|
|
112
|
+
field_info = get_field_info(reader)
|
|
113
|
+
with open(json_output_path, "w") as f:
|
|
114
|
+
json.dump(field_info, f, indent=2)
|
|
115
|
+
print(f"Wrote {len(field_info)} fields to {json_output_path}")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
if __name__ == "__main__":
|
|
119
|
+
if len(sys.argv) != 3:
|
|
120
|
+
print("Usage: extract_form_field_info.py [input pdf] [output json]")
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
write_field_info(sys.argv[1], sys.argv[2])
|