From e13b6ff259283c30680405e905b317a9a971e45b Mon Sep 17 00:00:00 2001 From: Zhongwei Li Date: Sun, 30 Nov 2025 08:25:58 +0800 Subject: [PATCH] Initial commit --- .claude-plugin/plugin.json | 18 + README.md | 3 + agents/extracting-form-fields.md | 49 +++ commands/pdf.md | 7 + plugin.lock.json | 153 ++++++++ skills/extracting-form-fields/.gitignore | 2 + skills/extracting-form-fields/SKILL.md | 117 ++++++ .../references/Fillable-Forms.md | 29 ++ .../references/Nonfillable-Forms.md | 218 ++++++++++++ .../scripts/check_bounding_boxes.py | 78 ++++ .../scripts/check_bounding_boxes_test.py | 226 ++++++++++++ .../scripts/check_fillable_fields.py | 12 + .../scripts/convert_coordinates.py | 179 ++++++++++ .../scripts/convert_pdf_to_images.py | 35 ++ .../scripts/create_validation_image.py | 59 ++++ .../scripts/extract_form_field_info.py | 158 +++++++++ skills/filling-pdf-forms/.gitignore | 2 + skills/filling-pdf-forms/LICENSE.txt | 30 ++ skills/filling-pdf-forms/SKILL.md | 134 +++++++ .../references/AskUserQuestion-Rules.md | 181 ++++++++++ .../references/CLI-Interview-Loop.md | 74 ++++ .../references/Converting-PDF-To-Chatfield.md | 332 ++++++++++++++++++ .../references/Data-Model-API.md | 216 ++++++++++++ .../references/Populating-Fillable.md | 100 ++++++ .../references/Populating-Nonfillable.md | 121 +++++++ .../references/Translating.md | 218 ++++++++++++ .../chatfield-1.0.0a2-py3-none-any.whl | Bin 0 -> 38958 bytes .../scripts/chatfield_interview_template.py | 28 ++ .../scripts/extract_form_field_info.py | 158 +++++++++ .../scripts/fill_fillable_fields.py | 114 ++++++ .../scripts/fill_nonfillable_fields.py | 134 +++++++ 31 files changed, 3185 insertions(+) create mode 100644 .claude-plugin/plugin.json create mode 100644 README.md create mode 100644 agents/extracting-form-fields.md create mode 100644 commands/pdf.md create mode 100644 plugin.lock.json create mode 100644 skills/extracting-form-fields/.gitignore create mode 100644 skills/extracting-form-fields/SKILL.md create mode 100644 skills/extracting-form-fields/references/Fillable-Forms.md create mode 100644 skills/extracting-form-fields/references/Nonfillable-Forms.md create mode 100644 skills/extracting-form-fields/scripts/check_bounding_boxes.py create mode 100644 skills/extracting-form-fields/scripts/check_bounding_boxes_test.py create mode 100644 skills/extracting-form-fields/scripts/check_fillable_fields.py create mode 100644 skills/extracting-form-fields/scripts/convert_coordinates.py create mode 100644 skills/extracting-form-fields/scripts/convert_pdf_to_images.py create mode 100644 skills/extracting-form-fields/scripts/create_validation_image.py create mode 100644 skills/extracting-form-fields/scripts/extract_form_field_info.py create mode 100644 skills/filling-pdf-forms/.gitignore create mode 100644 skills/filling-pdf-forms/LICENSE.txt create mode 100644 skills/filling-pdf-forms/SKILL.md create mode 100644 skills/filling-pdf-forms/references/AskUserQuestion-Rules.md create mode 100644 skills/filling-pdf-forms/references/CLI-Interview-Loop.md create mode 100644 skills/filling-pdf-forms/references/Converting-PDF-To-Chatfield.md create mode 100644 skills/filling-pdf-forms/references/Data-Model-API.md create mode 100644 skills/filling-pdf-forms/references/Populating-Fillable.md create mode 100644 skills/filling-pdf-forms/references/Populating-Nonfillable.md create mode 100644 skills/filling-pdf-forms/references/Translating.md create mode 100644 skills/filling-pdf-forms/scripts/chatfield-1.0.0a2-py3-none-any.whl create mode 100644 skills/filling-pdf-forms/scripts/chatfield_interview_template.py create mode 100644 skills/filling-pdf-forms/scripts/extract_form_field_info.py create mode 100644 skills/filling-pdf-forms/scripts/fill_fillable_fields.py create mode 100644 skills/filling-pdf-forms/scripts/fill_nonfillable_fields.py diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json new file mode 100644 index 0000000..37659ce --- /dev/null +++ b/.claude-plugin/plugin.json @@ -0,0 +1,18 @@ +{ + "name": "filling-pdf-forms", + "description": "Complete PDF forms through conversation", + "version": "0.0.0-2025.11.28", + "author": { + "name": "Jason Smith", + "email": "jason.h.smith@gmail.com" + }, + "skills": [ + "./skills" + ], + "agents": [ + "./agents" + ], + "commands": [ + "./commands" + ] +} \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..dc39e0c --- /dev/null +++ b/README.md @@ -0,0 +1,3 @@ +# filling-pdf-forms + +Complete PDF forms through conversation diff --git a/agents/extracting-form-fields.md b/agents/extracting-form-fields.md new file mode 100644 index 0000000..72a7dc6 --- /dev/null +++ b/agents/extracting-form-fields.md @@ -0,0 +1,49 @@ +--- +name: extracting-form-fields +description: Extract form fields from a PDF form +model: inherit +skills: extracting-form-fields +--- + +# Extract Form Fields from PDF + + +You are a specialized PDF form field extraction agent with expertise in analyzing both fillable and non-fillable PDFs. + +**Your Output**: Structured field metadata files ready for chatfield interview creation + + +## Input + + +You will receive: +- **PDF path**: Filesystem path to the PDF file (e.g., `/home/user/documents/application.pdf`) + + +## Task Overview + + +Use the `extracting-form-fields` skill to extract form field data from the PDF. + +The skill will automatically: +1. Determine if the PDF is fillable or non-fillable +2. Create working directory (`.chatfield/`) +3. Extract PDF content as Markdown +4. Extract field metadata (automatically for fillable PDFs, guided for non-fillable PDFs) +5. Copy the interview template + + +If an unrecoverable error happens, halt and report the error verbatim. + +## Process + + +Before beginning extraction, think through: +1. What is the PDF file path provided by the user? +2. Is this path absolute or relative? (Convert to absolute if needed) +3. What is the basename for this PDF? (e.g., `tax-form.pdf` → `tax-form`) + + +## Output + +When complete, state whether the PDF file is "FILLABLE" or "NON-FILLABLE" \ No newline at end of file diff --git a/commands/pdf.md b/commands/pdf.md new file mode 100644 index 0000000..ddbdb8f --- /dev/null +++ b/commands/pdf.md @@ -0,0 +1,7 @@ +--- +description: Complete a PDF form +--- + +# Form Command + +Use the filling-pdf-forms skill to help the user complete their indicated PDF form. diff --git a/plugin.lock.json b/plugin.lock.json new file mode 100644 index 0000000..4047489 --- /dev/null +++ b/plugin.lock.json @@ -0,0 +1,153 @@ +{ + "$schema": "internal://schemas/plugin.lock.v1.json", + "pluginId": "gh:jhs/Chatfield:Claude/filling-pdf-forms", + "normalized": { + "repo": null, + "ref": "refs/tags/v20251128.0", + "commit": "72d444cf2d9c4f460e77a6e1ae483150aa94fd57", + "treeHash": "ddfe583e3d499c1a3e5316143ddfe1c1b84f96a5c9a7b1fc1b64a40dbef4ed02", + "generatedAt": "2025-11-28T10:19:06.647369Z", + "toolVersion": "publish_plugins.py@0.2.0" + }, + "origin": { + "remote": "git@github.com:zhongweili/42plugin-data.git", + "branch": "master", + "commit": "aa1497ed0949fd50e99e70d6324a29c5b34f9390", + "repoRoot": "/Users/zhongweili/projects/openmind/42plugin-data" + }, + "manifest": { + "name": "filling-pdf-forms", + "description": "Complete PDF forms through conversation", + "version": null + }, + "content": { + "files": [ + { + "path": "README.md", + "sha256": "57ba62755aa17aff3dba9dffb8c3cbf78e9c8553f3ff1d0c7af4cc302c60bc7a" + }, + { + "path": "agents/extracting-form-fields.md", + "sha256": "51077597b547a5685fafeb1a20416e93926928d7d80ec3036d9130fc14be7066" + }, + { + "path": ".claude-plugin/plugin.json", + "sha256": "0ebfca4618a013e9ecf6d8c91f383ee4c174d4f4224b620a16a364fec6f523b5" + }, + { + "path": "commands/pdf.md", + "sha256": "78305f47c72402626b01939b43673ba518f56e1d76ce829932ef4d8441d1990e" + }, + { + "path": "skills/extracting-form-fields/.gitignore", + "sha256": "cf56f7d7cbebabe7a9c2fe006cf710bd46dbfca4353211be3745297836e0b599" + }, + { + "path": "skills/extracting-form-fields/SKILL.md", + "sha256": "ae0463e1041950a1ad5d267ded456110de5a190e87c1129d878d4632f9dee35c" + }, + { + "path": "skills/extracting-form-fields/references/Nonfillable-Forms.md", + "sha256": "ac2450f97c9d8fcb3edda9fe47115ab0ebccca9cca00e32b3aaf1f87960d7bdd" + }, + { + "path": "skills/extracting-form-fields/references/Fillable-Forms.md", + "sha256": "acb29479f1fcfa44aef8fd7f82faea5caefd6d0d7c169365feac3c9ca2719cab" + }, + { + "path": "skills/extracting-form-fields/scripts/convert_pdf_to_images.py", + "sha256": "095a0105a718af75ede309cb03f84a20c81d17f1727f7686fd4b294f1f40294f" + }, + { + "path": "skills/extracting-form-fields/scripts/convert_coordinates.py", + "sha256": "dbfdad4a24fde97853183db0563897b6339fe6e4e27bf518fd7a409d976cfb20" + }, + { + "path": "skills/extracting-form-fields/scripts/extract_form_field_info.py", + "sha256": "4ef43439008567c01530fd5cbe6a803c73b7980b8d9317f8ba3fe98042c64ba0" + }, + { + "path": "skills/extracting-form-fields/scripts/check_bounding_boxes.py", + "sha256": "058ad8a37e53f5b978a079c7929c6ad3443b39956371a4663ade002224a23d11" + }, + { + "path": "skills/extracting-form-fields/scripts/check_bounding_boxes_test.py", + "sha256": "f95dca01a8b79aafd152511e9f7bf2bbcd606dde1be77d691f03a18624e002ca" + }, + { + "path": "skills/extracting-form-fields/scripts/create_validation_image.py", + "sha256": "c331cb71925e94b1dbad87e0194567d0ad2f9143d16771d0d11605a7f300cb5b" + }, + { + "path": "skills/extracting-form-fields/scripts/check_fillable_fields.py", + "sha256": "58c41dca989754cf7d9ee81363d8e82c01bf2f542a19f813a52fd79741e6b0ff" + }, + { + "path": "skills/filling-pdf-forms/.gitignore", + "sha256": "cf56f7d7cbebabe7a9c2fe006cf710bd46dbfca4353211be3745297836e0b599" + }, + { + "path": "skills/filling-pdf-forms/SKILL.md", + "sha256": "d81c1c38edc609cbc71d303ed289e45dc148bc7fad33dc314082444996dd3217" + }, + { + "path": "skills/filling-pdf-forms/LICENSE.txt", + "sha256": "79f6d8f5b427252fa3b1c11ecdbdb6bf610b944f7530b4de78f770f38741cfaa" + }, + { + "path": "skills/filling-pdf-forms/references/Data-Model-API.md", + "sha256": "fe4f8bb844151447d4bd1de0386703aacc60c201906db09ecf0f2ad863b8015c" + }, + { + "path": "skills/filling-pdf-forms/references/AskUserQuestion-Rules.md", + "sha256": "21ae28b44e7b4c9496bb758960e30bd4913387d5f1c1f9ae2ff09bac054c1574" + }, + { + "path": "skills/filling-pdf-forms/references/Populating-Nonfillable.md", + "sha256": "a798e4f85503b58919a2e1233a739a048205cb6ab265e3b385c9664bc315216b" + }, + { + "path": "skills/filling-pdf-forms/references/CLI-Interview-Loop.md", + "sha256": "7ad179062c2bfc9bf582f015017f4a508f19ca68e60638b252fb2e9735e6a1f6" + }, + { + "path": "skills/filling-pdf-forms/references/Translating.md", + "sha256": "663ad32457899e6493b81a045a638530c6adf3a661e8aa54266ba7ba39d90ec6" + }, + { + "path": "skills/filling-pdf-forms/references/Populating-Fillable.md", + "sha256": "142ba2308f96f47d6c14b55172d1cc1b037830c5859248d9a79ba66c80487216" + }, + { + "path": "skills/filling-pdf-forms/references/Converting-PDF-To-Chatfield.md", + "sha256": "9daf04e7e26e0eb3b998be949fc969623c065d82ce66b392dd141de7faa6d586" + }, + { + "path": "skills/filling-pdf-forms/scripts/chatfield_interview_template.py", + "sha256": "ed5391bf3f2d46c3cb74c5ff4003e1d18e955084e3d15af1dad5ffb9747ce7f0" + }, + { + "path": "skills/filling-pdf-forms/scripts/fill_fillable_fields.py", + "sha256": "65b3e41969707022283a313a4cf9696d31793cbe255dffe13370e75abda448a7" + }, + { + "path": "skills/filling-pdf-forms/scripts/chatfield-1.0.0a2-py3-none-any.whl", + "sha256": "1a57f592435ce99645fe83309cd8953205aa558be858242db5383e4715758c00" + }, + { + "path": "skills/filling-pdf-forms/scripts/extract_form_field_info.py", + "sha256": "4ef43439008567c01530fd5cbe6a803c73b7980b8d9317f8ba3fe98042c64ba0" + }, + { + "path": "skills/filling-pdf-forms/scripts/fill_nonfillable_fields.py", + "sha256": "09ef5c9c55d9fe787fc5bf38f0f619bd67a3a9a612874240e669f9cf65c8d8f9" + } + ], + "dirSha256": "ddfe583e3d499c1a3e5316143ddfe1c1b84f96a5c9a7b1fc1b64a40dbef4ed02" + }, + "security": { + "scannedAt": null, + "scannerVersion": null, + "flags": [] + } +} \ No newline at end of file diff --git a/skills/extracting-form-fields/.gitignore b/skills/extracting-form-fields/.gitignore new file mode 100644 index 0000000..a247a86 --- /dev/null +++ b/skills/extracting-form-fields/.gitignore @@ -0,0 +1,2 @@ +*.png +*.plantuml diff --git a/skills/extracting-form-fields/SKILL.md b/skills/extracting-form-fields/SKILL.md new file mode 100644 index 0000000..5c6b83c --- /dev/null +++ b/skills/extracting-form-fields/SKILL.md @@ -0,0 +1,117 @@ +--- +name: extracting-form-fields +description: Extract form field data from PDFs as a first step to filling PDF forms +allowed-tools: Read, Write, Edit, Glob, Bash +version: 1.0.0a2 +license: Apache 2.0 +--- + +# Extracting Form Fields + +Prepare working directory and extract field data from PDF forms. + + +This skill extracts PDF form information into useful JSON. +- Detects fillable vs. non-fillable PDFs +- Extracts PDF content as readable Markdown +- Creates field metadata in common JSON format + + +## Inputs + +- **PDF path**: Path to PDF file (e.g., `/home/user/input.pdf`) + +## Process Overview + +```plantuml +@startuml SKILL +title Extracting Form Fields - High-Level Workflow +start +:Create working directory; +:Copy interview template; +:Extract PDF content as Markdown; +:Check Fillability; +if (PDF has fillable fields?) then (yes) + :Fillable workflow + (see Fillable-Forms.md); +else (no) + :Non-fillable workflow + (see Nonfillable-Forms.md); +endif +:**✓ EXTRACTION COMPLETE**; +:Ready for Form Data Model creation; +stop +@enduml +``` + +## Process + +### 1. Create Working Directory + +```bash +mkdir .chatfield +``` + +### 2. Copy Interview Template + +Copy a file from the included `filling-pdf-forms` skill's template. The example path below is relative to this skill directory. + +```bash +cp ../filling-pdf-forms/scripts/chatfield_interview_template.py .chatfield/interview.py +``` + +### 3. Extract PDF Content + +```bash +markitdown > .chatfield/.form.md +``` + +### 4. Check Fillability + +```bash +python scripts/check_fillable_fields.py +``` + +**Output:** +- `"This PDF has fillable form fields"` → use fillable workflow +- `"This PDF does not have fillable form fields"` → use non-fillable workflow + +### 5. Branch Based on Fillability + +#### If Fillable: + +Follow ./references/Fillable-Forms.md + +#### If Non-fillable: + +Follow ./references/Nonfillable-Forms.md + +## Output Format + +### Fillable PDFs - .form.json + +```json +[ + { + "field_id": "topmostSubform[0].Page1[0].f1_01[0]", + "type": "text", + "page": 1, + "rect": [100, 200, 300, 220], + "tooltip": "Enter your full legal name", + "max_length": null + }, + { + "field_id": "checkbox_over_18", + "type": "checkbox", + "page": 1, + "rect": [150, 250, 165, 265], + "checked_value": "/1", + "unchecked_value": "/Off" + } +] +``` + +## References + +- ./references/Fillable-Forms.md - Fillable PDF extraction workflow +- ./references/Nonfillable-Forms.md - Non-fillable PDF extraction workflow \ No newline at end of file diff --git a/skills/extracting-form-fields/references/Fillable-Forms.md b/skills/extracting-form-fields/references/Fillable-Forms.md new file mode 100644 index 0000000..63d45aa --- /dev/null +++ b/skills/extracting-form-fields/references/Fillable-Forms.md @@ -0,0 +1,29 @@ +# Fillable PDF Forms - Extraction Guide + +This guide is for the "extracting-form-fields" agent performing extraction on fillable PDFs. + +## Process Overview + +```plantuml +@startuml Fillable-Forms +title Fillable PDF Forms - Extraction Workflow +start +:Extract form field metadata; +:**✓ FILLABLE EXTRACTION COMPLETE**; +stop +@enduml +``` + +## Extraction Process + +### 1. Extract Form Field Metadata + +```bash +python scripts/extract_form_field_info.py input.pdf input.chatfield/input.form.json +``` + +This creates a JSON file with field metadata: + +## Completion Report + +After extraction, simply state "Done". If there is an unrecoverable error, halt and report the error verbatim. \ No newline at end of file diff --git a/skills/extracting-form-fields/references/Nonfillable-Forms.md b/skills/extracting-form-fields/references/Nonfillable-Forms.md new file mode 100644 index 0000000..5440bdd --- /dev/null +++ b/skills/extracting-form-fields/references/Nonfillable-Forms.md @@ -0,0 +1,218 @@ +# Non-fillable PDF Forms - Extraction Guide + +You'll need to visually determine where the data should be added as text annotations. Follow the below steps *exactly*. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below. +- Convert the PDF to PNG images and determine field bounding boxes. +- Create a JSON file with field information and validation images showing the bounding boxes. +- Validate the the bounding boxes. + +## Process Overview + +```plantuml +@startuml Nonfillable-Forms +title Non-fillable PDF Forms - Extraction Workflow +start +:Convert PDF to PNG images; +:Visual analysis & determine bounding boxes +in IMAGE coordinates; +:Create .scan.json; +repeat + :Automated intersection check + on image coordinates; + if (Automated check passes?) then (yes) + :Create validation images + (overlay on PNGs); + :Manual image inspection; + if (Manual check passes?) then (yes) + else (no) + :Fix bounding boxes in .scan.json; + endif + else (no) + :Fix bounding boxes in .scan.json; + endif +repeat while (Both checks pass?) is (no) +->yes; +:Convert coordinates +(.scan.json → .form.json); +:**✓ NON-FILLABLE EXTRACTION COMPLETE** +.form.json ready with PDF coordinates; +stop +@enduml +``` + +## Extraction Process + +## Step 1: Visual Analysis (REQUIRED) +- Convert the PDF to PNG images. Run this script from this skill's directory: +```bash +python scripts/convert_pdf_to_images.py .pdf .chatfield/ +``` +The script will create a PNG image for each page. +- Read and analyze the the .form.md file which is a Markdown text preview of the PDF content +- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter information, determine bounding boxes, in the image coordinate system, for both the field label and the input entry area. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text. + +These are some examples of form structures that you might see (in English, but the form can be any language): + +*Label inside box* +``` +┌────────────────────────┐ +│ Name: │ +└────────────────────────┘ +``` +The input area should be to the right of the "Name" label and extend to the edge of the box. + +*Label before line* +``` +Email: _______________________ +``` +The input area should be above the line and include its entire width. + +*Label under line* +``` +_________________________ +Name +``` +The input area should be above the line and include the entire width of the line. This is common for signature and date fields. + +*Label above line* +``` +Please enter any special requests: +________________________________________________ +``` +The input area should extend from the bottom of the label to the line, and should include the entire width of the line. + +*Checkboxes* +``` +Are you a US citizen? Yes □ No □ +``` +For checkboxes: +- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels. +- Distinguish between label text ("Yes", "No") and the clickable checkbox squares. +- The entry bounding box should cover ONLY the small square, not the text label. + +## Step 2: Create .scan.json + +Create `.chatfield/.scan.json` formatted like the below example. Rectangle values are **IMAGE coordinates** (what you see directly in the PNG, top-left origin). + +```json +[ + { + "field_id": "full_name", + "type": "text", + "page": 1, + "rect": [180, 200, 550, 220], + "label_text": "Full Name:", + "label_rect": [50, 200, 175, 220] + }, + { + "field_id": "is_citizen", + "type": "checkbox", + "page": 1, + "rect": [60, 320, 75, 335], + "label_text": "US Citizen", + "label_rect": [80, 320, 150, 335], + "checked_value": "X", + "unchecked_value": "" + } +] +``` + +**Field structure:** +- `field_id` - Unique identifier (will be used in chatfield definition) + - **CRITICAL:** Every field MUST have a unique field_id with no collisions + - Field IDs are internal identifiers, not user-facing +- `type` - "text" or "checkbox" +- `page` - Page number (1-indexed) +- `rect` - Entry area bounding box [x1, y1, x2, y2] where data will be written +- `label_text` - Optional label text for this field +- `label_rect` - Optional label bounding box [x1, y1, x2, y2] +- For checkboxes only: + - `checked_value` - String to write when checked (typically "X" or "✓") + - `unchecked_value` - String to write when unchecked (typically "") + +**Bounding box coordinates (IMAGE COORDINATES):** +- Image coordinate system: Origin (0,0) at top-left +- X increases to the right, Y increases downward +- Format: `[x1, y1, x2, y2]` where (x1,y1) is top-left corner, (x2,y2) is bottom-right corner +- These are the pixel coordinates you see directly in the PNG image +- Entry boxes (`rect`) must be tall and wide enough to contain text +- Label boxes (`label_rect`) should contain the label text +- Entry and label boxes MUST NOT overlap +- Checkboxes should be at least 10-20 pixels square + +## Step 3: Validate Bounding Boxes (REQUIRED) + +This is a two-stage validation process. You must pass the automated check before proceeding to manual inspection. + +### Stage 1: Automated intersection check + +Run the automated check script: + +```bash +python scripts/check_bounding_boxes.py .chatfield/.scan.json +``` + +**What it checks:** +- Label/entry bounding box intersections (must not overlap) +- Boxes too small to contain text +- Missing required fields + +**If there are errors:** Fix the bounding boxes in `.scan.json` and re-run the automated check. Iterate until there are no remaining errors. + +**Only proceed to Stage 2 once all automated checks pass.** + +### Stage 2: Manual image inspection + +Create validation images for each page: + +```bash +# For each page (e.g., if you have 3 pages) +python scripts/create_validation_image.py 1 .chatfield/.scan.json .chatfield/page_1.png .chatfield/page_1_validation.png +python scripts/create_validation_image.py 2 .chatfield/.scan.json .chatfield/page_2.png .chatfield/page_2_validation.png +python scripts/create_validation_image.py 3 .chatfield/.scan.json .chatfield/page_3.png .chatfield/page_3_validation.png +``` + +This overlays colored rectangles (red for entry boxes, blue for labels) on the PNG images to visualize bounding boxes. + +**CRITICAL: Visually inspect validation images** + +Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not. +- Red rectangles must ONLY cover input areas +- Red rectangles MUST NOT contain any text or labels +- Blue rectangles should contain label text +- For checkboxes: + - Red rectangle MUST be centered on the checkbox square + - Blue rectangle should cover the text label for the checkbox + +**If any rectangles look wrong:** Fix bounding boxes in `.scan.json`, then return to Stage 1 (automated check gate). You must pass both stages again. + +## Step 4: Convert to PDF Coordinates + +Once all validation passes, convert the image coordinates to PDF coordinates: + +```bash +python scripts/convert_coordinates.py .chatfield/.scan.json .pdf +``` + +## Troubleshooting + +**Bounding boxes don't align in validation images:** +- Review the validation image carefully +- Adjust coordinates in `.scan.json` +- Remember: You're using IMAGE coordinates (origin at top-left, Y downward) +- Re-run validation after changes + +**Text gets cut off:** +- Increase bounding box height and/or width in `.scan.json` +- Entry boxes should have extra space for text + +**Validation script errors:** +- Ensure all page images exist in `.chatfield/` +- Verify JSON syntax in `.scan.json` +- Check that page numbers are 1-indexed + +--- + +**See Also:** +- ../../filling-pdf-forms/references/Converting-PDF-To-Chatfield.md - How the main skill builds the interview +- ./Fillable-Forms.md - Alternative extraction for fillable PDFs +- ../../filling-pdf-forms/references/populating.md - How bounding boxes are used during PDF population \ No newline at end of file diff --git a/skills/extracting-form-fields/scripts/check_bounding_boxes.py b/skills/extracting-form-fields/scripts/check_bounding_boxes.py new file mode 100644 index 0000000..9abd0bd --- /dev/null +++ b/skills/extracting-form-fields/scripts/check_bounding_boxes.py @@ -0,0 +1,78 @@ +from dataclasses import dataclass +import json +import sys + + +# Script to check that bounding boxes in a JSON file do not overlap or have other issues. +# Works with any coordinate system since it only checks geometric relationships. + + +@dataclass +class RectAndField: + rect: list[float] + rect_type: str + field: dict + + +# Returns a list of messages that are printed to stdout for Claude to read. +def get_bounding_box_messages(fields_json_stream) -> list[str]: + messages = [] + fields = json.load(fields_json_stream) + messages.append(f"Read {len(fields)} fields") + + def rects_intersect(r1, r2): + disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0] + disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1] + return not (disjoint_horizontal or disjoint_vertical) + + rects_and_fields = [] + for f in fields: + # Skip empty label rects (used for fields without labels) + label_rect = f.get('label_rect', [0, 0, 0, 0]) + if label_rect != [0, 0, 0, 0]: + rects_and_fields.append(RectAndField(label_rect, "label", f)) + rects_and_fields.append(RectAndField(f['rect'], "entry", f)) + + has_error = False + for i, ri in enumerate(rects_and_fields): + # This is O(N^2); we can optimize if it becomes a problem. + for j in range(i + 1, len(rects_and_fields)): + rj = rects_and_fields[j] + if ri.field['page'] == rj.field['page'] and rects_intersect(ri.rect, rj.rect): + has_error = True + if ri.field is rj.field: + messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['field_id']}` ({ri.rect}, {rj.rect})") + else: + messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['field_id']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['field_id']}` ({rj.rect})") + if len(messages) >= 20: + messages.append("Aborting further checks; fix bounding boxes and try again") + return messages + if ri.rect_type == "entry": + if "entry_text" in ri.field: + font_size = ri.field["entry_text"].get("font_size", 14) + entry_height = ri.rect[3] - ri.rect[1] + if entry_height < font_size: + has_error = True + messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['field_id']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.") + if len(messages) >= 20: + messages.append("Aborting further checks; fix bounding boxes and try again") + return messages + + if not has_error: + messages.append("SUCCESS: All bounding boxes are valid") + return messages + +if __name__ == "__main__": + if len(sys.argv) != 2: + print("Usage: check_bounding_boxes.py [fields.json or scan.json]") + print() + print("Examples:") + print(" python check_bounding_boxes.py form.chatfield/form.scan.json") + print(" python check_bounding_boxes.py form.chatfield/form.form.json") + sys.exit(1) + # Input file can be .scan.json (image coords) or .form.json (PDF coords) + # The geometry checks work the same either way + with open(sys.argv[1]) as f: + messages = get_bounding_box_messages(f) + for msg in messages: + print(msg) diff --git a/skills/extracting-form-fields/scripts/check_bounding_boxes_test.py b/skills/extracting-form-fields/scripts/check_bounding_boxes_test.py new file mode 100644 index 0000000..1dbb463 --- /dev/null +++ b/skills/extracting-form-fields/scripts/check_bounding_boxes_test.py @@ -0,0 +1,226 @@ +import unittest +import json +import io +from check_bounding_boxes import get_bounding_box_messages + + +# Currently this is not run automatically in CI; it's just for documentation and manual checking. +class TestGetBoundingBoxMessages(unittest.TestCase): + + def create_json_stream(self, data): + """Helper to create a JSON stream from data""" + return io.StringIO(json.dumps(data)) + + def test_no_intersections(self): + """Test case with no bounding box intersections""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 1, + "label_bounding_box": [10, 40, 50, 60], + "entry_bounding_box": [60, 40, 150, 60] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_label_entry_intersection_same_field(self): + """Test intersection between label and entry of the same field""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 60, 30], + "entry_bounding_box": [50, 10, 150, 30] # Overlaps with label + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_intersection_between_different_fields(self): + """Test intersection between bounding boxes of different fields""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 1, + "label_bounding_box": [40, 20, 80, 40], # Overlaps with Name's boxes + "entry_bounding_box": [160, 10, 250, 30] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_different_pages_no_intersection(self): + """Test that boxes on different pages don't count as intersecting""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30] + }, + { + "description": "Email", + "page_number": 2, + "label_bounding_box": [10, 10, 50, 30], # Same coordinates but different page + "entry_bounding_box": [60, 10, 150, 30] + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_entry_height_too_small(self): + """Test that entry box height is checked against font size""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20], # Height is 10 + "entry_text": { + "font_size": 14 # Font size larger than height + } + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_entry_height_adequate(self): + """Test that adequate entry box height passes""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 30], # Height is 20 + "entry_text": { + "font_size": 14 # Font size smaller than height + } + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_default_font_size(self): + """Test that default font size is used when not specified""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20], # Height is 10 + "entry_text": {} # No font_size specified, should use default 14 + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages)) + self.assertFalse(any("SUCCESS" in msg for msg in messages)) + + def test_no_entry_text(self): + """Test that missing entry_text doesn't cause height check""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [60, 10, 150, 20] # Small height but no entry_text + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + def test_multiple_errors_limit(self): + """Test that error messages are limited to prevent excessive output""" + fields = [] + # Create many overlapping fields + for i in range(25): + fields.append({ + "description": f"Field{i}", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], # All overlap + "entry_bounding_box": [20, 15, 60, 35] # All overlap + }) + + data = {"form_fields": fields} + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + # Should abort after ~20 messages + self.assertTrue(any("Aborting" in msg for msg in messages)) + # Should have some FAILURE messages but not hundreds + failure_count = sum(1 for msg in messages if "FAILURE" in msg) + self.assertGreater(failure_count, 0) + self.assertLess(len(messages), 30) # Should be limited + + def test_edge_touching_boxes(self): + """Test that boxes touching at edges don't count as intersecting""" + data = { + "form_fields": [ + { + "description": "Name", + "page_number": 1, + "label_bounding_box": [10, 10, 50, 30], + "entry_bounding_box": [50, 10, 150, 30] # Touches at x=50 + } + ] + } + + stream = self.create_json_stream(data) + messages = get_bounding_box_messages(stream) + self.assertTrue(any("SUCCESS" in msg for msg in messages)) + self.assertFalse(any("FAILURE" in msg for msg in messages)) + + +if __name__ == '__main__': + unittest.main() diff --git a/skills/extracting-form-fields/scripts/check_fillable_fields.py b/skills/extracting-form-fields/scripts/check_fillable_fields.py new file mode 100644 index 0000000..0349061 --- /dev/null +++ b/skills/extracting-form-fields/scripts/check_fillable_fields.py @@ -0,0 +1,12 @@ +import sys +from pypdf import PdfReader + + +# Script for Claude to run to determine whether a PDF has fillable form fields. See forms.md. + + +reader = PdfReader(sys.argv[1]) +if (reader.get_fields()): + print("This PDF has fillable form fields") +else: + print("This PDF does not have fillable form fields") diff --git a/skills/extracting-form-fields/scripts/convert_coordinates.py b/skills/extracting-form-fields/scripts/convert_coordinates.py new file mode 100644 index 0000000..f0490e6 --- /dev/null +++ b/skills/extracting-form-fields/scripts/convert_coordinates.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +""" +Converts bounding box coordinates from image coordinates to PDF coordinates. + +This script takes a .scan.json file (with image coordinates) and converts all +bounding boxes to PDF coordinates, producing a .form.json file. + +Image coordinates: Origin at top-left, Y increases downward +PDF coordinates: Origin at bottom-left, Y increases upward + +Usage: + python convert_coordinates.py +""" + +import json +import sys +from pathlib import Path + +from PIL import Image +from pypdf import PdfReader + + +def image_to_pdf_coords(image_bbox, image_width, image_height, pdf_width, pdf_height): + """ + Convert bounding box from image coordinates to PDF coordinates. + + Args: + image_bbox: [x1, y1, x2, y2] in image coordinates (top-left origin) + image_width: Width of the image in pixels + image_height: Height of the image in pixels + pdf_width: Width of the PDF page in points + pdf_height: Height of the PDF page in points + + Returns: + [x1, y1, x2, y2] in PDF coordinates (bottom-left origin) + """ + x_scale = pdf_width / image_width + y_scale = pdf_height / image_height + + # Convert X coordinates (simple scaling, same origin) + pdf_x1 = image_bbox[0] * x_scale + pdf_x2 = image_bbox[2] * x_scale + + # Convert Y coordinates (flip vertical axis) + # Image: y1 is top, y2 is bottom (y1 < y2 in image coords) + # PDF: need to flip - what's at top of image is high Y in PDF + pdf_y1 = (image_height - image_bbox[3]) * y_scale # Bottom in PDF (was bottom in image) + pdf_y2 = (image_height - image_bbox[1]) * y_scale # Top in PDF (was top in image) + + return [pdf_x1, pdf_y1, pdf_x2, pdf_y2] + + +def get_image_dimensions(images_dir, page_number): + """Get dimensions of the PNG image for a specific page.""" + image_path = Path(images_dir) / f"page_{page_number}.png" + if not image_path.exists(): + raise FileNotFoundError(f"Image not found: {image_path}") + + with Image.open(image_path) as img: + return img.width, img.height + + +def convert_scan_to_form(scan_json_path, pdf_path, output_json_path): + """ + Convert .scan.json (image coords) to .form.json (PDF coords). + + Args: + scan_json_path: Path to input .scan.json file + pdf_path: Path to the PDF file + output_json_path: Path to output .form.json file + """ + # Load scan data + with open(scan_json_path, 'r') as f: + fields = json.load(f) + + # Get PDF dimensions + reader = PdfReader(pdf_path) + + # Determine images directory (same directory as scan.json) + scan_path = Path(scan_json_path) + images_dir = scan_path.parent + + if not images_dir.exists(): + raise FileNotFoundError( + f"Images directory not found: {images_dir}\n" + f"Expected to find page images in {images_dir}" + ) + + # Convert each field + converted_fields = [] + + for field in fields: + page_num = field.get('page', 1) + + # Get dimensions for this page + page = reader.pages[page_num - 1] # Convert to 0-indexed + pdf_width = float(page.mediabox.width) + pdf_height = float(page.mediabox.height) + image_width, image_height = get_image_dimensions(images_dir, page_num) + + # Create converted field + converted_field = field.copy() + + # Convert main rect + if 'rect' in field: + converted_field['rect'] = image_to_pdf_coords( + field['rect'], + image_width, image_height, + pdf_width, pdf_height + ) + + # Convert label_rect if present + if 'label_rect' in field: + converted_field['label_rect'] = image_to_pdf_coords( + field['label_rect'], + image_width, image_height, + pdf_width, pdf_height + ) + + # Convert radio button options if present + if 'radio_options' in field: + converted_options = [] + for option in field['radio_options']: + converted_option = option.copy() + if 'rect' in option: + converted_option['rect'] = image_to_pdf_coords( + option['rect'], + image_width, image_height, + pdf_width, pdf_height + ) + converted_options.append(converted_option) + converted_field['radio_options'] = converted_options + + converted_fields.append(converted_field) + + # Write output + with open(output_json_path, 'w') as f: + json.dump(converted_fields, f, indent=2) + + print(f"Converted {len(converted_fields)} fields from image to PDF coordinates") + print(f"Input: {scan_json_path}") + print(f"Output: {output_json_path}") + + # Show an example conversion + if converted_fields: + print("\nExample conversion (first field):") + orig = fields[0] + conv = converted_fields[0] + print(f" Field: {orig.get('field_id')}") + print(f" Image rect: {orig.get('rect')}") + print(f" PDF rect: {conv.get('rect')}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: convert_coordinates.py ") + print() + print("Example:") + print(" python convert_coordinates.py my_form.chatfield/my_form.scan.json my_form.pdf") + print() + print("Output filename is automatically computed by replacing .scan.json with .form.json") + sys.exit(1) + + scan_json_path = sys.argv[1] + pdf_path = sys.argv[2] + + # Compute output filename by replacing .scan.json with .form.json + scan_path = Path(scan_json_path) + if not scan_path.name.endswith('.scan.json'): + print(f"Error: Input file must end with .scan.json, got: {scan_path.name}", file=sys.stderr) + sys.exit(1) + + output_json_path = str(scan_path.parent / scan_path.name.replace('.scan.json', '.form.json')) + + try: + convert_scan_to_form(scan_json_path, pdf_path, output_json_path) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) diff --git a/skills/extracting-form-fields/scripts/convert_pdf_to_images.py b/skills/extracting-form-fields/scripts/convert_pdf_to_images.py new file mode 100644 index 0000000..f8a4ec5 --- /dev/null +++ b/skills/extracting-form-fields/scripts/convert_pdf_to_images.py @@ -0,0 +1,35 @@ +import os +import sys + +from pdf2image import convert_from_path + + +# Converts each page of a PDF to a PNG image. + + +def convert(pdf_path, output_dir, max_dim=1000): + images = convert_from_path(pdf_path, dpi=200) + + for i, image in enumerate(images): + # Scale image if needed to keep width/height under `max_dim` + width, height = image.size + if width > max_dim or height > max_dim: + scale_factor = min(max_dim / width, max_dim / height) + new_width = int(width * scale_factor) + new_height = int(height * scale_factor) + image = image.resize((new_width, new_height)) + + image_path = os.path.join(output_dir, f"page_{i+1}.png") + image.save(image_path) + print(f"Saved page {i+1} as {image_path} (size: {image.size})") + + print(f"Converted {len(images)} pages to PNG images") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: convert_pdf_to_images.py [input pdf] [output directory]") + sys.exit(1) + pdf_path = sys.argv[1] + output_directory = sys.argv[2] + convert(pdf_path, output_directory) diff --git a/skills/extracting-form-fields/scripts/create_validation_image.py b/skills/extracting-form-fields/scripts/create_validation_image.py new file mode 100644 index 0000000..a4381f9 --- /dev/null +++ b/skills/extracting-form-fields/scripts/create_validation_image.py @@ -0,0 +1,59 @@ +import json +import sys + +from PIL import Image, ImageDraw + + +# Creates "validation" images with rectangles for the bounding box information that +# Claude creates when determining where to add text annotations in PDFs. +# This version works with IMAGE coordinates (from .scan.json files). + + +def create_validation_image(page_number, fields_json_path, input_path, output_path): + """ + Create a validation image with bounding boxes overlaid. + + Args: + page_number: Page number (1-indexed) + fields_json_path: Path to .scan.json file (IMAGE coordinates) + input_path: Path to input PNG image + output_path: Path to output validation image + """ + # Input file should be in the .scan.json format with IMAGE coordinates + with open(fields_json_path, 'r') as f: + fields = json.load(f) + + img = Image.open(input_path) + draw = ImageDraw.Draw(img) + num_boxes = 0 + + for field in fields: + if field['page'] == page_number: + # Coordinates are already in image space - use them directly! + entry_box_img = field['rect'] + label_box_img = field.get('label_rect', [0, 0, 0, 0]) + + # Draw red rectangle over entry bounding box + draw.rectangle(entry_box_img, outline='red', width=2) + num_boxes += 1 + + if label_box_img != [0, 0, 0, 0]: + draw.rectangle(label_box_img, outline='blue', width=2) + num_boxes += 1 + + img.save(output_path) + print(f"Created validation image at {output_path} with {num_boxes} bounding boxes") + + +if __name__ == "__main__": + if len(sys.argv) != 5: + print("Usage: create_validation_image.py [page number] [scan.json file] [input image path] [output image path]") + print() + print("Example:") + print(" python create_validation_image.py 1 form.chatfield/form.scan.json form.chatfield/page_1.png form.chatfield/page_1_validation.png") + sys.exit(1) + page_number = int(sys.argv[1]) + fields_json_path = sys.argv[2] + input_image_path = sys.argv[3] + output_image_path = sys.argv[4] + create_validation_image(page_number, fields_json_path, input_image_path, output_image_path) diff --git a/skills/extracting-form-fields/scripts/extract_form_field_info.py b/skills/extracting-form-fields/scripts/extract_form_field_info.py new file mode 100644 index 0000000..af71cd0 --- /dev/null +++ b/skills/extracting-form-fields/scripts/extract_form_field_info.py @@ -0,0 +1,158 @@ +import json +import sys + +from pypdf import PdfReader + + +# Extracts data for the fillable form fields in a PDF and outputs JSON that +# Claude uses to fill the fields. See forms.md. + + +# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods. +def get_full_annotation_field_id(annotation): + components = [] + while annotation: + field_name = annotation.get('/T') + if field_name: + components.append(field_name) + annotation = annotation.get('/Parent') + return ".".join(reversed(components)) if components else None + + +def make_field_dict(field, field_id): + field_dict = {"field_id": field_id} + ft = field.get('/FT') + if ft == "/Tx": + field_dict["type"] = "text" + elif ft == "/Btn": + field_dict["type"] = "checkbox" # radio groups handled separately + states = field.get("/_States_", []) + if len(states) == 2: + # "/Off" seems to always be the unchecked value, as suggested by + # https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448 + # It can be either first or second in the "/_States_" list. + if "/Off" in states: + field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1] + field_dict["unchecked_value"] = "/Off" + else: + 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.") + field_dict["checked_value"] = states[0] + field_dict["unchecked_value"] = states[1] + elif ft == "/Ch": + field_dict["type"] = "choice" + states = field.get("/_States_", []) + field_dict["choice_options"] = [{ + "value": state[0], + "text": state[1], + } for state in states] + else: + field_dict["type"] = f"unknown ({ft})" + + # Extract tooltip (TU = tooltip/user-facing text) + tooltip = field.get('/TU') + if tooltip: + field_dict["tooltip"] = tooltip + + return field_dict + + +# Returns a list of fillable PDF fields: +# [ +# { +# "field_id": "name", +# "page": 1, +# "type": ("text", "checkbox", "radio_group", or "choice") +# // Per-type additional fields described in forms.md +# }, +# ] +def get_field_info(reader: PdfReader): + fields = reader.get_fields() + + field_info_by_id = {} + possible_radio_names = set() + + for field_id, field in fields.items(): + # Skip if this is a container field with children, except that it might be + # a parent group for radio button options. + if field.get("/Kids"): + if field.get("/FT") == "/Btn": + possible_radio_names.add(field_id) + continue + field_info_by_id[field_id] = make_field_dict(field, field_id) + + # Bounding rects are stored in annotations in page objects. + + # Radio button options have a separate annotation for each choice; + # all choices have the same field name. + # See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html + radio_fields_by_id = {} + + for page_index, page in enumerate(reader.pages): + annotations = page.get('/Annots', []) + for ann in annotations: + field_id = get_full_annotation_field_id(ann) + if field_id in field_info_by_id: + field_info_by_id[field_id]["page"] = page_index + 1 + field_info_by_id[field_id]["rect"] = ann.get('/Rect') + elif field_id in possible_radio_names: + try: + # ann['/AP']['/N'] should have two items. One of them is '/Off', + # the other is the active value. + on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"] + except KeyError: + continue + if len(on_values) == 1: + rect = ann.get("/Rect") + if field_id not in radio_fields_by_id: + radio_fields_by_id[field_id] = { + "field_id": field_id, + "type": "radio_group", + "page": page_index + 1, + "radio_options": [], + } + # Note: at least on macOS 15.7, Preview.app doesn't show selected + # radio buttons correctly. (It does if you remove the leading slash + # from the value, but that causes them not to appear correctly in + # Chrome/Firefox/Acrobat/etc). + radio_fields_by_id[field_id]["radio_options"].append({ + "value": on_values[0], + "rect": rect, + }) + + # Some PDFs have form field definitions without corresponding annotations, + # so we can't tell where they are. Ignore these fields for now. + fields_with_location = [] + for field_info in field_info_by_id.values(): + if "page" in field_info: + fields_with_location.append(field_info) + else: + print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring") + + # Sort by page number, then Y position (flipped in PDF coordinate system), then X. + def sort_key(f): + if "radio_options" in f: + rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] + else: + rect = f.get("rect") or [0, 0, 0, 0] + adjusted_position = [-rect[1], rect[0]] + return [f.get("page"), adjusted_position] + + sorted_fields = fields_with_location + list(radio_fields_by_id.values()) + sorted_fields.sort(key=sort_key) + + return sorted_fields + + +def write_field_info(pdf_path: str, json_output_path: str): + reader = PdfReader(pdf_path) + field_info = get_field_info(reader) + with open(json_output_path, "w") as f: + json.dump(field_info, f, indent=2) + print(f"Wrote {len(field_info)} fields to {json_output_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: extract_form_field_info.py [input pdf] [output json]") + sys.exit(1) + write_field_info(sys.argv[1], sys.argv[2]) diff --git a/skills/filling-pdf-forms/.gitignore b/skills/filling-pdf-forms/.gitignore new file mode 100644 index 0000000..a247a86 --- /dev/null +++ b/skills/filling-pdf-forms/.gitignore @@ -0,0 +1,2 @@ +*.png +*.plantuml diff --git a/skills/filling-pdf-forms/LICENSE.txt b/skills/filling-pdf-forms/LICENSE.txt new file mode 100644 index 0000000..c55ab42 --- /dev/null +++ b/skills/filling-pdf-forms/LICENSE.txt @@ -0,0 +1,30 @@ +© 2025 Anthropic, PBC. All rights reserved. + +LICENSE: Use of these materials (including all code, prompts, assets, files, +and other components of this Skill) is governed by your agreement with +Anthropic regarding use of Anthropic's services. If no separate agreement +exists, use is governed by Anthropic's Consumer Terms of Service or +Commercial Terms of Service, as applicable: +https://www.anthropic.com/legal/consumer-terms +https://www.anthropic.com/legal/commercial-terms +Your applicable agreement is referred to as the "Agreement." "Services" are +as defined in the Agreement. + +ADDITIONAL RESTRICTIONS: Notwithstanding anything in the Agreement to the +contrary, users may not: + +- Extract these materials from the Services or retain copies of these + materials outside the Services +- Reproduce or copy these materials, except for temporary copies created + automatically during authorized use of the Services +- Create derivative works based on these materials +- Distribute, sublicense, or transfer these materials to any third party +- Make, offer to sell, sell, or import any inventions embodied in these + materials +- Reverse engineer, decompile, or disassemble these materials + +The receipt, viewing, or possession of these materials does not convey or +imply any license or right beyond those expressly granted above. + +Anthropic retains all right, title, and interest in these materials, +including all copyrights, patents, and other intellectual property rights. diff --git a/skills/filling-pdf-forms/SKILL.md b/skills/filling-pdf-forms/SKILL.md new file mode 100644 index 0000000..c72342c --- /dev/null +++ b/skills/filling-pdf-forms/SKILL.md @@ -0,0 +1,134 @@ +--- +name: filling-pdf-forms +description: Complete PDF forms by collecting data through conversational interviews and populating form fields. Use when filling forms, completing documents, or when the user mentions PDFs, forms, form completion, or document population. +allowed-tools: Read, Write, Edit, Glob, Bash, Task +version: 1.0.0a2 +license: Apache 2.0 +--- + +# Filling PDF Forms + +Complete PDF forms by collecting required data through conversational interviews and populating form fields. + + +Use when completing PDF forms with user-provided data. Goal: produce `.done.pdf` populated with user information by following this process exactly. + + +## Process Overview + +```plantuml +@startuml SKILL +title Filling PDF Forms - High-Level Workflow +|User| +start +:User provides PDF form to complete; +|filling-pdf-forms skill| +:Step 0: Initialize Chatfield; +:Step 1: Form Extraction; +:Step 2: Build Form Data Model; +:Step 3: Translation Decision; +if (User language is form language?) then (yes) + :Use base Form Data Model; +else (no) + :Translation Setup; +endif +:Step 4: Run Interview Loop; +partition "Chatfield CLI Interview Loop" { + :Initialize: Run CLI without message; + repeat + :CLI outputs question to stdout; + :Present question to user via AskUserQuestion(); + |User| + :User provides response; + |filling-pdf-forms skill| + :Run CLI with user's message; + repeat while (CLI indicates complete?) is (no) + ->yes; +} +:Inspect collected data via CLI --inspect; +:Step 5: Populate PDF; +if (Fillable form?) then (yes) + :Populate fillable fields + (see Populating-Fillable.md); +else (no) + :Populate non-fillable fields + (see Populating-Nonfillable.md); +endif +|User| +:**✓ SUCCESS**; +:Receive completed PDF .done.pdf; +stop +@enduml +``` + +## Workflow + +### Step 0: Initialize Chatfield + +Test: `python -c "import pypdf; import pdf2image; import markitdown; import chatfield"`. + +Install via `pip` if needed; exceptions: +- `markitdown` → `pip install "markitdown[pdf]"` +- `chatfield` → `pip install ./scripts/chatfield-1.0.0a2-py3-none-any.whl` (relative to this .md) + +### Step 1: Form Extraction + +Extract PDF form using `extracting-form-fields` sub-agent: + +```python +Task( + subagent_type="general-purpose", + description="Extract PDF form fields", + prompt=f"Extract form field data from PDF: {pdf_path}\n\nUse the extracting-form-fields skill." +) +``` + +**Task reports**: "fillable" or "non-fillable" (needed for Step 5) + +**Creates** (for `input.pdf`): +- `input.chatfield/input.form.md` - PDF as Markdown +- `input.chatfield/input.form.json` - Field definitions +- `input.chatfield/interview.py` - Template Form Data Model + +### Step 2: Build Form Data Model + +1. Read entirely: `./references/Data-Model-API.md` - Learn Chatfield API +2. Read entirely: `./references/Converting-PDF-To-Chatfield.md` - PDF→Chatfield Form Data Model guidance +3. Edit `.chatfield/interview.py` - Define Form Data Model + +**Result**: The **Form Data Model**, a faithful representation of PDF form using Chatfield API. + +### Step 3: Translation + +Determine if translation is needed. Translation is needed either: +- **Explicit**: User states "I need to fill this Spanish form but I only speak English" +- **Implicit**: User request is in language X, but PDF is in language Y + +Example: "Help me complete form.es.pdf" (English request, Spanish form) + +State to the user whether you will translate. Either: +- Claude: This form uses +- or Claude: This form uses so I will set up translation to + +**To apply translation, see:** ./references/Translating.md + +Translation creates `interview_.py` and **re-defines** the Form Data Model from `interview.py` to the new `interview_.py` instead. Henceforth, use the translated file as the Form Data Model. + +### Step 4: Run Interview Loop via CLI + +**CRITICAL**: See `./references/CLI-Interview-Loop.md` for complete MANDATORY execution rules. + + +### Step 5: Populate PDF + +Parse `--inspect` output and populate the PDF. + +#### If Fillable: + +**See:** ./references/Populating-Fillable.md + +#### If Non-fillable: + +**See:** ./references/Populating-Nonfillable.md + +**Result**: `.done.pdf` diff --git a/skills/filling-pdf-forms/references/AskUserQuestion-Rules.md b/skills/filling-pdf-forms/references/AskUserQuestion-Rules.md new file mode 100644 index 0000000..1a4c369 --- /dev/null +++ b/skills/filling-pdf-forms/references/AskUserQuestion-Rules.md @@ -0,0 +1,181 @@ +# AskUserQuestion using chatfield.cli strategy + +**CRITICAL: Strict adherence required. No deviations permitted.** + +This document defines MANDATORY patterns for using `AskUserQuestion` with `chatfield.cli` interviews. Assumes you already know the AskUserQuestion tool signature. + +--- + +## MANDATORY Pattern for EVERY Question + +**REQUIRED - EXACT structure:** + +```python +AskUserQuestion( + questions=[{ + "question": "", # No paraphrasing + "header": "<12 chars max>", + "multiSelect": , # Based on data model + "options": [ + # POSITION 1: REQUIRED + {"label": "Skip", "description": "Skip (N/A, blank, negative, etc)"}, + # POSITION 2: REQUIRED + {"label": "Delegate", "description": "Ask Claude to look up the needed information using all available resources"}, + # POSITION 3: First option from chatfield.cli (if present) + {"label": "", "description": "..."}, + # POSITION 4: Second option from chatfield.cli (if present) + {"label": "", "description": "..."} + ] + }] +) +# POSITION 5 (implicit): "Other" - auto-added for free text +``` + +--- + +## Determine multiSelect + +**Check `interview.py` Form Data Model (Chatfield builder API):** + +| Data Model | multiSelect | +|------------|-------------| +| `.as_multi()` or `.one_or_more()` | `True` | +| `.as_one()` or `.as_nullable_one()` | `False` | +| Plain `.field()` (no cardinality) | `False` | + +--- + +## Parse chatfield.cli Options + +**If chatfield.cli output contains options, extract and prioritize:** + +**Recognize patterns:** +- `"Status? (Single, Married, Divorced)"` +- `"Choose: A, B, C, D"` +- `"Preference: Red | Blue | Green"` + +Add **first TWO** as positions 3-4 + +**Example:** +``` +chatfield.cli: "Status? (Single, Married, Divorced, Widowed)" +Options: +1. Skip +2. Delegate +3. Single ← First from chatfield.cli +4. Married ← Second from chatfield.cli +"Other": User can type "Divorced" or "Widowed" +``` + +--- + +## Handle Responses + +| Selection | Action | +|-----------|--------| +| Types via "Other" | If starts with `'`: strip prefix and pass verbatim to chatfield.cli. Otherwise: judge if it's a direct answer or instruction to Claude. Direct answer → pass to chatfield.cli; Request for Claude → research/process, then respond to chatfield.cli | +| "Skip" | Context-aware response: Yes/No questions → "No"; Optional/nullable fields → "N/A"; Other fields → "Skip" | +| "Delegate" | Research & provide answer | +| Option 3-4 | Pass selection to CLI | +| Multi-select | Join: "Email, Phone" to chatfield.cli next iteration | + +## Distinguishing Direct Answers from Claude Requests + +**When user types via "Other", judge intent:** + +**Direct answers** (pass to chatfield.cli): +- "Find new customers in new markets" ← answer to "What is your business strategy?" +- "123 Main St, Boston MA" ← answer to "What is your address?" +- "Python and TypeScript" ← answer to "What programming languages?" + +**Requests for Claude** (research first): +- "look up my SSN" ← asking Claude to find something +- "research the population" ← asking Claude to look something up +- "what's today's date" ← asking Claude a question + +**Edge case:** `'` prefix forces verbatim pass-through regardless of content + +--- + +## Delegation Pattern + +**When user selects "Delegate":** +1. Parse question to understand needed info +2. Treat this as if the user directly asked, "Help me find out ..." +2. Use ALL tools available to you, +4. Pass the result to chatfield.cli as if user typed it +5. If not found, ask user + +--- + +## Quick Examples (RULES 1-7) + +**Note:** Skip handling is context-aware per "Handle Responses" table above. + +### RULE 1: Free Text +``` +# chatfield.cli: "What is your name?" +# multiSelect: False +# Options: Skip, Delegate +``` + +### RULE 2: Yes/No +``` +# chatfield.cli: "Are you employed?" +# multiSelect: False +# Options: Skip, Delegate, Yes, No +``` + +### RULE 3: Single-Select Choice +``` +# chatfield.cli: "Status? (Single, Married, Divorced, Widowed)" +# multiSelect: False +# Extract: ["Single", "Married", "Divorced", "Widowed"] +# Options: Skip, Delegate, Single, Married +# Via Other: "Divorced", "Widowed" +``` + +### RULE 4: Multi-Select Choice +``` +# chatfield.cli: "Contact? (Email, Phone, Text, Mail)" +# Data model: .as_multi(...) +# multiSelect: True +# Extract: ["Email", "Phone", "Text", "Mail"] +# Options: Skip, Delegate, Email, Phone +# Via Other: "Text", "Mail" +``` + +### RULE 5: Numeric +``` +# chatfield.cli: "How many dependents?" +# multiSelect: False +# Options: Skip, Delegate (optionally: "0", "1-2") +# Via Other: Exact number +``` + +### RULE 6: Complex/Address +``` +# chatfield.cli: "Mailing address?" +# multiSelect: False +# Options: Skip, Delegate +# Via Other: Full address +``` + +### RULE 7: Date +``` +# chatfield.cli: "Date of birth?" +# multiSelect: False +# Options: Skip, Delegate (optionally: "Today", "Tomorrow") +# Via Other: Specific date +``` + +--- + +## MANDATORY Checklist + +**EVERY question MUST:** +- [ ] Be based on chatfield.cli's stdout message +- [ ] Include "Skip" as option 1 +- [ ] Include "Delegate" as option 2 +- [ ] Check Form Data Model for multiSelect +- [ ] Add first TWO chatfield.cli options as 3-4 (if present) \ No newline at end of file diff --git a/skills/filling-pdf-forms/references/CLI-Interview-Loop.md b/skills/filling-pdf-forms/references/CLI-Interview-Loop.md new file mode 100644 index 0000000..e1dfe03 --- /dev/null +++ b/skills/filling-pdf-forms/references/CLI-Interview-Loop.md @@ -0,0 +1,74 @@ +# CLI Interview Loop + +**CRITICAL: Strict adherence required. No deviations permitted.** + +Run `chatfield.cli` iteratively, presenting its output messages via AskUserQuestion(), passing responses back, repeating until complete. + +**Files:** +- State: `.chatfield/interview.db` +- Interview: `.chatfield/interview.py` (or `interview_.py` if translated) + +## Workflow Overview + +```plantuml +@startuml CLI-Interview-Loop +title CLI Interview Loop +start +:Initialize chatfield.cli (no message); +:chatfield.cli outputs first question; +repeat + :Understand the chatfield.cli message; + :Consider the Form Data Model for multiSelect; + :Build AskUserQuestion; + :Present to user via AskUserQuestion(); + :Call chatfield.cli with the result as a message; + :chatfield.cli outputs next question/response; +repeat while (Complete?) is (no) +->yes; +:Run chatfield.cli --inspect; +:Parse collected data; +stop +@enduml +``` + +## CLI Command Reference + +```bash +# Initialize (NO user message) +python -m chatfield.cli --state= --interview= + +# Continue (WITH message) +python -m chatfield.cli --state= --interview= "user response" + +# Inspect (when complete, or any time to troubleshoot) +python -m chatfield.cli --state= --interview= --inspect +``` + +In all cases, chatfield.cli will print to its stdout a message for the user. + +## Interview Loop Process + +**CRITICAL**: When building AskUserQuestion from chatfield.cli's message, you MUST strictly follow ./AskUserQuestion-Rules.md + +1. Initialize: `python -m chatfield.cli --state= --interview=` (NO message) +2. Read chatfield.cli's stdout message +3. Recall or look up Form Data Model for multiSelect (`.as_multi()`, `.one_or_more()` → True) +4. Build AskUserQuestion per mandatory rules: ./AskUserQuestion-Rules.md +5. Present AskUserQuestion to user +6. Handle response: + - "Other" text → pass to chatfield.cli + - "Skip" → Context-aware response: Yes/No questions → "No"; Optional/nullable fields → "N/A"; Other fields → "Skip" + - "Delegate" → research answer, pass to chatfield.cli + - Options 3-4 → pass selection to chatfield.cli + - Multi-select → join with commas, pass to chatfield.cli +7. Call: `python -m chatfield.cli --state= --interview= "user response"` +8. Repeat steps 2-7 until completion signal +9. Run: `python -m chatfield.cli --state= --interview= --inspect` + +## Completion Signals + +Watch for: +- "Thank you! I have all the information I need." +- "complete" / "done" + +When Chatfield mentions the conversation is complete, stop the loop. The CLI Interview loop is done. \ No newline at end of file diff --git a/skills/filling-pdf-forms/references/Converting-PDF-To-Chatfield.md b/skills/filling-pdf-forms/references/Converting-PDF-To-Chatfield.md new file mode 100644 index 0000000..df2d622 --- /dev/null +++ b/skills/filling-pdf-forms/references/Converting-PDF-To-Chatfield.md @@ -0,0 +1,332 @@ +# Converting PDF Forms to Chatfield Interviews + + +This guide covers how to build a Chatfield interview definition from PDF form data. This is the core transformation step that converts a static PDF form into a conversational interview. + + + +**Read complete API reference**: See ./Data-Model-API.md for all builder methods, transformations, and validation rules. + + +## Process Overview + +```plantuml +@startuml Converting-PDF-To-Chatfield +title Converting PDF Forms to Chatfield Interviews +start +:Prerequisites: Form extraction complete; +partition "Read Input Files" { + :Read .form.md; + :Read .form.json; +} +:Build Interview Definition; +repeat + :Validate Form Data Model + (see validation checklist); + if (All checks pass?) then (yes) + else (no) + :Fix issues identified in validation; + endif +repeat while (All checks pass?) is (no) +->yes; +:**✓ FORM DATA MODEL COMPLETE**; +:interview.py ready for next step; +stop +@enduml +``` + +## The Form Data Model + + +The **Form Data Model** is the `interview.py` file in the `.chatfield/` working directory. This file contains the chatfield builder definition that faithfully represents the PDF form. + + +## Critical Principle: Faithfulness to Original PDF + + +**The Form Data Model must be as accurate and faithful as possible to the source PDF.** + +**Why?** Downstream code will NOT see the PDF anymore. The interview must create the "illusion" that the AI agent has full access to the form, speaking to the user, writing information - all from the Form Data Model alone. + +This means every field, every instruction, every validation rule from the PDF must be captured in the interview definition. + + +## Language Matching Rule + +**CRITICAL: Only pass English-language strings to the chatfield builder API for English-language forms.** + +The chatfield object strings should virtually always match the PDF's primary language: +- `.type()` - Use short identifier (e.g., "DHFS_FoodBusinessLicense"), not full official name. **HARD LIMIT: 64 characters maximum** +- `.desc()` - Use form's language +- `.trait()` - Use form's language for Background content +- `.hint()` - Use form's language + +**Translation happens LATER** (see ./Translating.md), not during initial definition. + +## Key Rules + +These fundamental rules apply to all Form Data Models: + +1. **Faithfulness to PDF**: The interview definition must accurately represent the source PDF form +2. **Short type identifiers**: Top-level `.type()` should be a short "class name" identifier (e.g., "W9_TIN", "DHFS_FoodBusinessLicense"), not the full official form name. **HARD LIMIT: 64 characters maximum** +3. **Direct mapping default**: Use PDF field_ids directly from `.form.json` unless using fan-out patterns +4. **Fan-out patterns**: Use `.as_*()` casts to populate multiple PDF fields from single collected value +5. **Exact field_ids**: Keep field IDs from `.form.json` unchanged (use as cast names or direct field names) +6. **Extract knowledge**: ALL form instructions go into Alice traits/hints +7. **Format flexibility**: Never specify format in `.desc()` - Alice accepts variations +8. **Validation vs transformation**: `.must()` for content constraints (use SPARINGLY), `.as_*()` for formatting (use LIBERALLY). Alice NEVER mentions format requirements to Bob +9. **Language matching**: All strings (`.desc()`, `.trait()`, `.hint()`) must match the PDF's language + +## Reading Input Files + +Your inputs from form-extract: +- **`.chatfield/.form.md`** - PDF content as Markdown (use this for form knowledge) +- **`.chatfield/.form.json`** - Field IDs, types, and metadata + +## Extracting Form Knowledge + +From `.form.md`, extract ONLY actionable knowledge: +- Form purpose (1-2 sentences) +- Key term definitions +- Field completion instructions +- Valid options/codes +- Decision logic ("If X then Y") + +**Do NOT extract:** +- Decorative text +- Repeated boilerplate +- Page numbers, footers + +Place extracted knowledge in interview: +- **Form-level** → Alice traits: `.trait("Background: [context]...")` +- **Field-level** → Field hints: `.hint("Background: [guidance]")` + +## Builder API Patterns + +### Direct Mapping (Default) + +One PDF field_id → one question + +```python +.field("topmostSubform[0].Page1[0].f1_01[0]") + .desc("What is your full legal name?") # English .desc() for English form + .hint("Background: Should match official records") +``` + +### Fan-out Pattern + +Collect once, populate multiple PDF fields via `.as_*()` casts + +```python +.field("age") + .desc("What is your age in years?") + .as_int("age_years", "Age as integer") + .as_bool("over_18", "True if 18 or older") + .as_str("age_display", "Age formatted for display") +``` + +**CRITICAL**: For fan-out, cast names MUST be exact PDF field_ids from `.form.json` + +#### Re-representation Sub-pattern + +When PDF has multiple fields for the same value in different formats (numeric vs words, date vs formatted date, etc.), collect ONCE and use casts: + +```python +.field("amount") + .desc("What is the payment amount?") + .as_int("amount_numeric", "Amount as number") + .as_str("amount_in_words", "Amount spelled out in words (e.g., 'One hundred')") + +.field("event_date") + .desc("When did the event occur?") + .as_str("date_iso", "Date in ISO format (YYYY-MM-DD)") + .as_str("date_display", "Date formatted as 'January 15, 2025'") +``` + +**Key principle**: Eliminate duplicate questions about the same underlying information. + +### Discriminate + Split Pattern + +Mutually-exclusive fields + +```python +.field("tin") + .desc("Is your taxpayer ID an EIN or SSN, and what is the number?") + .must("be exactly 9 digits") + .must("indicate SSN or EIN type") + .as_str("ssn_part1", "First 3 of SSN, or empty if N/A") + .as_str("ssn_part2", "Middle 2 of SSN, or empty if N/A") + .as_str("ssn_part3", "Last 4 of SSN, or empty if N/A") + .as_str("ein_full", "Full 9-digit EIN, or empty if N/A") +``` + +### Expand Pattern + +Multiple checkboxes from single field + +```python +.field("preferences") + .desc("What are your communication preferences?") + .as_bool("email_ok", "True if wants email") + .as_bool("phone_ok", "True if wants phone calls") + .as_bool("mail_ok", "True if wants postal mail") +``` + +## `.must()` vs `.as_*()` Usage + +**`.must()`** - CONTENT constraints (use SPARINGLY): +- Only when field MUST contain specific information +- Creates hard blocking constraint +- Example: `.must("match tax return exactly")` + +**`.as_*()`** - TYPE/FORMAT transformations (use LIBERALLY): +- For any type casting, formatting, derived values +- Alice accepts variations, computes transformation +- Example: `.as_int()`, `.as_bool()`, `.as_str("name", "desc")` + +**Rule of thumb**: Expect MORE `.as_*()` calls than `.must()` calls. + +## Field Types + +- **Text** → `.field("id").desc("question")` +- **Checkbox** → `.field("id").desc("question").as_bool()` +- **Radio/choice (required)** → `.field("id").desc("question").as_one("opt1", "opt2")` +- **Radio/choice (optional)** → `.field("id").desc("question").as_nullable_one("opt1", "opt2")` + +## Optional Fields + +```python +.field("middle_name") + .desc("Middle name") + .hint("Background: Optional per form instructions") +``` + +## Hint Conventions + +All hints must have a prefix: + +- **"Background:"** - Internal notes for Alice only + - Alice uses these for formatting, conversions, context without mentioning to Bob + - Example: `.hint("Background: Convert to Buddhist calendar by adding 543 years")` +- **"Tooltip:"** - May be shared with Bob if helpful + - Example: `.hint("Tooltip: Your employer provides this number")` + +**See ./Data-Model-API.md** for complete list of transformations (`.as_int()`, `.as_bool()`, etc.) and cardinality options (`.as_one()`, `.as_multi()`, etc.). + +## When to Use `.conclude()` + +Only when derived field depends on multiple previous fields OR complex logic that can't be expressed in a single field's casts. + +## Additional Guidance from PDF Forms + +**Extract Knowledge Wisely:** +- Extract actionable knowledge ONLY from PDF +- Form purpose (1-2 sentences max) +- Key term definitions +- Field completion instructions +- Valid options/codes +- Decision logic ("If X then Y") +- **Do NOT extract**: Decorative text, repeated boilerplate, page numbers, footers + +**Alice Traits for Format Flexibility:** +```python +.alice() + .type("Form Assistant") + .trait("Collects information content naturally, handling all formatting invisibly") + .trait("Accepts format variations (SSN with/without hyphens)") + .trait("Background: [extracted form knowledge goes here]") +``` + +**Default to Direct Mapping:** +PDF field_ids are internal - users only see `.desc()`. Use field IDs directly unless using fan-out patterns. + +**Format Flexibility:** +Never specify format in `.desc()` - Alice accepts variations. Use `.as_*()` for formatting requirements. + +## Complete Example + +```python +from chatfield import chatfield + +interview = (chatfield() + .type("W9_TIN") + .desc("Form to provide TIN to entities paying income") + + .alice() + .type("Tax Form Assistant") + .trait("Collects information content naturally, handling all formatting invisibly") + .trait("Accepts format variations (SSN with/without hyphens)") + .trait("Background: W-9 used to provide TIN to entities paying income") + .trait("Background: EIN for business entities, SSN for individuals") + + .bob() + .type("Taxpayer completing W-9 form") + .trait("Speaks naturally and freely") + + .field("name") + .desc("What is your full legal name as shown on your tax return?") + .hint("Background: Must match IRS records exactly") + + .field("business_name") + .desc("Business name or disregarded entity name, if different from above") + .hint("Background: Optional - only if applicable") + + .field("tin") + .desc("What is your taxpayer identification number (SSN or EIN)?") + .must("be exactly 9 digits") + .must("indicate whether SSN or EIN") + .as_str("ssn_part1", "First 3 digits of SSN, or empty if using EIN") + .as_str("ssn_part2", "Middle 2 digits of SSN, or empty if using EIN") + .as_str("ssn_part3", "Last 4 digits of SSN, or empty if using EIN") + .as_str("ein_part1", "First 2 digits of EIN, or empty if using SSN") + .as_str("ein_part2", "Last 7 digits of EIN, or empty if using SSN") + + .field("address") + .desc("What is your address (number, street, apt/suite)?") + + .field("city_state_zip") + .desc("What is your city, state, and ZIP code?") + .as_str("city", "City name") + .as_str("state", "State abbreviation (2 letters)") + .as_str("zip", "ZIP code") + + .build() +) +``` + +## Validation Checklist + +Before proceeding, validate the interview definition: + + +``` +Interview Validation Checklist: +- [ ] All field_ids from .form.json are mapped +- [ ] No field_ids duplicated or missing +- [ ] Re-representations (amount/amount_in_words, date/date_formatted, etc.) use single field with casts, not duplicate questions +- [ ] .desc() describes WHAT information is needed (content), never HOW it should be formatted +- [ ] .hint() provides context about content (e.g., "Optional", "Must match passport"), never formatting instructions +- [ ] All formatting requirements (dates, codes, number formats, etc.) use .as_*() transformations exclusively +- [ ] Fan-out patterns use .as_*() with PDF field_ids as cast names +- [ ] Split patterns use .as_*() with "or empty/0 if N/A" descriptions +- [ ] Discriminate + split uses .as_*() for mutually-exclusive fields +- [ ] Expand pattern uses .as_*() casts on single field +- [ ] .conclude() used only when necessary (multi-field dependencies) +- [ ] Alice traits include extracted form knowledge +- [ ] Field hints provide context from PDF instructions +- [ ] Optional fields explicitly marked with hint("Background: Optional...") +- [ ] .must() used sparingly (only true content requirements) +- [ ] Field .desc() questions are natural and user-friendly (no technical field_ids) +- [ ] ALL STRINGS match the PDF's primary language +``` + + +If any items fail: +1. Review the specific issue +2. Fix the interview definition +3. Re-run validation checklist +4. Proceed only when all items pass + +## The Result: Form Data Model + +When validation passes, you have successfully created the **Form Data Model** in `.chatfield/interview.py`. \ No newline at end of file diff --git a/skills/filling-pdf-forms/references/Data-Model-API.md b/skills/filling-pdf-forms/references/Data-Model-API.md new file mode 100644 index 0000000..4bda16a --- /dev/null +++ b/skills/filling-pdf-forms/references/Data-Model-API.md @@ -0,0 +1,216 @@ +# Conversational Form API Reference + +**Library:** `chatfield` Python package + +API reference for building conversational form interviews. Powered by the Chatfield library. + +## Contents +- Quick Start +- Builder API + - Interview Configuration + - Roles + - Fields + - Validation + - Special Field Types + - Transformations + - Cardinality +- Field Access +- Optional Fields + +--- + +## Quick Start + +```python +from chatfield import chatfield, Interviewer + +# Define +interview = (chatfield() + .field("name") + .desc("What is your full name?") + .must("include first and last") + .field("age") + .desc("Your age?") + .as_int() + .must("be between 18 and 120") + .build()) + +# Run +interviewer = Interviewer(interview) +user_input = None +while not interview._done: + message = interviewer.go(user_input) + print(f"Assistant: {message}") + if not interview._done: + user_input = input("You: ").strip() + +# Access +print(interview.name, interview.age.as_int) +``` + +--- + +## Builder API + +### Interview Configuration + +```python +interview = (chatfield() + .type("Job Application") # Interview type + .desc("Collect applicant info") # Description + .build()) +``` + +### Roles + +```python +.alice() # Configure AI assistant + .type("Tax Assistant") + .trait("Professional and accurate") + .trait("Never provides tax advice") + +.bob() # Configure user + .type("Taxpayer") + .trait("Speaks colloquially") +``` + +### Fields + +```python +.field("email") # Define field (becomes interview.email) + .desc("What is your email?") # User-facing question +``` + +**All fields mandatory to populate** (must be non-`None` for `._done`). Content can be empty string `""`. +Exception: `.as_one()`, `.as_multi()`, and fields with strict validation require non-empty values. + +### Validation + +```python +.field("email") + .must("be valid email format") # Requirement (AND logic) + .must("not be disposable") + .reject("profanity") # Block pattern + .hint("Background: Company email preferred") # Advisory (not enforced) +``` + +### Hints + +Hints provide context and guidance to Alice. **All hints must start with "Background:" or "Tooltip:"** + +```python +# Background hints: Internal notes for Alice only (not mentioned to Bob) +.hint("Background: Convert Gregorian to Buddhist calendar (+543 years)") +.hint("Background: Optional per form instructions") + +# Tooltip hints: May be shared with Bob if helpful +.hint("Tooltip: Your employer should provide this number") +.hint("Tooltip: Ask your supervisor if unsure") +``` + +**Background hints** are for Alice's internal use - she handles formatting/conversions transparently without mentioning them to Bob. +**Tooltip hints** may be shared with Bob to help clarify what information is needed. + +### Special Field Types + +```python +.field("sentiment_score") + .confidential() # Track silently, never ask Bob + +.field("summary") + .conclude() # Compute after regular fields (auto-confidential) +``` + +### Transformations + +LLM computes during collection. Access via `interview.field.as_*` + +```python +.field("age").as_int() # → interview.age.as_int = 25 +.field("price").as_float() # → interview.price.as_float = 99.99 +.field("citizen").as_bool() # → interview.citizen.as_bool = True +.field("hobbies").as_list() # → interview.hobbies.as_list = ["reading", "coding"] +.field("config").as_json() # → interview.config.as_json = {"theme": "dark"} +.field("progress").as_percent() # → interview.progress.as_percent = 0.75 +.field("greeting").as_lang("fr") # → interview.greeting.as_lang_fr = "Bonjour" + +# Optional descriptions guide edge cases +.field("has_partners") + .as_bool("true if you have partners; false if not or N/A") + +.field("quantity") + .as_int("parse as integer, ignore units") + +# Named string casts for formatting +.field("ssn") + .must("be exactly 9 digits") + .as_str("formatted", "Format as ###-##-####") +# Access: interview.ssn.as_str_formatted → "123-45-6789" +``` + +**Validation vs. Casts:** +- **Validation** (`.must()`): Check content ("9 digits", "valid email") +- **Casts** (`.as_*()`): Provide format (hyphens, capitalization) + +### Choice Cardinality + +Select from predefined options: + +```python +.field("tax_class") + .as_one("Individual", "C Corp", "S Corp") # Exactly one choice required + +.field("dietary") + .as_nullable_one("Vegetarian", "Vegan") # Zero or one + +.field("languages") + .as_multi("Python", "JavaScript", "Go") # One or more choices required + +.field("interests") + .as_nullable_multi("ML", "Web Dev", "DevOps") # Zero or more +``` + +### Build + +```python +.build() # Return Interview instance +``` + +--- + +## Field Access + +**Dot notation** (regular fields): +```python +interview.name +interview.age.as_int +``` + +**Bracket notation** (special characters): +```python +interview["topmostSubform[0].Page1[0].f1_01[0]"] # PDF form fields +interview["user.name"] # Dots +interview["full name"] # Spaces +interview["class"] # Reserved words +``` + +--- + +## Optional Fields + +Fields known to be optional (from PDF tooltip, nearby context, or instructions): + +```python +.alice() + .trait("Records optional fields as empty string when user says blank/none/skip") + +.field("middle_name") + .desc("Middle name") + .hint("Background: Optional per form instructions") + +.field("extension") + .desc("Phone extension") + .hint("Background: Leave blank if none") +``` + +For optional **choices**, use `.as_nullable_one()` or `.as_nullable_multi()` (see examples above). diff --git a/skills/filling-pdf-forms/references/Populating-Fillable.md b/skills/filling-pdf-forms/references/Populating-Fillable.md new file mode 100644 index 0000000..9ddfea6 --- /dev/null +++ b/skills/filling-pdf-forms/references/Populating-Fillable.md @@ -0,0 +1,100 @@ +# Populating Fillable PDF Forms + + +After collecting data via Chatfield interview, populate fillable PDF forms with the results. + + +## Process Overview + +```plantuml +@startuml Populating-Fillable +title Populating Fillable PDF Forms +start +:Parse Chatfield output; +:Read .form.json for metadata; +:Create .values.json; +repeat + :Validate .values.json + (see validation checklist); + if (All checks pass?) then (yes) + else (no) + :Fix .values.json; + endif +repeat while (All checks pass?) is (no) +->yes; +:Execute fill_fillable_fields.py; +:**✓ PDF POPULATION COMPLETE**; +stop +@enduml +``` + +## Process + +### 1. Parse Chatfield Output + +Run Chatfield with `--inspect` for a final summary of all collected data: +```bash +python -m chatfield.cli --state='.chatfield/interview.db' --interview='.chatfield/interview.py' --inspect +``` + +Extract `field_id` and the proper value for each field. + +### 2. Create `.values.json` + +Create `.values.json` in the `.chatfield/` directory with the collected field values: + +```json +[ + {"field_id": "name", "page": 1, "value": "John Doe"}, + {"field_id": "age_years", "page": 1, "value": 25}, + {"field_id": "age_display", "page": 1, "value": "25"}, + {"field_id": "checkbox_over_18", "page": 1, "value": "/1"} +] +``` + +**Value selection priority:** +- **CRITICAL**: If a language cast exists for a field (e.g., `.as_lang_es`, `.as_lang_fr`), **always prefer it** over the raw value +- This ensures forms are populated in the form's language, not the conversation language +- The language cast name matches the form's language code (e.g., `as_lang_es` for Spanish forms) +- Only use the raw value if no language cast exists + +**Boolean conversion for checkboxes:** +- Read `.form.json` for `checked_value` and `unchecked_value` +- Typically: `"/1"` or `"/On"` for checked, `"/Off"` for unchecked +- Convert Python `True`/`False` → PDF checkbox values + +### 3. Validate `.values.json` + +**Before running the population script**, validate the `.values.json` file against the validation checklist below: +- Verify all field_ids from `.form.json` are present +- Check checkbox values match `checked_value`/`unchecked_value` from `.form.json` +- Ensure numeric fields use numbers, not strings +- Confirm language cast values are used when available + +If validation fails, fix the `.values.json` file and re-validate until all checks pass. + +### 4. Populate PDF + +Once validation passes, run the population script (note, the `scripts` directory is relative to the base directory for this skill): + +```bash +python scripts/fill_fillable_fields.py .pdf .chatfield/.values.json .done.pdf + +## Validation Checklist + + +**Missing fields:** +- Check that all field_ids from `.form.json` are in `.values.json` +- Verify field_id spelling matches exactly + +**Wrong checkbox values:** +- Check `checked_value`/`unchecked_value` in `.form.json` +- Common values: `/1`, `/On`, `/Yes` for checked; `/Off`, `/No` for unchecked + +**Type errors:** +- Ensure numeric fields use numbers, not strings: `25` not `"25"` +- Ensure boolean checkboxes use proper values from `.form.json` + +**Language translation (for translated forms):** +- Ensure language cast value is used when it exists (e.g., `as_lang_es` for Spanish forms) + diff --git a/skills/filling-pdf-forms/references/Populating-Nonfillable.md b/skills/filling-pdf-forms/references/Populating-Nonfillable.md new file mode 100644 index 0000000..2c76085 --- /dev/null +++ b/skills/filling-pdf-forms/references/Populating-Nonfillable.md @@ -0,0 +1,121 @@ +# Populating Non-fillable PDF Forms + + +After collecting data via Chatfield interview, populate the non-fillable PDF with text annotations. + + +## Process Overview + +```plantuml +@startuml Populating-Nonfillable +title Populating Non-fillable PDF Forms +start +:Parse Chatfield output; +:Create .values.json with field values; +:Add annotations to PDF; +:**✓ PDF POPULATION COMPLETE**; +stop +@enduml +``` + +## Process + +### 1. Parse Chatfield Output + +Run Chatfield with `--inspect` for a final summary of all collected data: +```bash +python -m chatfield.cli --state='.chatfield/interview.db' --interview='.chatfield/interview.py' --inspect +``` + +Extract `field_id` and value for each field from the interview results. + +### 2. Create `.values.json` + +Create `.chatfield/.values.json` with the collected field values in the format expected by the annotation script: + +```json +{ + "fields": [ + { + "field_id": "full_name", + "page": 1, + "value": "John Doe" + }, + { + "field_id": "is_over_18", + "page": 2, + "value": "X" + } + ] +} +``` + +**Value selection priority:** +- **CRITICAL**: If a language cast exists for a field (e.g., `.as_lang_es`, `.as_lang_fr`), **always prefer it** over the raw value +- This ensures forms are populated in the form's language, not the conversation language +- The language cast name matches the form's language code (e.g., `as_lang_es` for Spanish forms) +- Only use the raw value if no language cast exists + +**Boolean conversion for checkboxes:** +- Read `.form.json` for `checked_value` and `unchecked_value` +- Typically: `"X"` or `"✓"` for checked, `""` (empty string) for unchecked +- Convert Python `True`/`False` → checkbox display values + +### 3. Add Annotations to PDF + +Run the annotation script to create the filled PDF: + +```bash +python scripts/fill_nonfillable_fields.py .pdf .chatfield/.values.json .done.pdf +``` + +This script: +- Reads the `.values.json` file with field values +- Reads the `.form.json` file (from extraction) with bounding box information +- Adds text annotations at the specified bounding boxes +- Creates the output PDF with all annotations + +**Verification:** +- Verify `.done.pdf` exists +- Spot-check a few fields to ensure values are correctly placed + +**Result**: `.done.pdf` + +## Validation Checklist + + +``` +Non-fillable Population Validation: +- [ ] All field values extracted from CLI output +- [ ] Language casts used when available (not raw values) +- [ ] Boolean values converted to checkbox display values +- [ ] .values.json created with correct format +- [ ] fill_nonfillable_fields.py executed successfully +- [ ] Output PDF exists at expected location +- [ ] Spot-checked fields contain correct values +- [ ] Text is visible and properly positioned +``` + + +## Troubleshooting + +**Text not visible:** +- Check font color in .form.json (should be dark, e.g., "000000" for black) +- Verify bounding boxes are correct size +- Ensure font size is appropriate for the bounding box + +**Text cut off:** +- Bounding boxes may be too small +- Review validation images from extraction phase +- Consider adjusting bounding boxes and re-running extraction validation + +**Wrong language:** +- Verify you're using language cast values (e.g., `as_lang_es`) not raw values +- Check that language casts were properly requested in the Form Data Model + +--- + +**See Also:** +- ./Populating-Fillable.md - Population workflow for fillable PDFs +- ../extracting-form-fields/references/Nonfillable-Forms.md - How bounding boxes were created +- ./Converting-PDF-To-Chatfield.md - How the Form Data Model was built diff --git a/skills/filling-pdf-forms/references/Translating.md b/skills/filling-pdf-forms/references/Translating.md new file mode 100644 index 0000000..eb4a12b --- /dev/null +++ b/skills/filling-pdf-forms/references/Translating.md @@ -0,0 +1,218 @@ +# Translating Forms for Users + + +Use this guide when the PDF form is in a language different from the user's language. This enables cross-language form completion where the user speaks one language and the form is in another. + + +## Process Overview + +```plantuml +@startuml Translating +title Translating Forms for Users +start +:Prerequisites: Form Data Model created\n(form language already determined); +partition "1. Copy Form Data Model" { + :Create language-specific .py file; +} +partition "2. Edit Language-Specific Version" { + :Edit interview_.py; + partition "3. Alice Translation Traits" { + :Add translation traits to Alice; + } + partition "4. Bob Language Traits" { + :Add language trait to Bob; + } + partition "5. Field Language Casts" { + :Add .as_lang("") to all text fields; + } +} +repeat + :Validate translation setup + (see validation checklist); + if (All checks pass?) then (yes) + else (no) + :Fix issues; + endif +repeat while (All checks pass?) is (no) +->yes; +:**✓ TRANSLATION COMPLETE**; +:Re-define Form Data Model as interview_.py; +stop +@enduml +``` + +## Critical Principle + + +The **Form Data Model** (`interview.py`) was already created with the form's language. + +**DO NOT recreate it.** Instead, ADAPT it for translation. + +The form definition stays in the form's language. Only Alice's behavior and Bob's profile are modified to enable translation. + + +## Process + +### 1. Copy Form Data Model + +Create a language-specific .py file. Use ISO 639-1 language codes: `en`, `es`, `fr`, `de`, `zh`, `ja`, etc. + +```bash +# If user speaks Spanish +cp input.chatfield/interview.py input.chatfield/interview_es.py +``` + +### 2. Edit Language-Specific Version + +Edit `interview_.py` to add translation traits. + +**What to change:** +- ✅ Alice traits - Add translation instructions +- ✅ Bob traits - Add language preference +- ✅ Text fields - Add `.as_lang("")` for translation (e.g., "es" for Spanish) + +**What NOT to change:** +- ❌ Form `.type()` or `.desc()` - Keep form's language +- ❌ Field definitions - Keep all field IDs unchanged +- ❌ Field `.desc()` - Keep form's language +- ❌ Background hints - Keep form's language +- ❌ Any field IDs or cast names + +### 3. Alice Translation Traits + +Add these traits to Alice: + +```python +.alice() + # Keep existing .type() + .trait("Conducts this conversation in [USER_LANGUAGE]") + .trait("Translates [USER_LANGUAGE] responses into [FORM_LANGUAGE] for the form") + .trait("Explains [FORM_LANGUAGE] terms in [USER_LANGUAGE]") + # Keep all existing .trait() calls +``` + +### 4. Bob Language Traits + +Add these traits to Bob: + +```python +.bob() + # Keep existing .type() + .trait("Speaks [USER_LANGUAGE] only") + # Keep all existing .trait() calls +``` + +### 5. Field Language Casts + +Add `.as_lang("")` to **all text fields** to ensure values are translated to the form's language using ISO 639-1 language codes (es, fr, th, de, etc.): + +```python +.field("field_name") + .desc("...") + .as_lang("es") # For Spanish form, use "fr" for French, "th" for Thai, etc. + # Keep all existing casts +``` + +## Complete Example + +**Original Form Data Model** (`interview.py`): + +```python +from chatfield import chatfield + +interview = (chatfield() + .type("Solicitud de Visa") + .desc("Formulario de solicitud de visa de turista") + + .alice() + .type("Asistente de Formularios") + .trait("Usa lenguaje claro y natural") + .trait("Acepta variaciones de formato") + + .bob() + .type("Solicitante de visa") + .trait("Habla de forma natural y libre") + + .field("nombre_completo") + .desc("¿Cuál es su nombre completo?") + .hint("Background: Debe coincidir con el pasaporte") + + .field("fecha_nacimiento") + .desc("¿Cuál es su fecha de nacimiento?") + .as_str("dia", "Día (DD)") + .as_str("mes", "Mes (MM)") + .as_str("anio", "Año (YYYY)") + + .build() +) +``` + +**Translated Version** (`interview_en.py` for English-speaking user): + +```python +from chatfield import chatfield + +interview = (chatfield() + .type("Solicitud de Visa") # Unchanged - form's language + .desc("Formulario de solicitud de visa de turista") # Unchanged + + .alice() + .type("Asistente de Formularios") # Unchanged + .trait("Conducts this conversation in English") # ADDED + .trait("Translates English responses into Spanish for the form") # ADDED + .trait("Explains Spanish terms in English") # ADDED + .trait("Usa lenguaje claro y natural") # Keep existing + .trait("Acepta variaciones de formato") # Keep existing + + .bob() + .type("Solicitante de visa") # Unchanged + .trait("Speaks English only") # ADDED + .trait("Habla de forma natural y libre") # Keep existing + + .field("nombre_completo") # Unchanged + .desc("¿Cuál es su nombre completo?") # Unchanged - form's language + .hint("Background: Debe coincidir con el pasaporte") # Unchanged + .as_lang("es") # ADDED - translate to Spanish + + .field("fecha_nacimiento") # Unchanged + .desc("¿Cuál es su fecha de nacimiento?") # Unchanged + .as_str("dia", "Día (DD)") # Unchanged + .as_str("mes", "Mes (MM)") # Unchanged + .as_str("anio", "Año (YYYY)") # Unchanged + + .build() +) +``` + +## Validation Checklist + +Before proceeding, verify ALL items: + + +``` +Translation Validation Checklist: +- [ ] Created interview_.py (copied from interview.py) +- [ ] No changes to form .type() or .desc() +- [ ] No changes to field definitions (field IDs) +- [ ] No changes to field .desc() (keep form's language) +- [ ] No changes to .as_*() cast names or descriptions +- [ ] No changes to Background hints (keep form's language) +- [ ] Added Alice trait: "Conducts this conversation in [USER_LANGUAGE]" +- [ ] Added Alice trait: "Translates [USER_LANGUAGE] responses into [FORM_LANGUAGE]" +- [ ] Added Alice trait: "Explains [FORM_LANGUAGE] terms in [USER_LANGUAGE]" +- [ ] Added Bob trait: "Speaks [USER_LANGUAGE] only" +- [ ] Added .as_lang("") to all text fields (e.g., "es" for Spanish) +``` + + +If any items fail: +1. Review the specific issue +2. Fix the interview definition +3. Re-run validation checklist +4. Proceed only when all items pass + +## Re-define Form Data Model + +**CRITICAL**: When translation setup is complete, the **Form Data Model** is now the language-specific version (`interview_.py`), NOT the base `interview.py`. + +Use this file for all subsequent steps (CLI execution, etc.). \ No newline at end of file diff --git a/skills/filling-pdf-forms/scripts/chatfield-1.0.0a2-py3-none-any.whl b/skills/filling-pdf-forms/scripts/chatfield-1.0.0a2-py3-none-any.whl new file mode 100644 index 0000000000000000000000000000000000000000..9c069fbd97345bddf1ff8a7e71b8c48a13cc4b6b GIT binary patch literal 38958 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 S-!>#?FTS%iYBBovum1u6Df?Og literal 0 HcmV?d00001 diff --git a/skills/filling-pdf-forms/scripts/chatfield_interview_template.py b/skills/filling-pdf-forms/scripts/chatfield_interview_template.py new file mode 100644 index 0000000..9c38908 --- /dev/null +++ b/skills/filling-pdf-forms/scripts/chatfield_interview_template.py @@ -0,0 +1,28 @@ +# DO NOT add a docstring + +from chatfield import chatfield + +# The chatfield.cli module will import this `interview` object. +# **CRITICAL** - Replace the commented examples below with the real data definition. +interview = (chatfield() + # .type(
) + # .desc() + + # Define Alice's type plus at least one trait. + # .alice() + # .type() + # .trait() + # # Optional additional .trait() calls + + # Define Bob's type plus at least one trait. + # .bob() + # .type() + # .trait() + # # Optional additional .trait() calls + + # Define one or more fields. + # .field() + # .desc() + + .build() +) diff --git a/skills/filling-pdf-forms/scripts/extract_form_field_info.py b/skills/filling-pdf-forms/scripts/extract_form_field_info.py new file mode 100644 index 0000000..af71cd0 --- /dev/null +++ b/skills/filling-pdf-forms/scripts/extract_form_field_info.py @@ -0,0 +1,158 @@ +import json +import sys + +from pypdf import PdfReader + + +# Extracts data for the fillable form fields in a PDF and outputs JSON that +# Claude uses to fill the fields. See forms.md. + + +# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods. +def get_full_annotation_field_id(annotation): + components = [] + while annotation: + field_name = annotation.get('/T') + if field_name: + components.append(field_name) + annotation = annotation.get('/Parent') + return ".".join(reversed(components)) if components else None + + +def make_field_dict(field, field_id): + field_dict = {"field_id": field_id} + ft = field.get('/FT') + if ft == "/Tx": + field_dict["type"] = "text" + elif ft == "/Btn": + field_dict["type"] = "checkbox" # radio groups handled separately + states = field.get("/_States_", []) + if len(states) == 2: + # "/Off" seems to always be the unchecked value, as suggested by + # https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448 + # It can be either first or second in the "/_States_" list. + if "/Off" in states: + field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1] + field_dict["unchecked_value"] = "/Off" + else: + 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.") + field_dict["checked_value"] = states[0] + field_dict["unchecked_value"] = states[1] + elif ft == "/Ch": + field_dict["type"] = "choice" + states = field.get("/_States_", []) + field_dict["choice_options"] = [{ + "value": state[0], + "text": state[1], + } for state in states] + else: + field_dict["type"] = f"unknown ({ft})" + + # Extract tooltip (TU = tooltip/user-facing text) + tooltip = field.get('/TU') + if tooltip: + field_dict["tooltip"] = tooltip + + return field_dict + + +# Returns a list of fillable PDF fields: +# [ +# { +# "field_id": "name", +# "page": 1, +# "type": ("text", "checkbox", "radio_group", or "choice") +# // Per-type additional fields described in forms.md +# }, +# ] +def get_field_info(reader: PdfReader): + fields = reader.get_fields() + + field_info_by_id = {} + possible_radio_names = set() + + for field_id, field in fields.items(): + # Skip if this is a container field with children, except that it might be + # a parent group for radio button options. + if field.get("/Kids"): + if field.get("/FT") == "/Btn": + possible_radio_names.add(field_id) + continue + field_info_by_id[field_id] = make_field_dict(field, field_id) + + # Bounding rects are stored in annotations in page objects. + + # Radio button options have a separate annotation for each choice; + # all choices have the same field name. + # See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html + radio_fields_by_id = {} + + for page_index, page in enumerate(reader.pages): + annotations = page.get('/Annots', []) + for ann in annotations: + field_id = get_full_annotation_field_id(ann) + if field_id in field_info_by_id: + field_info_by_id[field_id]["page"] = page_index + 1 + field_info_by_id[field_id]["rect"] = ann.get('/Rect') + elif field_id in possible_radio_names: + try: + # ann['/AP']['/N'] should have two items. One of them is '/Off', + # the other is the active value. + on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"] + except KeyError: + continue + if len(on_values) == 1: + rect = ann.get("/Rect") + if field_id not in radio_fields_by_id: + radio_fields_by_id[field_id] = { + "field_id": field_id, + "type": "radio_group", + "page": page_index + 1, + "radio_options": [], + } + # Note: at least on macOS 15.7, Preview.app doesn't show selected + # radio buttons correctly. (It does if you remove the leading slash + # from the value, but that causes them not to appear correctly in + # Chrome/Firefox/Acrobat/etc). + radio_fields_by_id[field_id]["radio_options"].append({ + "value": on_values[0], + "rect": rect, + }) + + # Some PDFs have form field definitions without corresponding annotations, + # so we can't tell where they are. Ignore these fields for now. + fields_with_location = [] + for field_info in field_info_by_id.values(): + if "page" in field_info: + fields_with_location.append(field_info) + else: + print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring") + + # Sort by page number, then Y position (flipped in PDF coordinate system), then X. + def sort_key(f): + if "radio_options" in f: + rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] + else: + rect = f.get("rect") or [0, 0, 0, 0] + adjusted_position = [-rect[1], rect[0]] + return [f.get("page"), adjusted_position] + + sorted_fields = fields_with_location + list(radio_fields_by_id.values()) + sorted_fields.sort(key=sort_key) + + return sorted_fields + + +def write_field_info(pdf_path: str, json_output_path: str): + reader = PdfReader(pdf_path) + field_info = get_field_info(reader) + with open(json_output_path, "w") as f: + json.dump(field_info, f, indent=2) + print(f"Wrote {len(field_info)} fields to {json_output_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: extract_form_field_info.py [input pdf] [output json]") + sys.exit(1) + write_field_info(sys.argv[1], sys.argv[2]) diff --git a/skills/filling-pdf-forms/scripts/fill_fillable_fields.py b/skills/filling-pdf-forms/scripts/fill_fillable_fields.py new file mode 100644 index 0000000..ac35753 --- /dev/null +++ b/skills/filling-pdf-forms/scripts/fill_fillable_fields.py @@ -0,0 +1,114 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter + +from extract_form_field_info import get_field_info + + +# Fills fillable form fields in a PDF. See forms.md. + + +def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str): + with open(fields_json_path) as f: + fields = json.load(f) + # Group by page number. + fields_by_page = {} + for field in fields: + if "value" in field: + field_id = field["field_id"] + page = field["page"] + if page not in fields_by_page: + fields_by_page[page] = {} + fields_by_page[page][field_id] = field["value"] + + reader = PdfReader(input_pdf_path) + + has_error = False + field_info = get_field_info(reader) + fields_by_ids = {f["field_id"]: f for f in field_info} + for field in fields: + existing_field = fields_by_ids.get(field["field_id"]) + if not existing_field: + has_error = True + print(f"ERROR: `{field['field_id']}` is not a valid field ID") + elif field["page"] != existing_field["page"]: + has_error = True + print(f"ERROR: Incorrect page number for `{field['field_id']}` (got {field['page']}, expected {existing_field['page']})") + else: + if "value" in field: + err = validation_error_for_field_value(existing_field, field["value"]) + if err: + print(err) + has_error = True + if has_error: + sys.exit(1) + + writer = PdfWriter(clone_from=reader) + for page, field_values in fields_by_page.items(): + writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False) + + # This seems to be necessary for many PDF viewers to format the form values correctly. + # It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes. + writer.set_need_appearances_writer(True) + + with open(output_pdf_path, "wb") as f: + writer.write(f) + + +def validation_error_for_field_value(field_info, field_value): + field_type = field_info["type"] + field_id = field_info["field_id"] + if field_type == "checkbox": + checked_val = field_info["checked_value"] + unchecked_val = field_info["unchecked_value"] + if field_value != checked_val and field_value != unchecked_val: + return f'ERROR: Invalid value "{field_value}" for checkbox field "{field_id}". The checked value is "{checked_val}" and the unchecked value is "{unchecked_val}"' + elif field_type == "radio_group": + option_values = [opt["value"] for opt in field_info["radio_options"]] + if field_value not in option_values: + return f'ERROR: Invalid value "{field_value}" for radio group field "{field_id}". Valid values are: {option_values}' + elif field_type == "choice": + choice_values = [opt["value"] for opt in field_info["choice_options"]] + if field_value not in choice_values: + return f'ERROR: Invalid value "{field_value}" for choice field "{field_id}". Valid values are: {choice_values}' + return None + + +# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field. +# In _writer.py around line 966: +# +# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0: +# txt = "\n".join(annotation.get_inherited(FA.Opt, [])) +# +# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like +# [["value1", "Text 1"], ["value2", "Text 2"], ...] +# This causes `join` to throw a TypeError because it expects an iterable of strings. +# The horrible workaround is to patch `get_inherited` to return a list of the value strings. +# We call the original method and adjust the return value only if the argument to `get_inherited` +# is `FA.Opt` and if the return value is a list of two-element lists. +def monkeypatch_pydpf_method(): + from pypdf.generic import DictionaryObject + from pypdf.constants import FieldDictionaryAttributes + + original_get_inherited = DictionaryObject.get_inherited + + def patched_get_inherited(self, key: str, default = None): + result = original_get_inherited(self, key, default) + if key == FieldDictionaryAttributes.Opt: + if isinstance(result, list) and all(isinstance(v, list) and len(v) == 2 for v in result): + result = [r[0] for r in result] + return result + + DictionaryObject.get_inherited = patched_get_inherited + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]") + sys.exit(1) + monkeypatch_pydpf_method() + input_pdf = sys.argv[1] + fields_json = sys.argv[2] + output_pdf = sys.argv[3] + fill_pdf_fields(input_pdf, fields_json, output_pdf) diff --git a/skills/filling-pdf-forms/scripts/fill_nonfillable_fields.py b/skills/filling-pdf-forms/scripts/fill_nonfillable_fields.py new file mode 100644 index 0000000..cbd0a99 --- /dev/null +++ b/skills/filling-pdf-forms/scripts/fill_nonfillable_fields.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Fills a non-fillable PDF by adding text annotations. + +This script reads: +- .form.json (field definitions with bounding boxes in PDF coordinates) +- .values.json (field values from the interview) + +And creates an annotated PDF with the values placed at the specified locations. + +Usage: + python fill_nonfillable_fields.py .chatfield/.values.json +""" + +import json +import sys +from pathlib import Path + +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import FreeText + + +def fill_nonfillable_pdf(input_pdf_path, values_json_path, output_pdf_path): + """ + Fill a non-fillable PDF with text annotations. + + Args: + input_pdf_path: Path to the input PDF file + values_json_path: Path to .values.json file with field values + output_pdf_path: Path to write the filled PDF + """ + # Derive .form.json path from .values.json path + values_path = Path(values_json_path) + if not values_path.name.endswith('.values.json'): + raise ValueError(f"Expected .values.json file, got: {values_path.name}") + + form_json_path = values_path.parent / values_path.name.replace('.values.json', '.form.json') + + if not form_json_path.exists(): + raise FileNotFoundError( + f"Form definition file not found: {form_json_path}\n" + f"Expected to find .form.json alongside .values.json" + ) + + # Load field definitions (with bounding boxes in PDF coordinates) + with open(form_json_path, 'r') as f: + form_fields = json.load(f) + + # Load field values + with open(values_json_path, 'r') as f: + values_data = json.load(f) + + # Create a lookup map: field_id -> value + values_map = {field['field_id']: field['value'] for field in values_data['fields']} + + # Open the PDF + reader = PdfReader(input_pdf_path) + writer = PdfWriter() + + # Copy all pages to writer + writer.append(reader) + + # Process each form field + annotations_added = 0 + + for field_def in form_fields: + field_id = field_def.get('field_id') + + # Get the value for this field + if field_id not in values_map: + # No value provided for this field, skip it + continue + + value = values_map[field_id] + + # Skip empty values + if not value: + continue + + # Get field properties + page_num = field_def.get('page', 1) + rect = field_def.get('rect') + + if not rect: + print(f"Warning: Field {field_id} has no rect, skipping", file=sys.stderr) + continue + + # Default font settings + # Note: Font size/color may not work reliably across all PDF viewers + # https://github.com/py-pdf/pypdf/issues/2084 + font_name = "Arial" + font_size = "12pt" + font_color = "000000" # Black + + # Create the annotation + annotation = FreeText( + text=str(value), + rect=rect, # Already in PDF coordinates + font=font_name, + font_size=font_size, + font_color=font_color, + border_color=None, + background_color=None, + ) + + # Add annotation to the appropriate page (pypdf uses 0-based indexing) + writer.add_annotation(page_number=page_num - 1, annotation=annotation) + annotations_added += 1 + + # Save the filled PDF + with open(output_pdf_path, 'wb') as output: + writer.write(output) + + print(f"Successfully filled PDF and saved to {output_pdf_path}") + print(f"Added {annotations_added} text annotations") + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_nonfillable_fields.py .values.json ") + print() + print("Example:") + print(" python fill_nonfillable_fields.py form.pdf form.chatfield/form.values.json form.done.pdf") + sys.exit(1) + + input_pdf = sys.argv[1] + values_json = sys.argv[2] + output_pdf = sys.argv[3] + + try: + fill_nonfillable_pdf(input_pdf, values_json, output_pdf) + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1)