feat(project): 项目完结
This commit is contained in:
103
configs/agent/agent-draw-io.yaml
Normal file
103
configs/agent/agent-draw-io.yaml
Normal file
@@ -0,0 +1,103 @@
|
||||
ai:
|
||||
agent:
|
||||
config:
|
||||
tables:
|
||||
drawIoAgent:
|
||||
app-name: drawIoAgent
|
||||
agent:
|
||||
agent-id: "300000"
|
||||
agent-name: "AI交互式绘图智能体"
|
||||
agent-desc: "人 + AI + Draw.io,交互式对话完成 draw.io 绘图。"
|
||||
module:
|
||||
ai-api:
|
||||
base-url: ${OPENAI_BASE_URL}
|
||||
api-key: ${OPENAI_API_KEY}
|
||||
completions-path: v1/chat/completions
|
||||
embeddings-path: v1/embeddings
|
||||
chat-model:
|
||||
model: "gpt-5.5"
|
||||
tool-mcp-list:
|
||||
- sse:
|
||||
name: baidu-search
|
||||
base-uri: ${BAIDU_SEARCH_MCP_BASE_URI}
|
||||
sse-endpoint: ${BAIDU_SEARCH_MCP_SSE_ENDPOINT}
|
||||
request-timeout: 500000
|
||||
tool-skills-list:
|
||||
- type: "resource"
|
||||
path: "agent/skills"
|
||||
agents:
|
||||
# 1. 需求分析与检索智能体
|
||||
- name: agent_analyst
|
||||
description: 负责理解用户意图,调用工具检索信息,并整理出可用于绘图的需求描述。
|
||||
instruction: |
|
||||
你是一个专业的绘图需求分析师。请基于用户最新的输入,输出一段中文文本,描述要绘制的图表。
|
||||
要求:
|
||||
1. 如有需要可调用可用工具(MCP)补充信息,但不要把工具调用结果原样贴出。
|
||||
2. 即便用户描述较简略,也要根据常识合理补全,不要要求用户继续补充信息。
|
||||
3. 输出必须是单段纯文本,明确包含:图表类型(流程图 / 时序图 / 类图 等)、关键节点、节点之间的关系或调用顺序、必要的布局提示。
|
||||
4. 严禁输出 JSON、Markdown、代码块或 XML,只输出自然语言描述。
|
||||
output-key: analysis_result
|
||||
|
||||
# 2. 绘图执行智能体
|
||||
- name: agent_drawer
|
||||
description: 根据分析结果生成 Draw.io 的 mxfile XML 数据。
|
||||
instruction: |
|
||||
你是一个 Draw.io 绘图专家。下面是上一步整理好的绘图需求:
|
||||
---
|
||||
{analysis_result}
|
||||
---
|
||||
请只输出一份合法的 draw.io XML,要求:
|
||||
1. 必须以 `<mxfile` 开头,以 `</mxfile>` 结尾,包含 `<diagram>` 与 `<mxGraphModel>`。
|
||||
2. 节点(mxCell vertex="1")有合适的 `geometry` 坐标和大小,避免重叠。
|
||||
3. 连线(mxCell edge="1")使用 `source` 与 `target` 引用节点 id,逻辑顺序清晰,避免明显交叉。
|
||||
4. 严禁输出任何 JSON、Markdown、代码块标记(```),不要写解释文字,输出从 `<mxfile` 第一个字符开始。
|
||||
output-key: draft_diagram
|
||||
|
||||
# 3. 检查与优化智能体
|
||||
- name: agent_reviewer
|
||||
description: 检查绘图结果,修正语法问题,输出最终的裸 XML。
|
||||
instruction: |
|
||||
你是一个 draw.io XML 质量检查员。下面是上一步生成的 XML:
|
||||
---
|
||||
{draft_diagram}
|
||||
---
|
||||
请按以下规则输出最终结果:
|
||||
1. 检查 XML 是否合法(标签闭合、属性正确、id 与 source/target 一致),有问题就直接修正。
|
||||
2. 如发现节点明显重叠或连线交叉,可微调 geometry 让布局更整洁,但不要新增/删除核心节点。
|
||||
3. 最终输出必须只有这份合法的 draw.io XML,必须以 `<mxfile` 开头,以 `</mxfile>` 结尾。
|
||||
4. 严禁输出任何 JSON、Markdown、代码块标记或解释文字。如果发现输入完全不像 XML,则按需求自行重写一份合法的最简 mxfile XML 后输出。
|
||||
output-key: final_result
|
||||
|
||||
agent-workflows:
|
||||
# 案例;定义一个循环工作流(示例),用于反复优化绘图(需要在 instruction 中支持反馈机制才能生效)
|
||||
- type: loop
|
||||
name: loop_refinement
|
||||
description: 循环优化绘图结果
|
||||
max-iterations: 3
|
||||
sub-agents:
|
||||
- agent_drawer
|
||||
- agent_reviewer
|
||||
|
||||
# 案例;定义一个并行工作流(示例),可以并行生成多个方案(需要后续有 Agent 进行选择)
|
||||
- type: parallel
|
||||
name: parallel_generation
|
||||
description: 并行生成多个绘图方案
|
||||
sub-agents:
|
||||
- agent_drawer
|
||||
- agent_drawer
|
||||
|
||||
# 对外;定义一个串行工作流,按顺序执行分析、绘图、检查
|
||||
- type: sequential
|
||||
name: sequential_draw_process
|
||||
description: 标准绘图流程:分析 -> 绘图 -> 检查
|
||||
sub-agents:
|
||||
- agent_analyst
|
||||
- agent_drawer
|
||||
- agent_reviewer
|
||||
|
||||
runner:
|
||||
# 指定入口为串行工作流,这是最稳健的配置
|
||||
agent-name: sequential_draw_process
|
||||
plugin-name-list:
|
||||
- myTestPlugin
|
||||
- myLogPlugin
|
||||
37
configs/agent/only-one-agent.yaml
Normal file
37
configs/agent/only-one-agent.yaml
Normal file
@@ -0,0 +1,37 @@
|
||||
ai:
|
||||
agent:
|
||||
config:
|
||||
tables:
|
||||
testAgent03:
|
||||
app-name: testAgent03
|
||||
agent:
|
||||
agent-id: "100003"
|
||||
agent-name: "single agent"
|
||||
agent-desc: "single agent demo"
|
||||
module:
|
||||
ai-api:
|
||||
base-url: ${OPENAI_BASE_URL}
|
||||
api-key: ${OPENAI_API_KEY}
|
||||
completions-path: "v1/chat/completions"
|
||||
embeddings-path: "v1/embeddings"
|
||||
chat-model:
|
||||
model: "gpt-5.5"
|
||||
tool-mcp-list:
|
||||
- sse:
|
||||
name: baidu-search
|
||||
base-uri: ${BAIDU_SEARCH_MCP_BASE_URI}
|
||||
sse-endpoint: ${BAIDU_SEARCH_MCP_SSE_ENDPOINT}
|
||||
request-timeout: 500000
|
||||
tool-skills-list:
|
||||
- type: "resource"
|
||||
path: "agent/skills"
|
||||
agents:
|
||||
- name: "onlyAgent"
|
||||
description: "study plan helper"
|
||||
instruction: |
|
||||
Build a beginner-friendly study plan from the user's request.
|
||||
runner:
|
||||
agent-name: "onlyAgent"
|
||||
plugin-name-list:
|
||||
- "myTestPlugin"
|
||||
- "myLogPlugin"
|
||||
10
configs/agent/skills/battle-plan/SKILL.md
Normal file
10
configs/agent/skills/battle-plan/SKILL.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: battle-plan
|
||||
description: 电脑性能优化
|
||||
license: MIT
|
||||
---
|
||||
|
||||
你需要执行 ```./scripts/get_system_info.sh``` 脚本,获取当前电脑的配置,包括;用户名、操作系统、CPU、内存、磁盘等信息。之后执行 ```./scripts/check_cleanable_files.sh``` 脚本,列举出 macOS 系统中常见的垃圾文件和可清理内容,并显示占用空间大小。
|
||||
|
||||
在获取以上信息之后,你要以 reference.md 文档的话术结构,对当前电脑的用户名,下达优化建议命令。如,小傅哥你记一下(小傅哥,要用你获得的电脑用户名替换),我做xxx部署调整的文案,让用户告知的方式来优化电脑性能。描述的话术,一定要和 reference.md 文档的话术结构保持一致,有种下达命令的体验。
|
||||
|
||||
1
configs/agent/skills/battle-plan/reference.md
Normal file
1
configs/agent/skills/battle-plan/reference.md
Normal file
@@ -0,0 +1 @@
|
||||
刘亚楼,你记一下,我作如下部署调整——以四纵、十一纵加两个独立师,强化塔山防线;二、三、七、八、九,五个纵队加六纵十七师,包打锦州;十纵加一个师在黑山大虎山一线阻击廖耀湘兵团;十二纵加十二个独立师,围团长春;五纵、六纵两个师监视沈阳;一纵作总预备队。给我复述一遍!
|
||||
@@ -0,0 +1,100 @@
|
||||
#!/bin/bash
|
||||
|
||||
# check_cleanable_files.sh
|
||||
# 脚本功能:列举 macOS 系统中常见的垃圾文件和可清理内容,并显示占用空间大小。
|
||||
# 注意:此脚本仅进行扫描和列举,不会删除任何文件。
|
||||
|
||||
echo "============================================================"
|
||||
echo " macOS 系统可清理垃圾文件扫描 "
|
||||
echo "============================================================"
|
||||
echo "正在扫描,请稍候..."
|
||||
echo ""
|
||||
|
||||
# 定义颜色
|
||||
RED='\033[0;31m'
|
||||
GREEN='\033[0;32m'
|
||||
YELLOW='\033[1;33m'
|
||||
NC='\033[0m' # No Color
|
||||
|
||||
# 函数:检查并打印目录大小
|
||||
check_dir_size() {
|
||||
local name="$1"
|
||||
local path="$2"
|
||||
local desc="$3"
|
||||
|
||||
if [ -d "$path" ]; then
|
||||
# 使用 du -sh 计算大小,并提取数值
|
||||
# 2>/dev/null 屏蔽权限错误
|
||||
size=$(du -sh "$path" 2>/dev/null | cut -f1)
|
||||
|
||||
# 如果目录为空或无法读取大小,可能显示为 0B 或空
|
||||
if [ -n "$size" ]; then
|
||||
echo -e "${YELLOW}[$name]${NC}"
|
||||
echo -e " 路径: $path"
|
||||
echo -e " 大小: ${RED}$size${NC}"
|
||||
echo -e " 说明: $desc"
|
||||
echo "------------------------------------------------------------"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
# 1. 用户缓存
|
||||
check_dir_size "用户缓存" "$HOME/Library/Caches" "应用程序产生的临时文件,通常可以安全清理(可能会导致应用重新加载数据变慢)。"
|
||||
|
||||
# 2. 系统日志
|
||||
check_dir_size "用户日志" "$HOME/Library/Logs" "应用程序的日志文件,如果不需要排查问题,通常可以清理。"
|
||||
check_dir_size "系统日志" "/private/var/log" "系统运行日志,通常由系统自动管理,但积压过多时可清理旧日志。"
|
||||
|
||||
# 3. 废纸篓
|
||||
check_dir_size "废纸篓" "$HOME/.Trash" "已删除但未清空的文件。"
|
||||
|
||||
# 4. Xcode 开发垃圾 (如果存在)
|
||||
check_dir_size "Xcode DerivedData" "$HOME/Library/Developer/Xcode/DerivedData" "Xcode 编译产生的中间文件和索引,删除后下次编译会重新生成(可解决很多 Xcode 报错问题)。"
|
||||
check_dir_size "Xcode iOS DeviceSupport" "$HOME/Library/Developer/Xcode/iOS DeviceSupport" "连接过的旧 iOS 设备支持文件,如果不再调试旧版本 iOS,可以清理。"
|
||||
check_dir_size "Xcode Archives" "$HOME/Library/Developer/Xcode/Archives" "打包发布的 App 归档,如果确认不再需要旧版本的包,可以清理。"
|
||||
|
||||
# 5. 浏览器缓存 (部分示例)
|
||||
check_dir_size "Chrome 缓存" "$HOME/Library/Caches/Google/Chrome" "Chrome 浏览器的缓存文件。"
|
||||
# Firefox
|
||||
check_dir_size "Firefox 缓存" "$HOME/Library/Caches/Firefox" "Firefox 浏览器的缓存文件。"
|
||||
|
||||
# 6. 包管理器缓存
|
||||
# Homebrew
|
||||
if command -v brew &> /dev/null; then
|
||||
brew_cache=$(brew --cache)
|
||||
check_dir_size "Homebrew 缓存" "$brew_cache" "Homebrew 下载的安装包缓存,可通过 'brew cleanup' 清理。"
|
||||
fi
|
||||
|
||||
# 7. 语言环境缓存/依赖
|
||||
check_dir_size "Yarn 缓存" "$HOME/Library/Caches/Yarn" "Yarn 包管理器缓存。"
|
||||
check_dir_size "npm 缓存" "$HOME/.npm" "npm 包管理器缓存。"
|
||||
check_dir_size "Maven 缓存" "$HOME/.m2/repository" "Maven 仓库,虽然不是垃圾,但如果很久不用,可能占用大量空间。"
|
||||
check_dir_size "Gradle 缓存" "$HOME/.gradle/caches" "Gradle 构建缓存。"
|
||||
check_dir_size "CocoaPods 缓存" "$HOME/Library/Caches/CocoaPods" "CocoaPods 依赖缓存。"
|
||||
|
||||
# 8. Docker (如果运行)
|
||||
if command -v docker &> /dev/null; then
|
||||
echo -e "${YELLOW}[Docker 未使用资源]${NC}"
|
||||
echo -e " 说明: 停止的容器、未使用的镜像和网络。"
|
||||
echo -e " 建议执行命令: ${GREEN}docker system df${NC} 查看详情"
|
||||
# docker system df 可能需要 docker 正在运行
|
||||
if docker info &> /dev/null; then
|
||||
docker system df
|
||||
else
|
||||
echo " (Docker 服务未运行,无法获取大小)"
|
||||
fi
|
||||
echo "------------------------------------------------------------"
|
||||
fi
|
||||
|
||||
# 9. 下载文件夹 (提醒)
|
||||
check_dir_size "下载文件夹" "$HOME/Downloads" "下载的文件,通常包含很多不再需要的安装包和临时文件。"
|
||||
|
||||
echo ""
|
||||
echo "============================================================"
|
||||
echo "建议清理方式:"
|
||||
echo "1. 使用 'rm -rf <路径>' 删除特定目录内容(请务必小心确认路径)。"
|
||||
echo "2. 对于 Homebrew,使用 'brew cleanup'。"
|
||||
echo "3. 对于 Docker,使用 'docker system prune'。"
|
||||
echo "4. 对于 Xcode,可以直接删除 DerivedData 目录。"
|
||||
echo "5. 推荐使用专门的清理工具(如 CleanMyMac 或腾讯柠檬清理)进行更安全的清理。"
|
||||
echo "============================================================"
|
||||
70
configs/agent/skills/battle-plan/scripts/get_system_info.sh
Normal file
70
configs/agent/skills/battle-plan/scripts/get_system_info.sh
Normal file
@@ -0,0 +1,70 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 脚本名称: get_system_info.sh
|
||||
# 描述: 获取 macOS 系统配置信息的脚本
|
||||
# 作者: Trae AI
|
||||
|
||||
echo "================================================"
|
||||
echo " 系统配置信息概览"
|
||||
echo "================================================"
|
||||
|
||||
# 1. 主机名
|
||||
echo "【主机信息】"
|
||||
echo " 主机名 : $(hostname)"
|
||||
echo " 用户名 : $(whoami)"
|
||||
echo ""
|
||||
|
||||
# 2. 操作系统版本
|
||||
echo "【操作系统】"
|
||||
PRODUCT_NAME=$(sw_vers -productName)
|
||||
PRODUCT_VERSION=$(sw_vers -productVersion)
|
||||
BUILD_VERSION=$(sw_vers -buildVersion)
|
||||
echo " 系统名称 : $PRODUCT_NAME"
|
||||
echo " 系统版本 : $PRODUCT_VERSION (Build $BUILD_VERSION)"
|
||||
# 获取内核版本
|
||||
echo " 内核版本 : $(uname -r)"
|
||||
echo ""
|
||||
|
||||
# 3. CPU 信息
|
||||
echo "【CPU 信息】"
|
||||
CPU_BRAND=$(sysctl -n machdep.cpu.brand_string)
|
||||
PHY_CORES=$(sysctl -n hw.physicalcpu)
|
||||
LOG_CORES=$(sysctl -n hw.logicalcpu)
|
||||
echo " 型号 : $CPU_BRAND"
|
||||
echo " 物理核心 : $PHY_CORES"
|
||||
echo " 逻辑核心 : $LOG_CORES"
|
||||
# 尝试获取架构 (e.g. x86_64 or arm64)
|
||||
ARCH=$(uname -m)
|
||||
echo " 架构 : $ARCH"
|
||||
echo ""
|
||||
|
||||
# 4. 内存信息
|
||||
echo "【内存信息】"
|
||||
MEM_BYTES=$(sysctl -n hw.memsize)
|
||||
MEM_GB=$(echo "scale=2; $MEM_BYTES / 1024 / 1024 / 1024" | bc)
|
||||
echo " 总内存 : ${MEM_GB} GB"
|
||||
echo ""
|
||||
|
||||
# 5. 磁盘使用情况 (根目录)
|
||||
echo "【磁盘信息 (根目录)】"
|
||||
# 使用 df -h 获取根目录信息,并格式化输出
|
||||
df -h / | awk 'NR==2 {printf " 总容量 : %s\n 已用 : %s\n 可用 : %s\n 使用率 : %s\n", $2, $3, $4, $5}'
|
||||
echo ""
|
||||
|
||||
# 6. 网络信息
|
||||
echo "【网络信息】"
|
||||
# 获取默认接口的 IP (通常是 en0 Wi-Fi 或 en1)
|
||||
IP_ADDR=$(ipconfig getifaddr en0)
|
||||
if [ -z "$IP_ADDR" ]; then
|
||||
IP_ADDR=$(ipconfig getifaddr en1)
|
||||
fi
|
||||
|
||||
if [ -z "$IP_ADDR" ]; then
|
||||
echo " IP 地址 : 未连接或无法获取"
|
||||
else
|
||||
echo " IP 地址 : $IP_ADDR"
|
||||
fi
|
||||
echo ""
|
||||
|
||||
echo "================================================"
|
||||
echo "信息获取完成。"
|
||||
30
configs/agent/skills/pdf/LICENSE.txt
Normal file
30
configs/agent/skills/pdf/LICENSE.txt
Normal file
@@ -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.
|
||||
294
configs/agent/skills/pdf/SKILL.md
Normal file
294
configs/agent/skills/pdf/SKILL.md
Normal file
@@ -0,0 +1,294 @@
|
||||
---
|
||||
name: pdf
|
||||
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.
|
||||
license: Proprietary. LICENSE.txt has complete terms
|
||||
---
|
||||
|
||||
# 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
|
||||
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("document.pdf") as pdf:
|
||||
for page in pdf.pages:
|
||||
text = page.extract_text()
|
||||
print(text)
|
||||
```
|
||||
|
||||
#### Extract Tables
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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)
|
||||
```bash
|
||||
# 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-5
|
||||
```
|
||||
|
||||
### qpdf
|
||||
```bash
|
||||
# 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.pdf
|
||||
```
|
||||
|
||||
### pdftk (if available)
|
||||
```bash
|
||||
# Merge
|
||||
pdftk file1.pdf file2.pdf cat output merged.pdf
|
||||
|
||||
# Split
|
||||
pdftk input.pdf burst
|
||||
|
||||
# Rotate
|
||||
pdftk input.pdf rotate 1east output rotated.pdf
|
||||
```
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Extract Text from Scanned PDFs
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
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
|
||||
```bash
|
||||
# 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
|
||||
```python
|
||||
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
|
||||
205
configs/agent/skills/pdf/forms.md
Normal file
205
configs/agent/skills/pdf/forms.md
Normal file
@@ -0,0 +1,205 @@
|
||||
**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.json` file 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.py` script 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*
|
||||
```
|
||||
_________________________
|
||||
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 fields.json and validation images (REQUIRED)
|
||||
- Create a file named `fields.json` with 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.py` script (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>
|
||||
612
configs/agent/skills/pdf/reference.md
Normal file
612
configs/agent/skills/pdf/reference.md
Normal file
@@ -0,0 +1,612 @@
|
||||
# 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
|
||||
```python
|
||||
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
|
||||
```python
|
||||
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
|
||||
```javascript
|
||||
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
|
||||
```javascript
|
||||
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);
|
||||
}
|
||||
```
|
||||
|
||||
#### Advanced Merge and Split Operations
|
||||
```javascript
|
||||
import { PDFDocument } from 'pdf-lib';
|
||||
import fs from 'fs';
|
||||
|
||||
async function mergePDFs() {
|
||||
// Create new document
|
||||
const mergedPdf = await PDFDocument.create();
|
||||
|
||||
// Load source PDFs
|
||||
const pdf1Bytes = fs.readFileSync('doc1.pdf');
|
||||
const pdf2Bytes = fs.readFileSync('doc2.pdf');
|
||||
|
||||
const pdf1 = await PDFDocument.load(pdf1Bytes);
|
||||
const pdf2 = await PDFDocument.load(pdf2Bytes);
|
||||
|
||||
// Copy pages from first PDF
|
||||
const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices());
|
||||
pdf1Pages.forEach(page => mergedPdf.addPage(page));
|
||||
|
||||
// Copy specific pages from second PDF (pages 0, 2, 4)
|
||||
const pdf2Pages = await mergedPdf.copyPages(pdf2, [0, 2, 4]);
|
||||
pdf2Pages.forEach(page => mergedPdf.addPage(page));
|
||||
|
||||
const mergedPdfBytes = await mergedPdf.save();
|
||||
fs.writeFileSync('merged.pdf', mergedPdfBytes);
|
||||
}
|
||||
```
|
||||
|
||||
### pdfjs-dist (Apache License)
|
||||
|
||||
PDF.js is Mozilla's JavaScript library for rendering PDFs in the browser.
|
||||
|
||||
#### Basic PDF Loading and Rendering
|
||||
```javascript
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
// Configure worker (important for performance)
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js';
|
||||
|
||||
async function renderPDF() {
|
||||
// Load PDF
|
||||
const loadingTask = pdfjsLib.getDocument('document.pdf');
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
console.log(`Loaded PDF with ${pdf.numPages} pages`);
|
||||
|
||||
// Get first page
|
||||
const page = await pdf.getPage(1);
|
||||
const viewport = page.getViewport({ scale: 1.5 });
|
||||
|
||||
// Render to canvas
|
||||
const canvas = document.createElement('canvas');
|
||||
const context = canvas.getContext('2d');
|
||||
canvas.height = viewport.height;
|
||||
canvas.width = viewport.width;
|
||||
|
||||
const renderContext = {
|
||||
canvasContext: context,
|
||||
viewport: viewport
|
||||
};
|
||||
|
||||
await page.render(renderContext).promise;
|
||||
document.body.appendChild(canvas);
|
||||
}
|
||||
```
|
||||
|
||||
#### Extract Text with Coordinates
|
||||
```javascript
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
async function extractText() {
|
||||
const loadingTask = pdfjsLib.getDocument('document.pdf');
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
let fullText = '';
|
||||
|
||||
// Extract text from all pages
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const textContent = await page.getTextContent();
|
||||
|
||||
const pageText = textContent.items
|
||||
.map(item => item.str)
|
||||
.join(' ');
|
||||
|
||||
fullText += `\n--- Page ${i} ---\n${pageText}`;
|
||||
|
||||
// Get text with coordinates for advanced processing
|
||||
const textWithCoords = textContent.items.map(item => ({
|
||||
text: item.str,
|
||||
x: item.transform[4],
|
||||
y: item.transform[5],
|
||||
width: item.width,
|
||||
height: item.height
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(fullText);
|
||||
return fullText;
|
||||
}
|
||||
```
|
||||
|
||||
#### Extract Annotations and Forms
|
||||
```javascript
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
|
||||
async function extractAnnotations() {
|
||||
const loadingTask = pdfjsLib.getDocument('annotated.pdf');
|
||||
const pdf = await loadingTask.promise;
|
||||
|
||||
for (let i = 1; i <= pdf.numPages; i++) {
|
||||
const page = await pdf.getPage(i);
|
||||
const annotations = await page.getAnnotations();
|
||||
|
||||
annotations.forEach(annotation => {
|
||||
console.log(`Annotation type: ${annotation.subtype}`);
|
||||
console.log(`Content: ${annotation.contents}`);
|
||||
console.log(`Coordinates: ${JSON.stringify(annotation.rect)}`);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Advanced Command-Line Operations
|
||||
|
||||
### poppler-utils Advanced Features
|
||||
|
||||
#### Extract Text with Bounding Box Coordinates
|
||||
```bash
|
||||
# Extract text with bounding box coordinates (essential for structured data)
|
||||
pdftotext -bbox-layout document.pdf output.xml
|
||||
|
||||
# The XML output contains precise coordinates for each text element
|
||||
```
|
||||
|
||||
#### Advanced Image Conversion
|
||||
```bash
|
||||
# Convert to PNG images with specific resolution
|
||||
pdftoppm -png -r 300 document.pdf output_prefix
|
||||
|
||||
# Convert specific page range with high resolution
|
||||
pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages
|
||||
|
||||
# Convert to JPEG with quality setting
|
||||
pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output
|
||||
```
|
||||
|
||||
#### Extract Embedded Images
|
||||
```bash
|
||||
# Extract all embedded images with metadata
|
||||
pdfimages -j -p document.pdf page_images
|
||||
|
||||
# List image info without extracting
|
||||
pdfimages -list document.pdf
|
||||
|
||||
# Extract images in their original format
|
||||
pdfimages -all document.pdf images/img
|
||||
```
|
||||
|
||||
### qpdf Advanced Features
|
||||
|
||||
#### Complex Page Manipulation
|
||||
```bash
|
||||
# Split PDF into groups of pages
|
||||
qpdf --split-pages=3 input.pdf output_group_%02d.pdf
|
||||
|
||||
# Extract specific pages with complex ranges
|
||||
qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf
|
||||
|
||||
# Merge specific pages from multiple PDFs
|
||||
qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf
|
||||
```
|
||||
|
||||
#### PDF Optimization and Repair
|
||||
```bash
|
||||
# Optimize PDF for web (linearize for streaming)
|
||||
qpdf --linearize input.pdf optimized.pdf
|
||||
|
||||
# Remove unused objects and compress
|
||||
qpdf --optimize-level=all input.pdf compressed.pdf
|
||||
|
||||
# Attempt to repair corrupted PDF structure
|
||||
qpdf --check input.pdf
|
||||
qpdf --fix-qdf damaged.pdf repaired.pdf
|
||||
|
||||
# Show detailed PDF structure for debugging
|
||||
qpdf --show-all-pages input.pdf > structure.txt
|
||||
```
|
||||
|
||||
#### Advanced Encryption
|
||||
```bash
|
||||
# Add password protection with specific permissions
|
||||
qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf
|
||||
|
||||
# Check encryption status
|
||||
qpdf --show-encryption encrypted.pdf
|
||||
|
||||
# Remove password protection (requires password)
|
||||
qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf
|
||||
```
|
||||
|
||||
## Advanced Python Techniques
|
||||
|
||||
### pdfplumber Advanced Features
|
||||
|
||||
#### Extract Text with Precise Coordinates
|
||||
```python
|
||||
import pdfplumber
|
||||
|
||||
with pdfplumber.open("document.pdf") as pdf:
|
||||
page = pdf.pages[0]
|
||||
|
||||
# Extract all text with coordinates
|
||||
chars = page.chars
|
||||
for char in chars[:10]: # First 10 characters
|
||||
print(f"Char: '{char['text']}' at x:{char['x0']:.1f} y:{char['y0']:.1f}")
|
||||
|
||||
# Extract text by bounding box (left, top, right, bottom)
|
||||
bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text()
|
||||
```
|
||||
|
||||
#### Advanced Table Extraction with Custom Settings
|
||||
```python
|
||||
import pdfplumber
|
||||
import pandas as pd
|
||||
|
||||
with pdfplumber.open("complex_table.pdf") as pdf:
|
||||
page = pdf.pages[0]
|
||||
|
||||
# Extract tables with custom settings for complex layouts
|
||||
table_settings = {
|
||||
"vertical_strategy": "lines",
|
||||
"horizontal_strategy": "lines",
|
||||
"snap_tolerance": 3,
|
||||
"intersection_tolerance": 15
|
||||
}
|
||||
tables = page.extract_tables(table_settings)
|
||||
|
||||
# Visual debugging for table extraction
|
||||
img = page.to_image(resolution=150)
|
||||
img.save("debug_layout.png")
|
||||
```
|
||||
|
||||
### reportlab Advanced Features
|
||||
|
||||
#### Create Professional Reports with Tables
|
||||
```python
|
||||
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
|
||||
from reportlab.lib.styles import getSampleStyleSheet
|
||||
from reportlab.lib import colors
|
||||
|
||||
# Sample data
|
||||
data = [
|
||||
['Product', 'Q1', 'Q2', 'Q3', 'Q4'],
|
||||
['Widgets', '120', '135', '142', '158'],
|
||||
['Gadgets', '85', '92', '98', '105']
|
||||
]
|
||||
|
||||
# Create PDF with table
|
||||
doc = SimpleDocTemplate("report.pdf")
|
||||
elements = []
|
||||
|
||||
# Add title
|
||||
styles = getSampleStyleSheet()
|
||||
title = Paragraph("Quarterly Sales Report", styles['Title'])
|
||||
elements.append(title)
|
||||
|
||||
# Add table with advanced styling
|
||||
table = Table(data)
|
||||
table.setStyle(TableStyle([
|
||||
('BACKGROUND', (0, 0), (-1, 0), colors.grey),
|
||||
('TEXTCOLOR', (0, 0), (-1, 0), colors.whitesmoke),
|
||||
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
|
||||
('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'),
|
||||
('FONTSIZE', (0, 0), (-1, 0), 14),
|
||||
('BOTTOMPADDING', (0, 0), (-1, 0), 12),
|
||||
('BACKGROUND', (0, 1), (-1, -1), colors.beige),
|
||||
('GRID', (0, 0), (-1, -1), 1, colors.black)
|
||||
]))
|
||||
elements.append(table)
|
||||
|
||||
doc.build(elements)
|
||||
```
|
||||
|
||||
## Complex Workflows
|
||||
|
||||
### Extract Figures/Images from PDF
|
||||
|
||||
#### Method 1: Using pdfimages (fastest)
|
||||
```bash
|
||||
# Extract all images with original quality
|
||||
pdfimages -all document.pdf images/img
|
||||
```
|
||||
|
||||
#### Method 2: Using pypdfium2 + Image Processing
|
||||
```python
|
||||
import pypdfium2 as pdfium
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
def extract_figures(pdf_path, output_dir):
|
||||
pdf = pdfium.PdfDocument(pdf_path)
|
||||
|
||||
for page_num, page in enumerate(pdf):
|
||||
# Render high-resolution page
|
||||
bitmap = page.render(scale=3.0)
|
||||
img = bitmap.to_pil()
|
||||
|
||||
# Convert to numpy for processing
|
||||
img_array = np.array(img)
|
||||
|
||||
# Simple figure detection (non-white regions)
|
||||
mask = np.any(img_array != [255, 255, 255], axis=2)
|
||||
|
||||
# Find contours and extract bounding boxes
|
||||
# (This is simplified - real implementation would need more sophisticated detection)
|
||||
|
||||
# Save detected figures
|
||||
# ... implementation depends on specific needs
|
||||
```
|
||||
|
||||
### Batch PDF Processing with Error Handling
|
||||
```python
|
||||
import os
|
||||
import glob
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def batch_process_pdfs(input_dir, operation='merge'):
|
||||
pdf_files = glob.glob(os.path.join(input_dir, "*.pdf"))
|
||||
|
||||
if operation == 'merge':
|
||||
writer = PdfWriter()
|
||||
for pdf_file in pdf_files:
|
||||
try:
|
||||
reader = PdfReader(pdf_file)
|
||||
for page in reader.pages:
|
||||
writer.add_page(page)
|
||||
logger.info(f"Processed: {pdf_file}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to process {pdf_file}: {e}")
|
||||
continue
|
||||
|
||||
with open("batch_merged.pdf", "wb") as output:
|
||||
writer.write(output)
|
||||
|
||||
elif operation == 'extract_text':
|
||||
for pdf_file in pdf_files:
|
||||
try:
|
||||
reader = PdfReader(pdf_file)
|
||||
text = ""
|
||||
for page in reader.pages:
|
||||
text += page.extract_text()
|
||||
|
||||
output_file = pdf_file.replace('.pdf', '.txt')
|
||||
with open(output_file, 'w', encoding='utf-8') as f:
|
||||
f.write(text)
|
||||
logger.info(f"Extracted text from: {pdf_file}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to extract text from {pdf_file}: {e}")
|
||||
continue
|
||||
```
|
||||
|
||||
### Advanced PDF Cropping
|
||||
```python
|
||||
from pypdf import PdfWriter, PdfReader
|
||||
|
||||
reader = PdfReader("input.pdf")
|
||||
writer = PdfWriter()
|
||||
|
||||
# Crop page (left, bottom, right, top in points)
|
||||
page = reader.pages[0]
|
||||
page.mediabox.left = 50
|
||||
page.mediabox.bottom = 50
|
||||
page.mediabox.right = 550
|
||||
page.mediabox.top = 750
|
||||
|
||||
writer.add_page(page)
|
||||
with open("cropped.pdf", "wb") as output:
|
||||
writer.write(output)
|
||||
```
|
||||
|
||||
## Performance Optimization Tips
|
||||
|
||||
### 1. For Large PDFs
|
||||
- Use streaming approaches instead of loading entire PDF in memory
|
||||
- Use `qpdf --split-pages` for splitting large files
|
||||
- Process pages individually with pypdfium2
|
||||
|
||||
### 2. For Text Extraction
|
||||
- `pdftotext -bbox-layout` is fastest for plain text extraction
|
||||
- Use pdfplumber for structured data and tables
|
||||
- Avoid `pypdf.extract_text()` for very large documents
|
||||
|
||||
### 3. For Image Extraction
|
||||
- `pdfimages` is 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
|
||||
```python
|
||||
# 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
|
||||
```python
|
||||
# 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
|
||||
```bash
|
||||
# Use qpdf to repair
|
||||
qpdf --check corrupted.pdf
|
||||
qpdf --replace-input corrupted.pdf
|
||||
```
|
||||
|
||||
### Text Extraction Issues
|
||||
```python
|
||||
# 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 text
|
||||
```
|
||||
|
||||
## License 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
|
||||
86
configs/agent/skills/pdf/scripts/check_bounding_boxes.py
Normal file
86
configs/agent/skills/pdf/scripts/check_bounding_boxes.py
Normal file
@@ -0,0 +1,86 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
|
||||
import json
|
||||
|
||||
# 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)
|
||||
224
configs/agent/skills/pdf/scripts/check_bounding_boxes_test.py
Normal file
224
configs/agent/skills/pdf/scripts/check_bounding_boxes_test.py
Normal file
@@ -0,0 +1,224 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
import io
|
||||
import unittest
|
||||
|
||||
import json
|
||||
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()
|
||||
15
configs/agent/skills/pdf/scripts/check_fillable_fields.py
Normal file
15
configs/agent/skills/pdf/scripts/check_fillable_fields.py
Normal file
@@ -0,0 +1,15 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
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'
|
||||
)
|
||||
36
configs/agent/skills/pdf/scripts/convert_pdf_to_images.py
Normal file
36
configs/agent/skills/pdf/scripts/convert_pdf_to_images.py
Normal file
@@ -0,0 +1,36 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
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)
|
||||
48
configs/agent/skills/pdf/scripts/create_validation_image.py
Normal file
48
configs/agent/skills/pdf/scripts/create_validation_image.py
Normal file
@@ -0,0 +1,48 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
import sys
|
||||
|
||||
import json
|
||||
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)
|
||||
160
configs/agent/skills/pdf/scripts/extract_form_field_info.py
Normal file
160
configs/agent/skills/pdf/scripts/extract_form_field_info.py
Normal file
@@ -0,0 +1,160 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
import sys
|
||||
|
||||
import json
|
||||
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])
|
||||
122
configs/agent/skills/pdf/scripts/fill_fillable_fields.py
Normal file
122
configs/agent/skills/pdf/scripts/fill_fillable_fields.py
Normal file
@@ -0,0 +1,122 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
import sys
|
||||
|
||||
import json
|
||||
from extract_form_field_info import get_field_info
|
||||
from pypdf import PdfReader, PdfWriter
|
||||
|
||||
# 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)
|
||||
@@ -0,0 +1,111 @@
|
||||
# flake8: noqa
|
||||
# yapf: disable
|
||||
import sys
|
||||
|
||||
import json
|
||||
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)
|
||||
21
configs/application.yaml
Normal file
21
configs/application.yaml
Normal file
@@ -0,0 +1,21 @@
|
||||
app:
|
||||
name: ai-agent-scaffold-go
|
||||
env: local
|
||||
server:
|
||||
addr: ":8091"
|
||||
database:
|
||||
required: false
|
||||
dsn: "root:123456@tcp(127.0.0.1:13306)/ai-agent-scaffold-go?charset=utf8mb4&parseTime=True&loc=Local"
|
||||
redis:
|
||||
required: false
|
||||
addr: "127.0.0.1:16379"
|
||||
password: ""
|
||||
db: 0
|
||||
llm:
|
||||
# OpenAI 兼容上游每次请求的整体超时;画图等多步 Agent 流程建议放宽
|
||||
# 支持 Go time.ParseDuration 格式:30s / 2m / 5m / 1h,留空走默认 5m
|
||||
request-timeout: 5m
|
||||
agent:
|
||||
config-paths:
|
||||
- configs/agent/only-one-agent.yaml
|
||||
- configs/agent/agent-draw-io.yaml
|
||||
Reference in New Issue
Block a user