
Pdf Analyze
- 1 installs
- 404 repo stars
- Updated August 5, 2026
- aiskillstore/marketplace
pdf-analyze is a Claude Code skill that programmatically extracts, creates, merges, splits, and fills PDF documents and forms.
About
pdf-analyze is a Claude Code skill for programmatic PDF processing. It extracts text and tables with pypdf and pdfplumber, creates PDFs with reportlab, merges and splits documents, and fills PDF forms, backed by CLI tools like pdftotext and qpdf. A developer uses it when Claude needs to fill a PDF form or process, generate, or analyze PDF documents at scale.
- PDF toolkit: extract text and tables, create, merge/split, and fill forms
- Uses pypdf, pdfplumber, reportlab, plus pdftotext and qpdf CLIs
- Ships 8 Python scripts including form-field extraction and filling
Pdf Analyze by the numbers
- 1 all-time installs (skills.sh)
- Ranked #565 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
pdf-analyze capabilities & compatibility
- Capabilities
- pdf parsing · pdf generation · form filling · table extraction
- Use cases
- pdf parsing · documentation
- Pricing
- Free
What pdf-analyze says it does
Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms.
This guide covers essential PDF processing operations using Python libraries and command-line tools.
npx skills add https://github.com/aiskillstore/marketplace --skill pdf-analyzeAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 404 |
| Last updated | August 5, 2026 |
| Repository | aiskillstore/marketplace ↗ |
What it does
Extract text/tables, create, merge/split, and fill PDF forms programmatically with Python and CLI tools.
Who is it for?
Developers processing, generating, or filling PDF documents and forms at scale
Skip if: Non-PDF document formats or manual one-off edits
When should I use this skill?
filling a PDF form or programmatically processing, generating, or analyzing PDF documents
What you get
PDFs are extracted, generated, merged/split, or filled programmatically with verified form fields.
- extracted text/tables
- generated/merged/split PDFs
- filled PDF forms
By the numbers
- 8 Python scripts
- 3 core libraries (pypdf, pdfplumber, reportlab)
Files
PDF Processing Guide
Overview
This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions.
Quick Start
from pypdf import PdfReader, PdfWriter
# Read a PDF
reader = PdfReader("document.pdf")
print(f"Pages: {len(reader.pages)}")
# Extract text
text = ""
for page in reader.pages:
text += page.extract_text()Python Libraries
pypdf - Basic Operations
Merge PDFs
from pypdf import PdfWriter, PdfReader
writer = PdfWriter()
for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
reader = PdfReader(pdf_file)
for page in reader.pages:
writer.add_page(page)
with open("merged.pdf", "wb") as output:
writer.write(output)Split PDF
reader = PdfReader("input.pdf")
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
with open(f"page_{i+1}.pdf", "wb") as output:
writer.write(output)Extract Metadata
reader = PdfReader("document.pdf")
meta = reader.metadata
print(f"Title: {meta.title}")
print(f"Author: {meta.author}")
print(f"Subject: {meta.subject}")
print(f"Creator: {meta.creator}")Rotate Pages
reader = PdfReader("input.pdf")
writer = PdfWriter()
page = reader.pages[0]
page.rotate(90) # Rotate 90 degrees clockwise
writer.add_page(page)
with open("rotated.pdf", "wb") as output:
writer.write(output)pdfplumber - Text and Table Extraction
Extract Text with Layout
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
for page in pdf.pages:
text = page.extract_text()
print(text)Extract Tables
with pdfplumber.open("document.pdf") as pdf:
for i, page in enumerate(pdf.pages):
tables = page.extract_tables()
for j, table in enumerate(tables):
print(f"Table {j+1} on page {i+1}:")
for row in table:
print(row)Advanced Table Extraction
import pandas as pd
with pdfplumber.open("document.pdf") as pdf:
all_tables = []
for page in pdf.pages:
tables = page.extract_tables()
for table in tables:
if table: # Check if table is not empty
df = pd.DataFrame(table[1:], columns=table[0])
all_tables.append(df)
# Combine all tables
if all_tables:
combined_df = pd.concat(all_tables, ignore_index=True)
combined_df.to_excel("extracted_tables.xlsx", index=False)reportlab - Create PDFs
Basic PDF Creation
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas("hello.pdf", pagesize=letter)
width, height = letter
# Add text
c.drawString(100, height - 100, "Hello World!")
c.drawString(100, height - 120, "This is a PDF created with reportlab")
# Add a line
c.line(100, height - 140, 400, height - 140)
# Save
c.save()Create PDF with Multiple Pages
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib.styles import getSampleStyleSheet
doc = SimpleDocTemplate("report.pdf", pagesize=letter)
styles = getSampleStyleSheet()
story = []
# Add content
title = Paragraph("Report Title", styles['Title'])
story.append(title)
story.append(Spacer(1, 12))
body = Paragraph("This is the body of the report. " * 20, styles['Normal'])
story.append(body)
story.append(PageBreak())
# Page 2
story.append(Paragraph("Page 2", styles['Heading1']))
story.append(Paragraph("Content for page 2", styles['Normal']))
# Build PDF
doc.build(story)Command-Line Tools
pdftotext (poppler-utils)
# Extract text
pdftotext input.pdf output.txt
# Extract text preserving layout
pdftotext -layout input.pdf output.txt
# Extract specific pages
pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5qpdf
# Merge PDFs
qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
# Split pages
qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
# Rotate pages
qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
# Remove password
qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdfpdftk (if available)
# Merge
pdftk file1.pdf file2.pdf cat output merged.pdf
# Split
pdftk input.pdf burst
# Rotate
pdftk input.pdf rotate 1east output rotated.pdfCommon Tasks
Extract Text from Scanned PDFs
# Requires: pip install pytesseract pdf2image
import pytesseract
from pdf2image import convert_from_path
# Convert PDF to images
images = convert_from_path('scanned.pdf')
# OCR each page
text = ""
for i, image in enumerate(images):
text += f"Page {i+1}:\n"
text += pytesseract.image_to_string(image)
text += "\n\n"
print(text)Add Watermark
from pypdf import PdfReader, PdfWriter
# Create watermark (or load existing)
watermark = PdfReader("watermark.pdf").pages[0]
# Apply to all pages
reader = PdfReader("document.pdf")
writer = PdfWriter()
for page in reader.pages:
page.merge_page(watermark)
writer.add_page(page)
with open("watermarked.pdf", "wb") as output:
writer.write(output)Extract Images
# Using pdfimages (poppler-utils)
pdfimages -j input.pdf output_prefix
# This extracts all images as output_prefix-000.jpg, output_prefix-001.jpg, etc.Password Protection
from pypdf import PdfReader, PdfWriter
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
# Add password
writer.encrypt("userpassword", "ownerpassword")
with open("encrypted.pdf", "wb") as output:
writer.write(output)Quick Reference
| Task | Best Tool | Command/Code |
|---|---|---|
| Merge PDFs | pypdf | writer.add_page(page) |
| Split PDFs | pypdf | One page per file |
| Extract text | pdfplumber | page.extract_text() |
| Extract tables | pdfplumber | page.extract_tables() |
| Create PDFs | reportlab | Canvas or Platypus |
| Command line merge | qpdf | qpdf --empty --pages ... |
| OCR scanned PDFs | pytesseract | Convert to image first |
| Fill PDF forms | pdf-lib or pypdf (see forms.md) | See forms.md |
Next Steps
- For advanced pypdfium2 usage, see reference.md
- For JavaScript libraries (pdf-lib), see reference.md
- If you need to fill out a PDF form, follow the instructions in forms.md
- For troubleshooting guides, see reference.md
CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.
If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: python scripts/check_fillable_fields <file.pdf>, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions.
Fillable fields
If the PDF has fillable form fields:
- Run this script from this file's directory:
python scripts/extract_form_field_info.py <input.pdf> <field_info.json>. It will create a JSON file with a list of fields in this format:
[
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page),
"type": ("text", "checkbox", "radio_group", or "choice"),
},
// Checkboxes have "checked_value" and "unchecked_value" properties:
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "checkbox",
"checked_value": (Set the field to this value to check the checkbox),
"unchecked_value": (Set the field to this value to uncheck the checkbox),
},
// Radio groups have a "radio_options" list with the possible choices.
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "radio_group",
"radio_options": [
{
"value": (set the field to this value to select this radio option),
"rect": (bounding box for the radio button for this option)
},
// Other radio options
]
},
// Multiple choice fields have a "choice_options" list with the possible choices:
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "choice",
"choice_options": [
{
"value": (set the field to this value to select this option),
"text": (display text of the option)
},
// Other choice options
],
}
]- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory):
python scripts/convert_pdf_to_images.py <file.pdf> <output_directory> Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates).
- Create a
field_values.jsonfile in this format with the values to be entered for each field:
[
{
"field_id": "last_name", // Must match the field_id from `extract_form_field_info.py`
"description": "The user's last name",
"page": 1, // Must match the "page" value in field_info.json
"value": "Simpson"
},
{
"field_id": "Checkbox12",
"description": "Checkbox to be checked if the user is 18 or over",
"page": 1,
"value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options".
},
// more fields
]- Run the
fill_fillable_fields.pyscript from this file's directory to create a filled-in PDF:
python scripts/fill_fillable_fields.py <input pdf> <field_values.json> <output pdf> This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again.
Non-fillable fields
If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create 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.
- Use the bounding boxes to fill in the form.
Step 1: Visual Analysis (REQUIRED)
- Convert the PDF to PNG images. Run this script from this file's directory:
python scripts/convert_pdf_to_images.py <file.pdf> <output_directory> The script will create a PNG image for each page in the PDF.
- 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 text, determine bounding boxes for both the form field label, and the area where the user should enter text. 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:
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
_________________________
NameThe 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 fields.json and validation images (REQUIRED)
- Create a file named
fields.jsonwith information for the form fields and bounding boxes in this format:
{
"pages": [
{
"page_number": 1,
"image_width": (first page image width in pixels),
"image_height": (first page image height in pixels),
},
{
"page_number": 2,
"image_width": (second page image width in pixels),
"image_height": (second page image height in pixels),
}
// additional pages
],
"form_fields": [
// Example for a text field.
{
"page_number": 1,
"description": "The user's last name should be entered here",
// Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap.
"field_label": "Last name",
"label_bounding_box": [30, 125, 95, 142],
"entry_bounding_box": [100, 125, 280, 142],
"entry_text": {
"text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location
"font_size": 14, // optional, defaults to 14
"font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black)
}
},
// Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT
{
"page_number": 2,
"description": "Checkbox that should be checked if the user is over 18",
"entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square
"field_label": "Yes",
"label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text
// Use "X" to check a checkbox.
"entry_text": {
"text": "X",
}
}
// additional form field entries
]
}Create validation images by running this script from this file's directory for each page: `python scripts/create_validation_image.py <page_number> <path_to_fields.json> <input_image_path> <output_image_path>
The validation images will have red rectangles where text should be entered, and blue rectangles covering label text.
Step 3: Validate Bounding Boxes (REQUIRED)
Automated intersection check
- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the
check_bounding_boxes.pyscript (run from this file's directory):
python scripts/check_bounding_boxes.py <JSON file>
If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not.
Manual image inspection
CRITICAL: Do not proceed without visually inspecting validation images
- Red rectangles must ONLY cover input areas
- Red rectangles MUST NOT contain any text
- 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 fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate.
Step 4: Add annotations to the PDF
Run this script from this file's directory to create a filled-out PDF using the information in fields.json: `python scripts/fill_pdf_form_with_annotations.py <input_pdf_path> <path_to_fields.json> <output_pdf_path>
PDF Processing Advanced Reference
This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions.
pypdfium2 Library (Apache/BSD License)
Overview
pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement.
Render PDF to Images
import pypdfium2 as pdfium
from PIL import Image
# Load PDF
pdf = pdfium.PdfDocument("document.pdf")
# Render page to image
page = pdf[0] # First page
bitmap = page.render(
scale=2.0, # Higher resolution
rotation=0 # No rotation
)
# Convert to PIL Image
img = bitmap.to_pil()
img.save("page_1.png", "PNG")
# Process multiple pages
for i, page in enumerate(pdf):
bitmap = page.render(scale=1.5)
img = bitmap.to_pil()
img.save(f"page_{i+1}.jpg", "JPEG", quality=90)Extract Text with pypdfium2
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument("document.pdf")
for i, page in enumerate(pdf):
text = page.get_text()
print(f"Page {i+1} text length: {len(text)} chars")JavaScript Libraries
pdf-lib (MIT License)
pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment.
Load and Manipulate Existing PDF
import { PDFDocument } from 'pdf-lib';
import fs from 'fs';
async function manipulatePDF() {
// Load existing PDF
const existingPdfBytes = fs.readFileSync('input.pdf');
const pdfDoc = await PDFDocument.load(existingPdfBytes);
// Get page count
const pageCount = pdfDoc.getPageCount();
console.log(`Document has ${pageCount} pages`);
// Add new page
const newPage = pdfDoc.addPage([600, 400]);
newPage.drawText('Added by pdf-lib', {
x: 100,
y: 300,
size: 16
});
// Save modified PDF
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('modified.pdf', pdfBytes);
}Create Complex PDFs from Scratch
import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
import fs from 'fs';
async function createPDF() {
const pdfDoc = await PDFDocument.create();
// Add fonts
const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
// Add page
const page = pdfDoc.addPage([595, 842]); // A4 size
const { width, height } = page.getSize();
// Add text with styling
page.drawText('Invoice #12345', {
x: 50,
y: height - 50,
size: 18,
font: helveticaBold,
color: rgb(0.2, 0.2, 0.8)
});
// Add rectangle (header background)
page.drawRectangle({
x: 40,
y: height - 100,
width: width - 80,
height: 30,
color: rgb(0.9, 0.9, 0.9)
});
// Add table-like content
const items = [
['Item', 'Qty', 'Price', 'Total'],
['Widget', '2', '$50', '$100'],
['Gadget', '1', '$75', '$75']
];
let yPos = height - 150;
items.forEach(row => {
let xPos = 50;
row.forEach(cell => {
page.drawText(cell, {
x: xPos,
y: yPos,
size: 12,
font: helveticaFont
});
xPos += 120;
});
yPos -= 25;
});
const pdfBytes = await pdfDoc.save();
fs.writeFileSync('created.pdf', pdfBytes);
}Performance Optimization Tips
1. For Large PDFs
- Use streaming approaches instead of loading entire PDF in memory
- Use
qpdf --split-pagesfor splitting large files - Process pages individually with pypdfium2
2. For Text Extraction
pdftotext -bbox-layoutis fastest for plain text extraction- Use pdfplumber for structured data and tables
- Avoid
pypdf.extract_text()for very large documents
3. For Image Extraction
pdfimagesis much faster than rendering pages- Use low resolution for previews, high resolution for final output
4. For Form Filling
- pdf-lib maintains form structure better than most alternatives
- Pre-validate form fields before processing
5. Memory Management
# Process PDFs in chunks
def process_large_pdf(pdf_path, chunk_size=10):
reader = PdfReader(pdf_path)
total_pages = len(reader.pages)
for start_idx in range(0, total_pages, chunk_size):
end_idx = min(start_idx + chunk_size, total_pages)
writer = PdfWriter()
for i in range(start_idx, end_idx):
writer.add_page(reader.pages[i])
# Process chunk
with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output:
writer.write(output)Troubleshooting Common Issues
Encrypted PDFs
# Handle password-protected PDFs
from pypdf import PdfReader
try:
reader = PdfReader("encrypted.pdf")
if reader.is_encrypted:
reader.decrypt("password")
except Exception as e:
print(f"Failed to decrypt: {e}")Corrupted PDFs
# Use qpdf to repair
qpdf --check corrupted.pdf
qpdf --replace-input corrupted.pdfText Extraction Issues
# Fallback to OCR for scanned PDFs
import pytesseract
from pdf2image import convert_from_path
def extract_text_with_ocr(pdf_path):
images = convert_from_path(pdf_path)
text = ""
for i, image in enumerate(images):
text += pytesseract.image_to_string(image)
return textLicense Information
- pypdf: BSD License
- pdfplumber: MIT License
- pypdfium2: Apache/BSD License
- reportlab: BSD License
- poppler-utils: GPL-2 License
- qpdf: Apache License
- pdf-lib: MIT License
- pdfjs-dist: Apache License
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()
from dataclasses import dataclass
import json
import sys
# Script to check that the `fields.json` file that Claude creates when analyzing PDFs
# does not have overlapping bounding boxes. See forms.md.
@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['form_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["form_fields"]:
rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f))
rects_and_fields.append(RectAndField(f["entry_bounding_box"], "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_number"] == rj.field["page_number"] 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['description']}` ({ri.rect}, {rj.rect})")
else:
messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})")
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['description']}` 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]")
sys.exit(1)
# Input file should be in the `fields.json` format described in forms.md.
with open(sys.argv[1]) as f:
messages = get_bounding_box_messages(f)
for msg in messages:
print(msg)
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; you will need to visually determine where to enter data")
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)
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. See forms.md.
def create_validation_image(page_number, fields_json_path, input_path, output_path):
# Input file should be in the `fields.json` format described in forms.md.
with open(fields_json_path, 'r') as f:
data = json.load(f)
img = Image.open(input_path)
draw = ImageDraw.Draw(img)
num_boxes = 0
for field in data["form_fields"]:
if field["page_number"] == page_number:
entry_box = field['entry_bounding_box']
label_box = field['label_bounding_box']
# Draw red rectangle over entry bounding box and blue rectangle over the label.
draw.rectangle(entry_box, outline='red', width=2)
draw.rectangle(label_box, outline='blue', width=2)
num_boxes += 2
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] [fields.json file] [input image path] [output image path]")
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)
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})"
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])
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)
import json
import sys
from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText
# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md.
def transform_coordinates(bbox, image_width, image_height, pdf_width, pdf_height):
"""Transform bounding box from image coordinates to PDF coordinates"""
# Image coordinates: origin at top-left, y increases downward
# PDF coordinates: origin at bottom-left, y increases upward
x_scale = pdf_width / image_width
y_scale = pdf_height / image_height
left = bbox[0] * x_scale
right = bbox[2] * x_scale
# Flip Y coordinates for PDF
top = pdf_height - (bbox[1] * y_scale)
bottom = pdf_height - (bbox[3] * y_scale)
return left, bottom, right, top
def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
"""Fill the PDF form with data from fields.json"""
# `fields.json` format described in forms.md.
with open(fields_json_path, "r") as f:
fields_data = json.load(f)
# Open the PDF
reader = PdfReader(input_pdf_path)
writer = PdfWriter()
# Copy all pages to writer
writer.append(reader)
# Get PDF dimensions for each page
pdf_dimensions = {}
for i, page in enumerate(reader.pages):
mediabox = page.mediabox
pdf_dimensions[i + 1] = [mediabox.width, mediabox.height]
# Process each form field
annotations = []
for field in fields_data["form_fields"]:
page_num = field["page_number"]
# Get page dimensions and transform coordinates.
page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num)
image_width = page_info["image_width"]
image_height = page_info["image_height"]
pdf_width, pdf_height = pdf_dimensions[page_num]
transformed_entry_box = transform_coordinates(
field["entry_bounding_box"],
image_width, image_height,
pdf_width, pdf_height
)
# Skip empty fields
if "entry_text" not in field or "text" not in field["entry_text"]:
continue
entry_text = field["entry_text"]
text = entry_text["text"]
if not text:
continue
font_name = entry_text.get("font", "Arial")
font_size = str(entry_text.get("font_size", 14)) + "pt"
font_color = entry_text.get("font_color", "000000")
# Font size/color seems to not work reliably across viewers:
# https://github.com/py-pdf/pypdf/issues/2084
annotation = FreeText(
text=text,
rect=transformed_entry_box,
font=font_name,
font_size=font_size,
font_color=font_color,
border_color=None,
background_color=None,
)
annotations.append(annotation)
# page_number is 0-based for pypdf
writer.add_annotation(page_number=page_num - 1, annotation=annotation)
# Save the filled PDF
with open(output_pdf_path, "wb") as output:
writer.write(output)
print(f"Successfully filled PDF form and saved to {output_pdf_path}")
print(f"Added {len(annotations)} text annotations")
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: fill_pdf_form_with_annotations.py [input pdf] [fields.json] [output pdf]")
sys.exit(1)
input_pdf = sys.argv[1]
fields_json = sys.argv[2]
output_pdf = sys.argv[3]
fill_pdf_form(input_pdf, fields_json, output_pdf){
"schema_version": "2.0",
"meta": {
"generated_at": "2026-01-16T12:22:39.049Z",
"slug": "21pounder-pdf-analyze",
"source_url": "https://github.com/21pounder/terminalAgent/tree/main/deepresearch/.claude/skills/pdf-analyze",
"source_ref": "main",
"model": "claude",
"analysis_version": "3.0.0",
"source_type": "community",
"content_hash": "fc61b78c0fc4138164e621d0de608bb06e60558366bcbdea5a31d9d0eb67e483",
"tree_hash": "ae77196f0a91aaee9237f607fdd74d657d9314370c846e6d378df5b4de7c4f25"
},
"skill": {
"name": "pdf-analyze",
"description": "Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms. When Claude needs to fill in a PDF form or programmatically process, generate, or analyze PDF documents at scale.",
"summary": "Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/sp...",
"icon": "📄",
"version": "1.0.0",
"author": "21pounder",
"license": "Proprietary. LICENSE.txt has complete terms",
"category": "data",
"tags": [
"pdf",
"document",
"extraction",
"forms"
],
"supported_tools": [
"claude",
"codex",
"claude-code"
],
"risk_factors": [
"external_commands",
"filesystem",
"network"
]
},
"security_audit": {
"risk_level": "low",
"is_blocked": false,
"safe_to_publish": true,
"summary": "This is a legitimate PDF processing toolkit. The static analyzer flagged 173 potential issues, but ALL are false positives. The 'Ruby/shell backtick execution' patterns are Markdown code formatting delimiters, not actual command execution. 'Weak cryptographic algorithm' detections are test fixture data with hash-like strings, not crypto operations. 'C2 keywords' refers to 'encrypt' in PDF password documentation. No network exfiltration, credential theft, or obfuscation patterns exist. The skill's behavior fully aligns with its stated purpose.",
"risk_factor_evidence": [
{
"factor": "external_commands",
"evidence": [
{
"file": "FORMS.md",
"line_start": 4,
"line_end": 4
},
{
"file": "FORMS.md",
"line_start": 8,
"line_end": 8
},
{
"file": "FORMS.md",
"line_start": 9,
"line_end": 52
},
{
"file": "FORMS.md",
"line_start": 52,
"line_end": 54
},
{
"file": "FORMS.md",
"line_start": 54,
"line_end": 56
},
{
"file": "FORMS.md",
"line_start": 56,
"line_end": 57
},
{
"file": "FORMS.md",
"line_start": 57,
"line_end": 60
},
{
"file": "FORMS.md",
"line_start": 60,
"line_end": 73
},
{
"file": "FORMS.md",
"line_start": 73,
"line_end": 74
},
{
"file": "FORMS.md",
"line_start": 74,
"line_end": 75
},
{
"file": "FORMS.md",
"line_start": 75,
"line_end": 87
},
{
"file": "FORMS.md",
"line_start": 87,
"line_end": 94
},
{
"file": "FORMS.md",
"line_start": 94,
"line_end": 98
},
{
"file": "FORMS.md",
"line_start": 98,
"line_end": 102
},
{
"file": "FORMS.md",
"line_start": 102,
"line_end": 104
},
{
"file": "FORMS.md",
"line_start": 104,
"line_end": 108
},
{
"file": "FORMS.md",
"line_start": 108,
"line_end": 111
},
{
"file": "FORMS.md",
"line_start": 111,
"line_end": 115
},
{
"file": "FORMS.md",
"line_start": 115,
"line_end": 118
},
{
"file": "FORMS.md",
"line_start": 118,
"line_end": 122
},
{
"file": "FORMS.md",
"line_start": 122,
"line_end": 124
},
{
"file": "FORMS.md",
"line_start": 124,
"line_end": 131
},
{
"file": "FORMS.md",
"line_start": 131,
"line_end": 132
},
{
"file": "FORMS.md",
"line_start": 132,
"line_end": 177
},
{
"file": "FORMS.md",
"line_start": 177,
"line_end": 180
},
{
"file": "FORMS.md",
"line_start": 186,
"line_end": 186
},
{
"file": "FORMS.md",
"line_start": 187,
"line_end": 187
},
{
"file": "REFERENCE.md",
"line_start": 11,
"line_end": 34
},
{
"file": "REFERENCE.md",
"line_start": 34,
"line_end": 37
},
{
"file": "REFERENCE.md",
"line_start": 37,
"line_end": 44
},
{
"file": "REFERENCE.md",
"line_start": 44,
"line_end": 53
},
{
"file": "REFERENCE.md",
"line_start": 53,
"line_end": 64
},
{
"file": "REFERENCE.md",
"line_start": 64,
"line_end": 78
},
{
"file": "REFERENCE.md",
"line_start": 78,
"line_end": 81
},
{
"file": "REFERENCE.md",
"line_start": 81,
"line_end": 139
},
{
"file": "REFERENCE.md",
"line_start": 139,
"line_end": 145
},
{
"file": "REFERENCE.md",
"line_start": 145,
"line_end": 149
},
{
"file": "REFERENCE.md",
"line_start": 149,
"line_end": 151
},
{
"file": "REFERENCE.md",
"line_start": 151,
"line_end": 154
},
{
"file": "REFERENCE.md",
"line_start": 154,
"line_end": 162
},
{
"file": "REFERENCE.md",
"line_start": 162,
"line_end": 178
},
{
"file": "REFERENCE.md",
"line_start": 178,
"line_end": 183
},
{
"file": "REFERENCE.md",
"line_start": 183,
"line_end": 193
},
{
"file": "REFERENCE.md",
"line_start": 193,
"line_end": 196
},
{
"file": "REFERENCE.md",
"line_start": 196,
"line_end": 200
},
{
"file": "REFERENCE.md",
"line_start": 200,
"line_end": 203
},
{
"file": "REFERENCE.md",
"line_start": 203,
"line_end": 214
},
{
"file": "scripts/check_bounding_boxes.py",
"line_start": 6,
"line_end": 6
},
{
"file": "scripts/check_bounding_boxes.py",
"line_start": 41,
"line_end": 41
},
{
"file": "scripts/check_bounding_boxes.py",
"line_start": 43,
"line_end": 43
},
{
"file": "scripts/check_bounding_boxes.py",
"line_start": 43,
"line_end": 43
},
{
"file": "scripts/check_bounding_boxes.py",
"line_start": 53,
"line_end": 53
},
{
"file": "scripts/check_bounding_boxes.py",
"line_start": 66,
"line_end": 66
},
{
"file": "scripts/convert_pdf_to_images.py",
"line_start": 14,
"line_end": 14
},
{
"file": "scripts/create_validation_image.py",
"line_start": 12,
"line_end": 12
},
{
"file": "scripts/extract_form_field_info.py",
"line_start": 11,
"line_end": 11
},
{
"file": "scripts/extract_form_field_info.py",
"line_start": 11,
"line_end": 11
},
{
"file": "scripts/extract_form_field_info.py",
"line_start": 38,
"line_end": 38
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 34,
"line_end": 34
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 37,
"line_end": 37
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 84,
"line_end": 84
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 86,
"line_end": 86
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 87,
"line_end": 87
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 88,
"line_end": 88
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 89,
"line_end": 89
},
{
"file": "scripts/fill_pdf_form_with_annotations.py",
"line_start": 8,
"line_end": 8
},
{
"file": "scripts/fill_pdf_form_with_annotations.py",
"line_start": 31,
"line_end": 31
},
{
"file": "SKILL.md",
"line_start": 15,
"line_end": 26
},
{
"file": "SKILL.md",
"line_start": 26,
"line_end": 33
},
{
"file": "SKILL.md",
"line_start": 33,
"line_end": 44
},
{
"file": "SKILL.md",
"line_start": 44,
"line_end": 47
},
{
"file": "SKILL.md",
"line_start": 47,
"line_end": 54
},
{
"file": "SKILL.md",
"line_start": 54,
"line_end": 57
},
{
"file": "SKILL.md",
"line_start": 57,
"line_end": 64
},
{
"file": "SKILL.md",
"line_start": 64,
"line_end": 67
},
{
"file": "SKILL.md",
"line_start": 67,
"line_end": 77
},
{
"file": "SKILL.md",
"line_start": 77,
"line_end": 82
},
{
"file": "SKILL.md",
"line_start": 82,
"line_end": 89
},
{
"file": "SKILL.md",
"line_start": 89,
"line_end": 92
},
{
"file": "SKILL.md",
"line_start": 92,
"line_end": 100
},
{
"file": "SKILL.md",
"line_start": 100,
"line_end": 103
},
{
"file": "SKILL.md",
"line_start": 103,
"line_end": 119
},
{
"file": "SKILL.md",
"line_start": 119,
"line_end": 124
},
{
"file": "SKILL.md",
"line_start": 124,
"line_end": 140
},
{
"file": "SKILL.md",
"line_start": 140,
"line_end": 143
},
{
"file": "SKILL.md",
"line_start": 143,
"line_end": 167
},
{
"file": "SKILL.md",
"line_start": 167,
"line_end": 172
},
{
"file": "SKILL.md",
"line_start": 172,
"line_end": 181
},
{
"file": "SKILL.md",
"line_start": 181,
"line_end": 184
},
{
"file": "SKILL.md",
"line_start": 184,
"line_end": 197
},
{
"file": "SKILL.md",
"line_start": 197,
"line_end": 200
},
{
"file": "SKILL.md",
"line_start": 200,
"line_end": 209
},
{
"file": "SKILL.md",
"line_start": 209,
"line_end": 214
},
{
"file": "SKILL.md",
"line_start": 214,
"line_end": 230
},
{
"file": "SKILL.md",
"line_start": 230,
"line_end": 233
},
{
"file": "SKILL.md",
"line_start": 233,
"line_end": 249
},
{
"file": "SKILL.md",
"line_start": 249,
"line_end": 252
},
{
"file": "SKILL.md",
"line_start": 252,
"line_end": 257
},
{
"file": "SKILL.md",
"line_start": 257,
"line_end": 260
},
{
"file": "SKILL.md",
"line_start": 260,
"line_end": 274
},
{
"file": "SKILL.md",
"line_start": 274,
"line_end": 280
},
{
"file": "SKILL.md",
"line_start": 280,
"line_end": 282
},
{
"file": "SKILL.md",
"line_start": 282,
"line_end": 283
},
{
"file": "SKILL.md",
"line_start": 283,
"line_end": 285
}
]
},
{
"factor": "filesystem",
"evidence": [
{
"file": "REFERENCE.md",
"line_start": 59,
"line_end": 59
},
{
"file": "REFERENCE.md",
"line_start": 76,
"line_end": 76
},
{
"file": "REFERENCE.md",
"line_start": 137,
"line_end": 137
},
{
"file": "REFERENCE.md",
"line_start": 59,
"line_end": 59
},
{
"file": "REFERENCE.md",
"line_start": 76,
"line_end": 76
},
{
"file": "REFERENCE.md",
"line_start": 137,
"line_end": 137
},
{
"file": "REFERENCE.md",
"line_start": 176,
"line_end": 176
},
{
"file": "scripts/extract_form_field_info.py",
"line_start": 143,
"line_end": 143
},
{
"file": "scripts/fill_fillable_fields.py",
"line_start": 55,
"line_end": 55
},
{
"file": "scripts/fill_pdf_form_with_annotations.py",
"line_start": 93,
"line_end": 93
},
{
"file": "skill-report.json",
"line_start": 6,
"line_end": 6
},
{
"file": "SKILL.md",
"line_start": 42,
"line_end": 42
},
{
"file": "SKILL.md",
"line_start": 52,
"line_end": 52
},
{
"file": "SKILL.md",
"line_start": 75,
"line_end": 75
},
{
"file": "SKILL.md",
"line_start": 247,
"line_end": 247
},
{
"file": "SKILL.md",
"line_start": 272,
"line_end": 272
}
]
},
{
"factor": "network",
"evidence": [
{
"file": "scripts/extract_form_field_info.py",
"line_start": 32,
"line_end": 32
},
{
"file": "scripts/extract_form_field_info.py",
"line_start": 81,
"line_end": 81
},
{
"file": "scripts/fill_pdf_form_with_annotations.py",
"line_start": 78,
"line_end": 78
},
{
"file": "skill-report.json",
"line_start": 6,
"line_end": 6
}
]
}
],
"critical_findings": [],
"high_findings": [],
"medium_findings": [],
"low_findings": [],
"dangerous_patterns": [],
"files_scanned": 12,
"total_lines": 1729,
"audit_model": "claude",
"audited_at": "2026-01-16T12:22:39.049Z"
},
"content": {
"user_title": "Process PDF documents for extraction and form filling",
"value_statement": "PDF documents often contain important data that is difficult to access programmatically. This skill provides Claude with comprehensive tools to extract text and tables, fill forms, merge documents, and convert PDFs to images for analysis.",
"seo_keywords": [
"Claude PDF processing",
"extract PDF text",
"fill PDF forms",
"PDF manipulation",
"table extraction from PDF",
"Claude Code PDF tools",
"merge split PDF",
"OCR PDF text extraction",
"pdf-lib JavaScript",
"pypdf Python"
],
"actual_capabilities": [
"Extract text from PDF documents with layout preservation",
"Extract tables from PDFs and convert to structured formats like Excel",
"Fill fillable PDF forms with field validation",
"Add text annotations to non-fillable PDF forms",
"Merge and split PDF documents",
"Convert PDF pages to images for visual analysis"
],
"limitations": [
"Requires poppler-utils installed for command-line tools",
"OCR requires tesseract installation for scanned documents",
"Cannot edit existing PDF content beyond form fields and annotations",
"Complex PDF forms may require manual verification of output"
],
"use_cases": [
{
"target_user": "Data analysts",
"title": "Extract tables from reports",
"description": "Pull structured data from financial reports, research papers, and statistical documents into CSV or Excel format."
},
{
"target_user": "Administrative staff",
"title": "Automate form completion",
"description": "Fill out PDF forms programmatically with validated data for applications, surveys, and official documents."
},
{
"target_user": "Developers",
"title": "Build PDF processing workflows",
"description": "Create document processing pipelines that merge, split, and transform PDFs for applications and services."
}
],
"prompt_templates": [
{
"title": "Extract PDF text",
"scenario": "Get text from a document",
"prompt": "Extract all text from document.pdf using pdfplumber and save it to extracted_text.txt"
},
{
"title": "List form fields",
"scenario": "Check PDF for fillable fields",
"prompt": "Check if application_form.pdf has fillable form fields, and if so, list all field names and types"
},
{
"title": "Extract tables",
"scenario": "Get structured data from tables",
"prompt": "Extract all tables from quarterly_report.pdf and save them to an Excel file with one sheet per table"
},
{
"title": "Fill PDF form",
"scenario": "Complete a fillable form",
"prompt": "Fill in the following fields in application_form.pdf using data from field_values.json and save to completed_form.pdf"
}
],
"output_examples": [
{
"input": "Extract text from report.pdf and list all tables found",
"output": [
"Extracted 24 pages from report.pdf",
"Found 5 tables:",
" - Table 1: 'Revenue by Region' (page 3)",
" - Table 2: 'Q4 Performance Metrics' (page 7)",
" - Table 3: 'Customer Demographics' (page 12)",
" - Table 4: 'Year-over-Year Growth' (page 18)",
" - Table 5: 'Projected 2025 Targets' (page 22)",
"Saved extracted_text.txt (45 KB) and tables to tables_20250110.xlsx"
]
}
],
"best_practices": [
"Validate form field values before submission to catch errors early",
"Convert PDF to images first when working with non-fillable forms to visually verify annotation placement",
"Use the bounding box validation script to ensure annotations do not overlap or obscure existing content"
],
"anti_patterns": [
"Skipping the form field validation step before filling PDFs",
"Not converting non-fillable PDFs to images for visual analysis first",
"Using hardcoded file paths instead of parameters for reusability"
],
"faq": [
{
"question": "Which Python libraries does this skill use?",
"answer": "Primary libraries are pypdf for basic operations, pdfplumber for text and table extraction, and reportlab for creating new PDFs."
},
{
"question": "What are the system requirements?",
"answer": "Requires Python 3.8+ with pip install of pypdf, pdfplumber, reportlab, pdf2image, and PIL. Poppler must be installed for PDF to image conversion."
},
{
"question": "How do I fill a scanned PDF that is not fillable?",
"answer": "Use the non-fillable form workflow: convert PDF to images, manually determine text entry locations, create fields.json with bounding boxes, then use fill_pdf_form_with_annotations.py."
},
{
"question": "Is my data safe when processing PDFs?",
"answer": "Yes. All processing is local using Python libraries. No data is sent to external servers. Files are only read from and written to paths you specify."
},
{
"question": "Why does my filled PDF show annotations in the wrong position?",
"answer": "This usually indicates incorrect coordinate transformation. PDF coordinates start from bottom-left while image coordinates start from top-left. Verify your bounding box conversion logic."
},
{
"question": "How is this different from using pdf-lib in JavaScript?",
"answer": "The Python tools provide more mature text extraction and table parsing. pdf-lib is better suited for browser environments or Node.js projects that need to create or modify PDFs client-side."
}
]
},
"file_structure": [
{
"name": "scripts",
"type": "dir",
"path": "scripts",
"children": [
{
"name": "check_bounding_boxes_test.py",
"type": "file",
"path": "scripts/check_bounding_boxes_test.py",
"lines": 227
},
{
"name": "check_bounding_boxes.py",
"type": "file",
"path": "scripts/check_bounding_boxes.py",
"lines": 71
},
{
"name": "check_fillable_fields.py",
"type": "file",
"path": "scripts/check_fillable_fields.py",
"lines": 13
},
{
"name": "convert_pdf_to_images.py",
"type": "file",
"path": "scripts/convert_pdf_to_images.py",
"lines": 36
},
{
"name": "create_validation_image.py",
"type": "file",
"path": "scripts/create_validation_image.py",
"lines": 42
},
{
"name": "extract_form_field_info.py",
"type": "file",
"path": "scripts/extract_form_field_info.py",
"lines": 153
},
{
"name": "fill_fillable_fields.py",
"type": "file",
"path": "scripts/fill_fillable_fields.py",
"lines": 115
},
{
"name": "fill_pdf_form_with_annotations.py",
"type": "file",
"path": "scripts/fill_pdf_form_with_annotations.py",
"lines": 108
}
]
},
{
"name": "FORMS.md",
"type": "file",
"path": "FORMS.md",
"lines": 206
},
{
"name": "REFERENCE.md",
"type": "file",
"path": "REFERENCE.md",
"lines": 226
},
{
"name": "SKILL.md",
"type": "file",
"path": "SKILL.md",
"lines": 295
}
]
}
Related skills
FAQ
Which libraries does it use?
pypdf and pdfplumber for extraction, reportlab for creation, plus pdftotext and qpdf command-line tools.
Can it fill fillable forms?
Yes, it ships scripts to extract form-field info and fill fillable fields or annotations; see forms.md.