commit 03e5c506ac003e1a279b512d423844bca8f0aa38 Author: wehub-skill-sync Date: Mon Jul 13 21:35:39 2026 +0800 chore: import zh skill pdf diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..4ab7fd5 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,10 @@ +- 从服务中提取这些材料或在服务之外保留这些材料的副本 +- 复制或仿制这些材料,但在授权使用服务期间自动创建的临时副本除外 +- 基于这些材料创作衍生作品 +- 向任何第三方分发、再许可或转让这些材料 +- 制造、许诺销售、销售或进口体现在这些材料中的任何发明 +- 对这些材料进行逆向工程、反编译或反汇编 + +接收、查看或持有这些材料并不授予或暗示超出上述明确授予范围的任何许可或权利。 + +Anthropic 保留对这些材料的全部权利、所有权和利益,包括所有版权、专利及其他知识产权。 diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..f12aeff --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,9 @@ +# WeHub 来源说明 + +- Skill 名称:`pdf` +- 中文类目:PDF 通用读写、表单与 OCR +- 上游仓库:`anthropics__skills` +- 上游路径:`skills/pdf/SKILL.md` +- 上游链接:https://github.com/anthropics/skills/blob/HEAD/skills/pdf/SKILL.md +- 本仓库为 WeHub 中文 Skill 汉化包,基于 skill 市场筛选 Top200 清单整理 +- 原作者、版权和许可证信息以上游仓库为准 diff --git a/SKILL.md b/SKILL.md new file mode 100644 index 0000000..4409d70 --- /dev/null +++ b/SKILL.md @@ -0,0 +1,314 @@ +--- +name: pdf +description: 当用户想要对 PDF 文件进行任何操作时,请使用本技能。这包括:从 PDF 中读取或提取文本/表格、将多个 PDF 合并为一个、拆分 PDF、旋转页面、添加水印、创建新 PDF、填写 PDF 表单、加密/解密 PDF、提取图片,以及对扫描版 PDF 进行 OCR 以使其可搜索。如果用户提到 .pdf 文件或要求生成 PDF 文件,请使用本技能。 +license: 专有。完整条款见 LICENSE.txt +--- + +# PDF 处理指南 + +## 概述 + +本指南涵盖使用 Python 库和命令行工具进行的基本 PDF 处理操作。有关高级功能、JavaScript 库及详细示例,请参阅 REFERENCE.md。如果需要填写 PDF 表单,请阅读 FORMS.md 并遵循其中的说明。 + +## 快速入门 + +```python +from pypdf import PdfReader, PdfWriter + +# 读取 PDF +reader = PdfReader("document.pdf") +print(f"页数: {len(reader.pages)}") + +# 提取文本 +text = "" +for page in reader.pages: + text += page.extract_text() +``` + +## Python 库 + +### pypdf - 基本操作 + +#### 合并 PDF +```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) +``` + +#### 拆分 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) +``` + +#### 提取元数据 +```python +reader = PdfReader("document.pdf") +meta = reader.metadata +print(f"标题: {meta.title}") +print(f"作者: {meta.author}") +print(f"主题: {meta.subject}") +print(f"创建程序: {meta.creator}") +``` + +#### 旋转页面 +```python +reader = PdfReader("input.pdf") +writer = PdfWriter() + +page = reader.pages[0] +page.rotate(90) # 顺时针旋转 90 度 +writer.add_page(page) + +with open("rotated.pdf", "wb") as output: + writer.write(output) +``` + +### pdfplumber - 文本与表格提取 + +#### 保留布局提取文本 +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + for page in pdf.pages: + text = page.extract_text() + print(text) +``` + +#### 提取表格 +```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"第 {i+1} 页的表格 {j+1}:") + for row in table: + print(row) +``` + +#### 高级表格提取 +```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: # 检查表格是否非空 + df = pd.DataFrame(table[1:], columns=table[0]) + all_tables.append(df) + +# 合并所有表格 +if all_tables: + combined_df = pd.concat(all_tables, ignore_index=True) + combined_df.to_excel("extracted_tables.xlsx", index=False) +``` + +### reportlab - 创建 PDF + +#### 基本 PDF 创建 +```python +from reportlab.lib.pagesizes import letter +from reportlab.pdfgen import canvas + +c = canvas.Canvas("hello.pdf", pagesize=letter) +width, height = letter + +# 添加文本 +c.drawString(100, height - 100, "Hello World!") +c.drawString(100, height - 120, "This is a PDF created with reportlab") + +# 添加线条 +c.line(100, height - 140, 400, height - 140) + +# 保存 +c.save() +``` + +#### 创建多页 PDF +```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 = [] + +# 添加内容 +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()) + +# 第 2 页 +story.append(Paragraph("Page 2", styles['Heading1'])) +story.append(Paragraph("Content for page 2", styles['Normal'])) + +# 构建 PDF +doc.build(story) +``` + +#### 下标与上标 + +**重要**:切勿在 ReportLab PDF 中使用 Unicode 下标/上标字符(₀₁₂₃₄₅₆₇₈₉、⁰¹²³⁴⁵⁶⁷⁸⁹)。内置字体不包含这些字形,会导致其渲染为实心黑色方块。 + +请改用 ReportLab 的 XML 标记标签,配合 Paragraph 对象使用: +```python +from reportlab.platypus import Paragraph +from reportlab.lib.styles import getSampleStyleSheet + +styles = getSampleStyleSheet() + +# 下标:使用 标签 +chemical = Paragraph("H2O", styles['Normal']) + +# 上标:使用 标签 +squared = Paragraph("x2 + y2", styles['Normal']) +``` + +对于 canvas 绘制的文本(非 Paragraph 对象),请手动调整字号和位置,不要使用 Unicode 下标/上标字符。 + +## 命令行工具 + +### pdftotext(poppler-utils) +```bash +# 提取文本 +pdftotext input.pdf output.txt + +# 保留布局提取文本 +pdftotext -layout input.pdf output.txt + +# 提取指定页面 +pdftotext -f 1 -l 5 input.pdf output.txt # 第 1–5 页 +``` + +### qpdf +```bash +# 合并 PDF +qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf + +# 拆分页面 +qpdf input.pdf --pages . 1-5 -- pages1-5.pdf +qpdf input.pdf --pages . 6-10 -- pages6-10.pdf + +# 旋转页面 +qpdf input.pdf output.pdf --rotate=+90:1 # 将第 1 页旋转 90 度 + +# 移除密码 +qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdf +``` + +### pdftk(如可用) +```bash +# 合并 +pdftk file1.pdf file2.pdf cat output merged.pdf + +# 拆分 +pdftk input.pdf burst + +# 旋转 +pdftk input.pdf rotate 1east output rotated.pdf +``` + +## 常见任务 + +### 从扫描版 PDF 提取文本 +```python +# 需要安装:pip install pytesseract pdf2image +import pytesseract +from pdf2image import convert_from_path + +# 将 PDF 转换为图片 +images = convert_from_path('scanned.pdf') + +# 对每页进行 OCR +text = "" +for i, image in enumerate(images): + text += f"第 {i+1} 页:\n" + text += pytesseract.image_to_string(image) + text += "\n\n" + +print(text) +``` + +### 添加水印 +```python +from pypdf import PdfReader, PdfWriter + +# 创建水印(或加载已有水印) +watermark = PdfReader("watermark.pdf").pages[0] + +# 应用到所有页面 +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) +``` + +### 提取图片 +```bash +# 使用 pdfimages(poppler-utils) +pdfimages -j input.pdf output_prefix + +# 这会将所有图片提取为 output_prefix-000.jpg、output_prefix-001.jpg 等 +``` + +### 密码保护 +```python +from pypdf import PdfReader, PdfWriter + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +for page in reader.pages: + writer.add_page(page) + +# 添加密码 +writer.encrypt("userpassword", "ownerpassword") + +with open("encrypted.pdf", "wb") as output: + writer.write(output) +``` + +## 快速参考 + +| 任务 | 最佳工具 | 命令/代码 | +|------|----------|-----------| +| 合并 PDF | pypdf | `writer.add_page(page)` | +| 拆分 PDF | pypdf | 每页一个文件 | +| 提取文本 | pdfplumber | `page.extract_text()` | +| 提取表格 | pdfplumber | `page.extract_tables()` | +| 创建 PDF | reportlab | Canvas 或 Platypus | +| 命令行合并 | qpdf | `qpdf --empty --pages ...` | +| 扫描版 PDF 的 OCR | pytesseract | 先转换为图片 | +| 填写 PDF 表单 | pdf-lib 或 pypdf(见 FORMS.md)| 见 FORMS.md | + +## 下一步 + +- 有关高级 pypdfium2 用法,请参阅 REFERENCE.md +- 有关 JavaScript 库(pdf-lib),请参阅 REFERENCE.md +- 如果需要填写 PDF 表单,请遵循 FORMS.md 中的说明 +- 有关故障排除指南,请参阅 REFERENCE.md diff --git a/forms.md b/forms.md new file mode 100644 index 0000000..06aba0e --- /dev/null +++ b/forms.md @@ -0,0 +1,291 @@ +# 可填写字段 + +如果 PDF 含有可填写的表单字段: +- 在此文件所在目录下运行以下脚本:`python scripts/extract_form_field_info.py `。该脚本会生成一个 JSON 文件,其中包含字段列表,格式如下: +``` +[ + { + "field_id": (字段的唯一 ID), + "page": (页码,从 1 开始), + "rect": ([左, 下, 右, 上] 边界框,使用 PDF 坐标,y=0 为页面底部), + "type": ("text"(文本)、"checkbox"(复选框)、"radio_group"(单选组)或 "choice"(下拉选择)), + }, + // 复选框包含 "checked_value" 和 "unchecked_value" 属性: + { + "field_id": (字段的唯一 ID), + "page": (页码,从 1 开始), + "type": "checkbox", + "checked_value": (将该字段设为此值即可勾选复选框), + "unchecked_value": (将该字段设为此值即可取消勾选复选框), + }, + // 单选组包含一个 "radio_options" 列表,列出所有可选选项: + { + "field_id": (字段的唯一 ID), + "page": (页码,从 1 开始), + "type": "radio_group", + "radio_options": [ + { + "value": (将该字段设为此值即可选择该单选选项), + "rect": (该单选按钮的边界框) + }, + // 其他单选选项 + ] + }, + // 下拉选择字段包含一个 "choice_options" 列表,列出所有可选选项: + { + "field_id": (字段的唯一 ID), + "page": (页码,从 1 开始), + "type": "choice", + "choice_options": [ + { + "value": (将该字段设为此值即可选择该选项), + "text": (选项的显示文本) + }, + // 其他下拉选项 + ], + } +] +``` +- 使用以下脚本将 PDF 转换为 PNG 图片(每页一张)(在此文件所在目录下运行): +`python scripts/convert_pdf_to_images.py ` +然后分析图片,确定每个表单字段的用途(注意将边界框的 PDF 坐标转换为图片坐标)。 +- 创建一个 `field_values.json` 文件,格式如下,包含每个字段要填写的值: +``` +[ + { + "field_id": "last_name", // 必须与 `extract_form_field_info.py` 中的 field_id 一致 + "description": "用户的姓氏", + "page": 1, // 必须与 field_info.json 中的 "page" 值一致 + "value": "Simpson" + }, + { + "field_id": "Checkbox12", + "description": "若用户年满 18 岁则勾选的复选框", + "page": 1, + "value": "/On" // 如果是复选框,使用其 "checked_value" 进行勾选;如果是单选按钮组,使用 "radio_options" 中的某个 "value" 值 + }, + // 更多字段 +] +``` +- 在此文件所在目录下运行 `fill_fillable_fields.py` 脚本,生成已填写的 PDF: +`python scripts/fill_fillable_fields.py ` +该脚本会验证你提供的字段 ID 和值是否有效;如果打印了错误信息,请修正相应字段后重试。 + +# 非可填写字段 + +如果 PDF 没有可填写的表单字段,你需要添加文本注释。首先尝试从 PDF 结构中提取坐标(更精确),如有需要再退回到视觉估算。 + +## 第一步:优先尝试结构提取 + +运行以下脚本,提取文本标签、线条和复选框及其精确的 PDF 坐标: +`python scripts/extract_form_structure.py form_structure.json` + +这会生成一个 JSON 文件,包含: +- **labels**(标签):每个文本元素及其精确坐标(以 PDF 点为单位:x0, top, x1, bottom) +- **lines**(线条):定义行边界的水平线 +- **checkboxes**(复选框):作为复选框的小正方形矩形(包含中心坐标) +- **row_boundaries**(行边界):根据水平线计算出的行顶部/底部位置 + +**检查结果**:如果 `form_structure.json` 包含有意义的标签(与表单字段对应的文本元素),则使用 **方法 A:基于结构的坐标**。如果 PDF 是扫描件或基于图片,几乎没有标签,则使用 **方法 B:视觉估算**。 + +--- + +## 方法 A:基于结构的坐标(推荐) + +当 `extract_form_structure.py` 在 PDF 中找到了文本标签时使用此方法。 + +### A.1:分析结构 + +读取 form_structure.json 并识别: + +1. **标签组**:相邻的文本元素构成一个完整的标签(例如,"Last" + "Name") +2. **行结构**:具有相近 `top` 值的标签位于同一行 +3. **字段列**:填写区域从标签结束之后开始(x0 = 标签.x1 + 间距) +4. **复选框**:直接从结构中获取复选框坐标 + +**坐标系统**:PDF 坐标中 y=0 位于页面顶部,y 值向下递增。 + +### A.2:检查缺失元素 + +结构提取可能无法检测到所有表单元素。常见情况: +- **圆形复选框**:只有正方形矩形会被检测为复选框 +- **复杂图形**:装饰性元素或非标准表单控件 +- **褪色或浅色元素**:可能无法被提取 + +如果你在 PDF 图片中看到了 form_structure.json 中未包含的表单字段,你需要对这些特定字段使用 **视觉分析**(见下方的「混合方法」)。 + +### A.3:使用 PDF 坐标创建 fields.json + +对于每个字段,根据提取的结构计算填写坐标: + +**文本字段:** +- 填写区域 x0 = 标签 x1 + 5(标签后留出小间距) +- 填写区域 x1 = 下一个标签的 x0,或行边界 +- 填写区域 top = 与标签 top 相同 +- 填写区域 bottom = 下方行边界线,或标签 bottom + 行高 + +**复选框:** +- 直接使用 form_structure.json 中的复选框矩形坐标 +- entry_bounding_box = [checkbox.x0, checkbox.top, checkbox.x1, checkbox.bottom] + +使用 `pdf_width` 和 `pdf_height`(表示 PDF 坐标)创建 fields.json: +```json +{ + "pages": [ + {"page_number": 1, "pdf_width": 612, "pdf_height": 792} + ], + "form_fields": [ + { + "page_number": 1, + "description": "姓氏填写字段", + "field_label": "Last Name", + "label_bounding_box": [43, 63, 87, 73], + "entry_bounding_box": [92, 63, 260, 79], + "entry_text": {"text": "Smith", "font_size": 10} + }, + { + "page_number": 1, + "description": "美国公民「是」复选框", + "field_label": "Yes", + "label_bounding_box": [260, 200, 280, 210], + "entry_bounding_box": [285, 197, 292, 205], + "entry_text": {"text": "X"} + } + ] +} +``` + +**重要**:使用 form_structure.json 中的 `pdf_width`/`pdf_height` 及坐标。 + +### A.4:验证边界框 + +在填写之前,检查你的边界框是否有错误: +`python scripts/check_bounding_boxes.py fields.json` + +该脚本会检查边界框是否存在相交,以及填写区域是否因字号过大而空间不足。修复所有报告的错误后再进行填写。 + +--- + +## 方法 B:视觉估算(备用方法) + +当 PDF 是扫描件或基于图片,结构提取未能找到可用的文本标签(例如所有文本都显示为 "(cid:X)" 模式)时使用此方法。 + +### B.1:将 PDF 转换为图片 + +`python scripts/convert_pdf_to_images.py ` + +### B.2:初步字段识别 + +检查每页图片,识别表单区域并获取字段位置的**大致估算**: +- 表单字段标签及其大致位置 +- 填写区域(用于文本输入的线条、方框或空白区域) +- 复选框及其大致位置 + +对于每个字段,记下近似的像素坐标(暂不需要精确)。 + +### B.3:缩放精确定位(对精度至关重要) + +对于每个字段,围绕估算位置裁剪出一个区域,以精确确定坐标。 + +**使用 ImageMagick 创建缩放裁剪图:** +```bash +magick -crop x++ +repage +``` + +其中: +- `, ` = 裁剪区域左上角(使用你的大致估算值减去边距) +- `, ` = 裁剪区域的大小(字段区域加上每边约 50px 的边距) + +**示例:** 精确定位大约在 (100, 150) 位置的「姓名」字段: +```bash +magick images_dir/page_1.png -crop 300x80+50+120 +repage crops/name_field.png +``` + +(注意:如果 `magick` 命令不可用,可尝试使用相同参数的 `convert`。) + +**检查裁剪后的图片**,确定精确坐标: +1. 确定填写区域开始的精确像素位置(标签之后) +2. 确定填写区域结束的位置(下一个字段或边缘之前) +3. 确定填写线条/方框的顶部和底部位置 + +**将裁剪坐标转换回完整图片坐标:** +- full_x = crop_x + crop_offset_x +- full_y = crop_y + crop_offset_y + +示例:如果裁剪从 (50, 120) 开始,填写方框在裁剪图内的坐标为 (52, 18): +- entry_x0 = 52 + 50 = 102 +- entry_top = 18 + 120 = 138 + +**对每个字段重复上述步骤**,尽可能将相邻字段合并到同一次裁剪中。 + +### B.4:使用精确定位坐标创建 fields.json + +使用 `image_width` 和 `image_height`(表示图片坐标)创建 fields.json: +```json +{ + "pages": [ + {"page_number": 1, "image_width": 1700, "image_height": 2200} + ], + "form_fields": [ + { + "page_number": 1, + "description": "姓氏填写字段", + "field_label": "Last Name", + "label_bounding_box": [120, 175, 242, 198], + "entry_bounding_box": [255, 175, 720, 218], + "entry_text": {"text": "Smith", "font_size": 10} + } + ] +} +``` + +**重要**:使用 `image_width`/`image_height` 以及缩放分析中得到的精确像素坐标。 + +### B.5:验证边界框 + +在填写之前,检查你的边界框是否有错误: +`python scripts/check_bounding_boxes.py fields.json` + +该脚本会检查边界框是否存在相交,以及填写区域是否因字号过大而空间不足。修复所有报告的错误后再进行填写。 + +--- + +## 混合方法:结构 + 视觉 + +当结构提取对大多数字段有效,但遗漏了部分元素(如圆形复选框、非标准表单控件)时使用此方法。 + +1. 对 form_structure.json 中检测到的字段使用**方法 A** +2. **将 PDF 转换为图片**,用于对缺失字段进行视觉分析 +3. 对缺失字段使用**缩放精确定位**(来自方法 B) +4. **合并坐标**:对于从结构提取获得的字段,使用 `pdf_width`/`pdf_height`。对于视觉估算的字段,需要将图片坐标转换为 PDF 坐标: + - pdf_x = image_x * (pdf_width / image_width) + - pdf_y = image_y * (pdf_height / image_height) +5. 在 fields.json 中使用**统一的坐标系统**——将所有坐标转换为 PDF 坐标,配合 `pdf_width`/`pdf_height` + +--- + +## 第二步:填写前验证 + +**在填写之前务必验证边界框:** +`python scripts/check_bounding_boxes.py fields.json` + +该脚本检查: +- 边界框是否相交(会导致文本重叠) +- 填写区域是否因指定字号过小而空间不足 + +修复 fields.json 中报告的所有错误后再继续。 + +## 第三步:填写表单 + +填写脚本会自动检测坐标系统并处理转换: +`python scripts/fill_pdf_form_with_annotations.py fields.json ` + +## 第四步:验证输出 + +将填写后的 PDF 转换为图片,验证文本位置: +`python scripts/convert_pdf_to_images.py ` + +如果文本位置不正确: +- **方法 A**:确认你使用了 form_structure.json 中的 PDF 坐标并配合 `pdf_width`/`pdf_height` +- **方法 B**:确认图片尺寸匹配且坐标是精确的像素值 +- **混合方法**:确保视觉估算字段的坐标转换正确 diff --git a/reference.md b/reference.md new file mode 100644 index 0000000..82ac766 --- /dev/null +++ b/reference.md @@ -0,0 +1,612 @@ +# PDF 处理高级参考 + +本文档包含主要技能说明中未涵盖的高级 PDF 处理功能、详细示例和附加库。 + +## pypdfium2 库(Apache/BSD 许可证) + +### 概述 +pypdfium2 是 PDFium(Chromium 的 PDF 库)的 Python 绑定。它非常适合快速 PDF 渲染、图片生成,并且可以作为 PyMuPDF 的替代方案。 + +### 将 PDF 渲染为图片 +```python +import pypdfium2 as pdfium +from PIL import Image + +# 加载 PDF +pdf = pdfium.PdfDocument("document.pdf") + +# 将页面渲染为图片 +page = pdf[0] # 第一页 +bitmap = page.render( + scale=2.0, # 更高分辨率 + rotation=0 # 不旋转 +) + +# 转换为 PIL Image +img = bitmap.to_pil() +img.save("page_1.png", "PNG") + +# 处理多个页面 +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) +``` + +### 使用 pypdfium2 提取文本 +```python +import pypdfium2 as pdfium + +pdf = pdfium.PdfDocument("document.pdf") +for i, page in enumerate(pdf): + text = page.get_text() + print(f"第 {i+1} 页文本长度:{len(text)} 个字符") +``` + +## JavaScript 库 + +### pdf-lib(MIT 许可证) + +pdf-lib 是一个功能强大的 JavaScript 库,用于在任何 JavaScript 环境中创建和修改 PDF 文档。 + +#### 加载和操作现有 PDF +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function manipulatePDF() { + // 加载现有 PDF + const existingPdfBytes = fs.readFileSync('input.pdf'); + const pdfDoc = await PDFDocument.load(existingPdfBytes); + + // 获取页数 + const pageCount = pdfDoc.getPageCount(); + console.log(`文档共有 ${pageCount} 页`); + + // 添加新页面 + const newPage = pdfDoc.addPage([600, 400]); + newPage.drawText('由 pdf-lib 添加', { + x: 100, + y: 300, + size: 16 + }); + + // 保存修改后的 PDF + const pdfBytes = await pdfDoc.save(); + fs.writeFileSync('modified.pdf', pdfBytes); +} +``` + +#### 从零开始创建复杂 PDF +```javascript +import { PDFDocument, rgb, StandardFonts } from 'pdf-lib'; +import fs from 'fs'; + +async function createPDF() { + const pdfDoc = await PDFDocument.create(); + + // 添加字体 + const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica); + const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold); + + // 添加页面 + const page = pdfDoc.addPage([595, 842]); // A4 尺寸 + const { width, height } = page.getSize(); + + // 添加带样式的文本 + page.drawText('发票 #12345', { + x: 50, + y: height - 50, + size: 18, + font: helveticaBold, + color: rgb(0.2, 0.2, 0.8) + }); + + // 添加矩形(页眉背景) + page.drawRectangle({ + x: 40, + y: height - 100, + width: width - 80, + height: 30, + color: rgb(0.9, 0.9, 0.9) + }); + + // 添加表格类内容 + const items = [ + ['项目', '数量', '单价', '合计'], + ['小工具', '2', '$50', '$100'], + ['小装置', '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); +} +``` + +#### 高级合并与拆分操作 +```javascript +import { PDFDocument } from 'pdf-lib'; +import fs from 'fs'; + +async function mergePDFs() { + // 创建新文档 + const mergedPdf = await PDFDocument.create(); + + // 加载源 PDF + const pdf1Bytes = fs.readFileSync('doc1.pdf'); + const pdf2Bytes = fs.readFileSync('doc2.pdf'); + + const pdf1 = await PDFDocument.load(pdf1Bytes); + const pdf2 = await PDFDocument.load(pdf2Bytes); + + // 从第一个 PDF 复制页面 + const pdf1Pages = await mergedPdf.copyPages(pdf1, pdf1.getPageIndices()); + pdf1Pages.forEach(page => mergedPdf.addPage(page)); + + // 从第二个 PDF 复制特定页面(第 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 许可证) + +PDF.js 是 Mozilla 的 JavaScript 库,用于在浏览器中渲染 PDF。 + +#### 基本的 PDF 加载与渲染 +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +// 配置 worker(对性能很重要) +pdfjsLib.GlobalWorkerOptions.workerSrc = './pdf.worker.js'; + +async function renderPDF() { + // 加载 PDF + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + console.log(`已加载 PDF,共 ${pdf.numPages} 页`); + + // 获取第一页 + const page = await pdf.getPage(1); + const viewport = page.getViewport({ scale: 1.5 }); + + // 渲染到 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); +} +``` + +#### 提取带坐标的文本 +```javascript +import * as pdfjsLib from 'pdfjs-dist'; + +async function extractText() { + const loadingTask = pdfjsLib.getDocument('document.pdf'); + const pdf = await loadingTask.promise; + + let fullText = ''; + + // 从所有页面提取文本 + 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--- 第 ${i} 页 ---\n${pageText}`; + + // 获取带坐标的文本以供高级处理 + 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; +} +``` + +#### 提取注释和表单 +```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.subtype}`); + console.log(`内容:${annotation.contents}`); + console.log(`坐标:${JSON.stringify(annotation.rect)}`); + }); + } +} +``` + +## 高级命令行操作 + +### poppler-utils 高级功能 + +#### 提取带边界框坐标的文本 +```bash +# 提取带边界框坐标的文本(对结构化数据至关重要) +pdftotext -bbox-layout document.pdf output.xml + +# XML 输出中包含每个文本元素的精确坐标 +``` + +#### 高级图片转换 +```bash +# 转换为指定分辨率的 PNG 图片 +pdftoppm -png -r 300 document.pdf output_prefix + +# 以高分辨率转换指定页面范围 +pdftoppm -png -r 600 -f 1 -l 3 document.pdf high_res_pages + +# 转换为 JPEG 并设置质量 +pdftoppm -jpeg -jpegopt quality=85 -r 200 document.pdf jpeg_output +``` + +#### 提取嵌入图片 +```bash +# 提取所有嵌入图片及元数据 +pdfimages -j -p document.pdf page_images + +# 列出图片信息(不提取) +pdfimages -list document.pdf + +# 以原始格式提取图片 +pdfimages -all document.pdf images/img +``` + +### qpdf 高级功能 + +#### 复杂页面操作 +```bash +# 将 PDF 拆分为多组页面 +qpdf --split-pages=3 input.pdf output_group_%02d.pdf + +# 使用复杂页码范围提取特定页面 +qpdf input.pdf --pages input.pdf 1,3-5,8,10-end -- extracted.pdf + +# 合并来自多个 PDF 的特定页面 +qpdf --empty --pages doc1.pdf 1-3 doc2.pdf 5-7 doc3.pdf 2,4 -- combined.pdf +``` + +#### PDF 优化与修复 +```bash +# 优化 PDF 以便网络传输(线性化以实现流式传输) +qpdf --linearize input.pdf optimized.pdf + +# 移除未使用对象并压缩 +qpdf --optimize-level=all input.pdf compressed.pdf + +# 尝试修复损坏的 PDF 结构 +qpdf --check input.pdf +qpdf --fix-qdf damaged.pdf repaired.pdf + +# 显示详细 PDF 结构以进行调试 +qpdf --show-all-pages input.pdf > structure.txt +``` + +#### 高级加密 +```bash +# 添加带特定权限的密码保护 +qpdf --encrypt user_pass owner_pass 256 --print=none --modify=none -- input.pdf encrypted.pdf + +# 检查加密状态 +qpdf --show-encryption encrypted.pdf + +# 移除密码保护(需要密码) +qpdf --password=secret123 --decrypt encrypted.pdf decrypted.pdf +``` + +## 高级 Python 技术 + +### pdfplumber 高级功能 + +#### 提取带精确坐标的文本 +```python +import pdfplumber + +with pdfplumber.open("document.pdf") as pdf: + page = pdf.pages[0] + + # 提取所有文本及坐标 + chars = page.chars + for char in chars[:10]: # 前 10 个字符 + print(f"字符:'{char['text']}' 位置 x:{char['x0']:.1f} y:{char['y0']:.1f}") + + # 按边界框提取文本(左、上、右、下) + bbox_text = page.within_bbox((100, 100, 400, 200)).extract_text() +``` + +#### 自定义设置的高级表格提取 +```python +import pdfplumber +import pandas as pd + +with pdfplumber.open("complex_table.pdf") as pdf: + page = pdf.pages[0] + + # 使用自定义设置提取复杂布局的表格 + table_settings = { + "vertical_strategy": "lines", + "horizontal_strategy": "lines", + "snap_tolerance": 3, + "intersection_tolerance": 15 + } + tables = page.extract_tables(table_settings) + + # 表格提取的可视化调试 + img = page.to_image(resolution=150) + img.save("debug_layout.png") +``` + +### reportlab 高级功能 + +#### 创建带表格的专业报告 +```python +from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph +from reportlab.lib.styles import getSampleStyleSheet +from reportlab.lib import colors + +# 示例数据 +data = [ + ['产品', 'Q1', 'Q2', 'Q3', 'Q4'], + ['小工具', '120', '135', '142', '158'], + ['小装置', '85', '92', '98', '105'] +] + +# 创建带表格的 PDF +doc = SimpleDocTemplate("report.pdf") +elements = [] + +# 添加标题 +styles = getSampleStyleSheet() +title = Paragraph("季度销售报告", styles['Title']) +elements.append(title) + +# 添加带高级样式的表格 +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) +``` + +## 复杂工作流 + +### 从 PDF 提取图形/图片 + +#### 方法一:使用 pdfimages(最快) +```bash +# 以原始质量提取所有图片 +pdfimages -all document.pdf images/img +``` + +#### 方法二:使用 pypdfium2 + 图片处理 +```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): + # 渲染高分辨率页面 + bitmap = page.render(scale=3.0) + img = bitmap.to_pil() + + # 转换为 numpy 以进行处理 + img_array = np.array(img) + + # 简单的图形检测(非白色区域) + mask = np.any(img_array != [255, 255, 255], axis=2) + + # 查找轮廓并提取边界框 + # (此处已简化——实际实现需要更复杂的检测) + + # 保存检测到的图形 + # ……具体实现取决于实际需求 +``` + +### 带错误处理的批量 PDF 处理 +```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"已处理:{pdf_file}") + except Exception as e: + logger.error(f"处理 {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"已从以下文件提取文本:{pdf_file}") + + except Exception as e: + logger.error(f"从 {pdf_file} 提取文本失败:{e}") + continue +``` + +### 高级 PDF 裁剪 +```python +from pypdf import PdfWriter, PdfReader + +reader = PdfReader("input.pdf") +writer = PdfWriter() + +# 裁剪页面(左、下、右、上,单位:点) +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) +``` + +## 性能优化技巧 + +### 1. 处理大型 PDF +- 使用流式方法而非将整个 PDF 加载到内存 +- 使用 `qpdf --split-pages` 拆分大文件 +- 使用 pypdfium2 逐页处理 + +### 2. 文本提取 +- `pdftotext -bbox-layout` 是纯文本提取最快的方案 +- 使用 pdfplumber 处理结构化数据和表格 +- 对于非常大的文档,避免使用 `pypdf.extract_text()` + +### 3. 图片提取 +- `pdfimages` 比渲染页面快得多 +- 预览使用低分辨率,最终输出使用高分辨率 + +### 4. 表单填写 +- pdf-lib 比大多数替代方案更好地保留表单结构 +- 处理前预先验证表单字段 + +### 5. 内存管理 +```python +# 分块处理 PDF +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]) + + # 处理块 + with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output: + writer.write(output) +``` + +## 常见问题排查 + +### 加密的 PDF +```python +# 处理密码保护的 PDF +from pypdf import PdfReader + +try: + reader = PdfReader("encrypted.pdf") + if reader.is_encrypted: + reader.decrypt("password") +except Exception as e: + print(f"解密失败:{e}") +``` + +### 损坏的 PDF +```bash +# 使用 qpdf 修复 +qpdf --check corrupted.pdf +qpdf --replace-input corrupted.pdf +``` + +### 文本提取问题 +```python +# 对扫描件回退到 OCR +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 +``` + +## 许可证信息 + +- **pypdf**:BSD 许可证 +- **pdfplumber**:MIT 许可证 +- **pypdfium2**:Apache/BSD 许可证 +- **reportlab**:BSD 许可证 +- **poppler-utils**:GPL-2 许可证 +- **qpdf**:Apache 许可证 +- **pdf-lib**:MIT 许可证 +- **pdfjs-dist**:Apache 许可证 diff --git a/scripts/check_bounding_boxes.py b/scripts/check_bounding_boxes.py new file mode 100644 index 0000000..2cc5e34 --- /dev/null +++ b/scripts/check_bounding_boxes.py @@ -0,0 +1,65 @@ +from dataclasses import dataclass +import json +import sys + + + + +@dataclass +class RectAndField: + rect: list[float] + rect_type: str + field: dict + + +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): + 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) + with open(sys.argv[1]) as f: + messages = get_bounding_box_messages(f) + for msg in messages: + print(msg) diff --git a/scripts/check_fillable_fields.py b/scripts/check_fillable_fields.py new file mode 100644 index 0000000..36dfb95 --- /dev/null +++ b/scripts/check_fillable_fields.py @@ -0,0 +1,11 @@ +import sys +from pypdf import PdfReader + + + + +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") diff --git a/scripts/convert_pdf_to_images.py b/scripts/convert_pdf_to_images.py new file mode 100644 index 0000000..7939cef --- /dev/null +++ b/scripts/convert_pdf_to_images.py @@ -0,0 +1,33 @@ +import os +import sys + +from pdf2image import convert_from_path + + + + +def convert(pdf_path, output_dir, max_dim=1000): + images = convert_from_path(pdf_path, dpi=200) + + for i, image in enumerate(images): + width, height = image.size + if width > max_dim or height > max_dim: + scale_factor = min(max_dim / width, max_dim / height) + new_width = int(width * scale_factor) + new_height = int(height * scale_factor) + image = image.resize((new_width, new_height)) + + image_path = os.path.join(output_dir, f"page_{i+1}.png") + image.save(image_path) + print(f"Saved page {i+1} as {image_path} (size: {image.size})") + + print(f"Converted {len(images)} pages to PNG images") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: convert_pdf_to_images.py [input pdf] [output directory]") + sys.exit(1) + pdf_path = sys.argv[1] + output_directory = sys.argv[2] + convert(pdf_path, output_directory) diff --git a/scripts/create_validation_image.py b/scripts/create_validation_image.py new file mode 100644 index 0000000..10eadd8 --- /dev/null +++ b/scripts/create_validation_image.py @@ -0,0 +1,37 @@ +import json +import sys + +from PIL import Image, ImageDraw + + + + +def create_validation_image(page_number, fields_json_path, input_path, output_path): + 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.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) diff --git a/scripts/extract_form_field_info.py b/scripts/extract_form_field_info.py new file mode 100644 index 0000000..64cd470 --- /dev/null +++ b/scripts/extract_form_field_info.py @@ -0,0 +1,122 @@ +import json +import sys + +from pypdf import PdfReader + + + + +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" + states = field.get("/_States_", []) + if len(states) == 2: + 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 + + +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(): + 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) + + + 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: + 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": [], + } + radio_fields_by_id[field_id]["radio_options"].append({ + "value": on_values[0], + "rect": rect, + }) + + 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") + + def sort_key(f): + if "radio_options" in f: + rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0] + else: + rect = f.get("rect") or [0, 0, 0, 0] + adjusted_position = [-rect[1], rect[0]] + return [f.get("page"), adjusted_position] + + sorted_fields = fields_with_location + list(radio_fields_by_id.values()) + sorted_fields.sort(key=sort_key) + + return sorted_fields + + +def write_field_info(pdf_path: str, json_output_path: str): + reader = PdfReader(pdf_path) + field_info = get_field_info(reader) + with open(json_output_path, "w") as f: + json.dump(field_info, f, indent=2) + print(f"Wrote {len(field_info)} fields to {json_output_path}") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: extract_form_field_info.py [input pdf] [output json]") + sys.exit(1) + write_field_info(sys.argv[1], sys.argv[2]) diff --git a/scripts/extract_form_structure.py b/scripts/extract_form_structure.py new file mode 100755 index 0000000..f219e7d --- /dev/null +++ b/scripts/extract_form_structure.py @@ -0,0 +1,115 @@ +""" +Extract form structure from a non-fillable PDF. + +This script analyzes the PDF to find: +- Text labels with their exact coordinates +- Horizontal lines (row boundaries) +- Checkboxes (small rectangles) + +Output: A JSON file with the form structure that can be used to generate +accurate field coordinates for filling. + +Usage: python extract_form_structure.py +""" + +import json +import sys +import pdfplumber + + +def extract_form_structure(pdf_path): + structure = { + "pages": [], + "labels": [], + "lines": [], + "checkboxes": [], + "row_boundaries": [] + } + + with pdfplumber.open(pdf_path) as pdf: + for page_num, page in enumerate(pdf.pages, 1): + structure["pages"].append({ + "page_number": page_num, + "width": float(page.width), + "height": float(page.height) + }) + + words = page.extract_words() + for word in words: + structure["labels"].append({ + "page": page_num, + "text": word["text"], + "x0": round(float(word["x0"]), 1), + "top": round(float(word["top"]), 1), + "x1": round(float(word["x1"]), 1), + "bottom": round(float(word["bottom"]), 1) + }) + + for line in page.lines: + if abs(float(line["x1"]) - float(line["x0"])) > page.width * 0.5: + structure["lines"].append({ + "page": page_num, + "y": round(float(line["top"]), 1), + "x0": round(float(line["x0"]), 1), + "x1": round(float(line["x1"]), 1) + }) + + for rect in page.rects: + width = float(rect["x1"]) - float(rect["x0"]) + height = float(rect["bottom"]) - float(rect["top"]) + if 5 <= width <= 15 and 5 <= height <= 15 and abs(width - height) < 2: + structure["checkboxes"].append({ + "page": page_num, + "x0": round(float(rect["x0"]), 1), + "top": round(float(rect["top"]), 1), + "x1": round(float(rect["x1"]), 1), + "bottom": round(float(rect["bottom"]), 1), + "center_x": round((float(rect["x0"]) + float(rect["x1"])) / 2, 1), + "center_y": round((float(rect["top"]) + float(rect["bottom"])) / 2, 1) + }) + + lines_by_page = {} + for line in structure["lines"]: + page = line["page"] + if page not in lines_by_page: + lines_by_page[page] = [] + lines_by_page[page].append(line["y"]) + + for page, y_coords in lines_by_page.items(): + y_coords = sorted(set(y_coords)) + for i in range(len(y_coords) - 1): + structure["row_boundaries"].append({ + "page": page, + "row_top": y_coords[i], + "row_bottom": y_coords[i + 1], + "row_height": round(y_coords[i + 1] - y_coords[i], 1) + }) + + return structure + + +def main(): + if len(sys.argv) != 3: + print("Usage: extract_form_structure.py ") + sys.exit(1) + + pdf_path = sys.argv[1] + output_path = sys.argv[2] + + print(f"Extracting structure from {pdf_path}...") + structure = extract_form_structure(pdf_path) + + with open(output_path, "w") as f: + json.dump(structure, f, indent=2) + + print(f"Found:") + print(f" - {len(structure['pages'])} pages") + print(f" - {len(structure['labels'])} text labels") + print(f" - {len(structure['lines'])} horizontal lines") + print(f" - {len(structure['checkboxes'])} checkboxes") + print(f" - {len(structure['row_boundaries'])} row boundaries") + print(f"Saved to {output_path}") + + +if __name__ == "__main__": + main() diff --git a/scripts/fill_fillable_fields.py b/scripts/fill_fillable_fields.py new file mode 100644 index 0000000..51c2600 --- /dev/null +++ b/scripts/fill_fillable_fields.py @@ -0,0 +1,98 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter + +from extract_form_field_info import get_field_info + + + + +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) + 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) + + 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 + + +def monkeypatch_pydpf_method(): + from pypdf.generic import DictionaryObject + from pypdf.constants import FieldDictionaryAttributes + + original_get_inherited = DictionaryObject.get_inherited + + def patched_get_inherited(self, key: str, default = None): + result = original_get_inherited(self, key, default) + if key == FieldDictionaryAttributes.Opt: + if isinstance(result, list) and all(isinstance(v, list) and len(v) == 2 for v in result): + result = [r[0] for r in result] + return result + + DictionaryObject.get_inherited = patched_get_inherited + + +if __name__ == "__main__": + if len(sys.argv) != 4: + print("Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]") + sys.exit(1) + monkeypatch_pydpf_method() + input_pdf = sys.argv[1] + fields_json = sys.argv[2] + output_pdf = sys.argv[3] + fill_pdf_fields(input_pdf, fields_json, output_pdf) diff --git a/scripts/fill_pdf_form_with_annotations.py b/scripts/fill_pdf_form_with_annotations.py new file mode 100644 index 0000000..b430069 --- /dev/null +++ b/scripts/fill_pdf_form_with_annotations.py @@ -0,0 +1,107 @@ +import json +import sys + +from pypdf import PdfReader, PdfWriter +from pypdf.annotations import FreeText + + + + +def transform_from_image_coords(bbox, image_width, image_height, pdf_width, pdf_height): + x_scale = pdf_width / image_width + y_scale = pdf_height / image_height + + left = bbox[0] * x_scale + right = bbox[2] * x_scale + + top = pdf_height - (bbox[1] * y_scale) + bottom = pdf_height - (bbox[3] * y_scale) + + return left, bottom, right, top + + +def transform_from_pdf_coords(bbox, pdf_height): + left = bbox[0] + right = bbox[2] + + pypdf_top = pdf_height - bbox[1] + pypdf_bottom = pdf_height - bbox[3] + + return left, pypdf_bottom, right, pypdf_top + + +def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path): + + with open(fields_json_path, "r") as f: + fields_data = json.load(f) + + reader = PdfReader(input_pdf_path) + writer = PdfWriter() + + writer.append(reader) + + pdf_dimensions = {} + for i, page in enumerate(reader.pages): + mediabox = page.mediabox + pdf_dimensions[i + 1] = [mediabox.width, mediabox.height] + + annotations = [] + for field in fields_data["form_fields"]: + page_num = field["page_number"] + + page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num) + pdf_width, pdf_height = pdf_dimensions[page_num] + + if "pdf_width" in page_info: + transformed_entry_box = transform_from_pdf_coords( + field["entry_bounding_box"], + float(pdf_height) + ) + else: + image_width = page_info["image_width"] + image_height = page_info["image_height"] + transformed_entry_box = transform_from_image_coords( + field["entry_bounding_box"], + image_width, image_height, + float(pdf_width), float(pdf_height) + ) + + 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") + + 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) + writer.add_annotation(page_number=page_num - 1, annotation=annotation) + + 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)