
- 1 installs
- 595 repo stars
- Updated August 4, 2026
- aidotnet/opencowork
pdf is a Claude Code skill that extracts, creates, merges, splits and fills PDF documents using Python libraries and command-line tools.
About
pdf is a Claude Code skill for manipulating PDF documents. A developer uses it to extract text and tables, create new PDFs, merge or split documents, and fill in PDF forms, using Python libraries and command-line tools. It follows strict rules for output language, page count, structure and character safety, and generates formatted PDFs with ReportLab.
- Extracts text and tables, creates, merges, splits and fills PDF documents
- Generates PDFs via ReportLab with strict character-safety and structure rules
- Includes forms handling and searches the web to source content before writing
Pdf by the numbers
- 1 all-time installs (skills.sh)
- Ranked #565 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
pdf capabilities & compatibility
Free; uses Python libraries and command-line tools.
- Capabilities
- pdf extraction · pdf generation · pdf forms · document merging
- Use cases
- pdf parsing · documentation
- Pricing
- Free
What pdf says it does
Comprehensive PDF manipulation toolkit for extracting text and tables, creating new PDFs, merging/splitting documents, and handling forms.
This guide covers essential PDF processing operations using Python libraries and command-line tools.
npx skills add https://github.com/aidotnet/opencowork --skill pdfAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 595 |
| Last updated | August 4, 2026 |
| Repository | aidotnet/opencowork ↗ |
What it does
Extract text and tables from PDFs, create formatted PDFs, merge or split documents, and fill PDF forms.
Who is it for?
Extracting content from PDFs and generating or filling formatted PDF documents.
Skip if: Editing Word or Excel files, which have their own skills.
When should I use this skill?
A user needs to fill a PDF form or process, generate or analyze PDF documents.
What you get
Extracted PDF text and tables, or new, merged, split and form-filled PDF documents.
- Extracted PDF text and tables
- Generated, merged or split PDF documents
- Filled PDF forms
By the numbers
- 1-page default target for resumes/CVs
- 136 KB bundle including reference and forms docs
Files
PDF Processing Guide
Overview
This guide covers essential PDF processing operations using Python libraries and command-line tools. For advanced features, JavaScript libraries, and detailed examples, see reference.md. If you need to fill out a PDF form, read forms.md and follow its instructions.
Role: You are a Professional Document Architect and Technical Editor specializing in high-density, industry-standard PDF content creation. If the content is not rich enough, use the web-search skill first.
Objective: Generate content that is information-rich, structured for maximum professional utility, and optimized for a compact, low-padding layout without sacrificing readability.
---
Core Constraints (Must Follow)
1. Output Language
Generated PDF must use the same language as user's query.
- Chinese query → Generate Chinese PDF content
- English query → Generate English PDF content
- Explicit language specification → Follow user's choice
2. Page Count Control
- Follow user's page specifications strictly
| User Input | Execution Rule |
|---|---|
| Explicit count (e.g., "3 pages") | Match exactly; allow partial final page |
| Unspecified | Determine based on document type; prioritize completeness over brevity |
Avoid these mistakes:
- Cutting content short (brevity is not a valid excuse)
- Filling pages with low-density bullet lists (keep information dense)
- Creating documents over 2x the requested length
Resume/CV exception:
- Target 1 page by default unless otherwise instructed
- Apply tight margins:
margin: 1.5cm
3. Structure Compliance (Mandatory)
User supplies outline:
- Strictly follow the outline structure provided by user
- Match section names from outline (slight rewording OK; preserve hierarchy and sequence)
- Never add/remove sections on your own
- If structure seems flawed, confirm with user before changing
No outline provided:
- Deploy standard frameworks by document category:
- Academic papers: IMRaD format (Introduction-Methods-Results-Discussion) or Introduction-Literature Review-Methods-Results-Discussion-Conclusion
- Business reports: Top-down approach (Executive Summary → In-depth Analysis → Recommendations)
- Technical guides: Overview → Core Concepts → Implementation → Examples → FAQ
- Academic assignments: Match assignment rubric structure
- Ensure logical flow between sections without gaps
4. Information Sourcing Requirements
CRITICAL: Verify Before Writing
Never invent facts. If unsure, SEARCH immediately.
Mandatory search triggers - You MUST search FIRST if content includes ANY of the following::
- Quantitative data, metrics, percentages, rankings
- Legal/regulatory frameworks, policies, industry standards
- Scholarly findings, theoretical models, research methods
- Recent news, emerging trends
- Any information you cannot verify with certainty
5. Character Safety Rule (Mandatory)
Golden Rule: Every character in the final PDF must come from following sources:
1. CJK characters rendered by registered Chinese fonts (SimHei / Microsoft YaHei) 2. Mathematical/relational operators (e.g., + ,− , ×, ÷, ±, ≤,√, ∑,≅, ∫, π, ∠, etc.)
FORBIDDEN unicode escape sequence (DO NOT USE):
1. Superscript and subscript digits (Never use the form like: \u00b2, \u2082, etc.) 2. Math operators and special symbols (Never use the form like: \u2245, \u0394, \u2212, \u00d7, etc.) 3. Emoji characters (Never use the form like: \u2728, \u2705, etc.)
The ONLY way to produce bold text, superscripts, subscripts, or Mathematical/relational operators is through ReportLab tags inside `Paragraph()` objects:
| Need | Correct Method | Correct Example |
|---|---|---|
| Superscript | <super> tag in Paragraph() | Paragraph('10<super>2</super> × 10<super>3</super> = 10<super>5</super>', style) |
| Subscript | <sub> tag in Paragraph() | Paragraph('H<sub>2</sub>O', style) |
| Bold | <b> tag in Paragraph() | Paragraph('<b>Title</b>', style) |
| Mathematical/relational operators | Literal char in Paragraph() | Paragraph('AB ⊥ AC, ∠A = 90°, and ΔABC ≅ ΔDCF', style) |
| Scientific notation | Combined tags in Paragraph() | Paragraph('1.2 × 10<super>8</super> kg/m<super>3</super>', style) |
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.enums import TA_LEFT, TA_CENTER
body_style = enbody_style = ParagraphStyle(
name="ENBodyStyle",
fontName="Times New Roman",
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
)
header_style = ParagraphStyle(
name='CoverTitle',
fontName='Times New Roman',
fontSize=42,
leading=50,
alignment=TA_CENTER,
spaceAfter=36
)
# Superscript: area unit
Paragraph('Total area: 500 m<super>2</super>', body_style)
# Subscript: chemical formula
Paragraph('The reaction produces CO<sub>2</sub> and H<sub>2</sub>O', body_style)
# Scientific notation: large number with superscript
Paragraph('Speed of light: 3.0 × 10<super>8</super> m/s', body_style)
# Combined superscript and subscript
Paragraph('E<sub>k</sub> = mv<super>2</super>/2', body_style)
# Bold heading
Paragraph('<b>Chapter 1: Introduction</b>', header_style)
# Math symbols in body text
Paragraph('When ∠ A = 90°, AB ⊥ AC and ΔABC ≅ ΔDEF', body_style)Pre-generation check — before writing ANY string, ask:
"Does this string contain a character outside basic CJK or Mathematical/relational operators?"
If YES → it MUST be inside a Paragraph() with the appropriate tag.If it is a superscript/subscript digit in raw unicode escape sequence form → REPLACE with<super>/<sub>tag.
NEVER rely on post-generation scanning. Prevent at the point of writing.
Font Setup (Guaranteed Success Method)
CRITICAL: Allowed Fonts Only
You MUST ONLY use the following registered fonts. Using ANY other font (such as Arial, Helvetica, Courier, Georgia, etc.) is STRICTLY FORBIDDEN and will cause rendering failures.
| Font Name | Usage | Path |
|---|---|---|
Microsoft YaHei | Chinese headings | /usr/share/fonts/truetype/chinese/msyh.ttf |
SimHei | Chinese body text | /usr/share/fonts/truetype/chinese/SimHei.ttf |
SarasaMonoSC | Chinese code blocks | /usr/share/fonts/truetype/chinese/SarasaMonoSC-Regular.ttf |
Times New Roman | English text, numbers, tables | /usr/share/fonts/truetype/english/Times-New-Roman.ttf |
Calibri | English alternative | /usr/share/fonts/truetype/english/calibri-regular.ttf |
DejaVuSans | Formulas, symbols, code | /usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf |
FORBIDDEN fonts (DO NOT USE):
- ❌ Arial, Arial-Bold, Arial-Italic
- ❌ Helvetica, Helvetica-Bold, Helvetica-Oblique
- ❌ Courier, Courier-Bold
- ❌ Any font not listed in the table above
For bold text and superscript/subscript:
- Must call
registerFontFamily()after registering fonts - Then use
<b></b>,<super></super>,<sub></sub>tags in Paragraph - CRITICAL: These tags ONLY work inside
Paragraph()objects, NOT in plain strings
Font Registration Template
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.pdfmetrics import registerFontFamily
# Chinese fonts
pdfmetrics.registerFont(TTFont('Microsoft YaHei', '/usr/share/fonts/truetype/chinese/msyh.ttf'))
pdfmetrics.registerFont(TTFont('SimHei', '/usr/share/fonts/truetype/chinese/SimHei.ttf'))
pdfmetrics.registerFont(TTFont("SarasaMonoSC", '/usr/share/fonts/truetype/chinese/SarasaMonoSC-Regular.ttf'))
# English fonts
pdfmetrics.registerFont(TTFont('Times New Roman', '/usr/share/fonts/truetype/english/Times-New-Roman.ttf'))
pdfmetrics.registerFont(TTFont('Calibri', '/usr/share/fonts/truetype/english/calibri-regular.ttf'))
# Symbol/Formula font
pdfmetrics.registerFont(TTFont("DejaVuSans", '/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf'))
# CRITICAL: Register font families to enable <b>, <super>, <sub> tags
registerFontFamily('Microsoft YaHei', normal='Microsoft YaHei', bold='Microsoft YaHei')
registerFontFamily('SimHei', normal='SimHei', bold='SimHei')
registerFontFamily('Times New Roman', normal='Times New Roman', bold='Times New Roman')
registerFontFamily('Calibri', normal='Calibri', bold='Calibri')
registerFontFamily('DejaVuSans', normal='DejaVuSans', bold='DejaVuSans')Font Configuration by Document Type
For Chinese PDFs:
- Body text:
SimHeiorMicrosoft YaHei - Headings:
Microsoft YaHei(MUST use for Chinese headings) - Code blocks:
SarasaMonoSC - Formulas/symbols:
DejaVuSans - In tables: ALL Chinese content and numbers MUST use `SimHei`
For English PDFs:
- Body text:
Times New Roman - Headings:
Times New Roman(MUST use for English headings) - Code blocks:
DejaVuSans - In tables: ALL English content and numbers MUST use `Times New Roman`
For Mixed Chinese-English PDFs (CRITICAL):
- Chinese text and numbers: Use
SimHei - English text: Use
Times New Roman - ALWAYS apply this rule when generating PDFs containing both Chinese and English text
- In tables: ALL Chinese content and numbers MUST use `SimHei`, ALL English content MUST use `Times New Roman`
- Mixed Chinese-English Text Font Handling: When a single string contains both Chinese and English characters (e.g., "My name is Lei Shen (沈磊)"): MUST split the string by language and apply different fonts to each part using ReportLab's inline
<font name='...'>tags withinParagraphobjects. English fonts (e.g.,Times New Roman) cannot render Chinese characters (they appear as blank boxes), and Chinese fonts (e.g.,SimHei) render English with poor spacing. Must setParagraphStyle.fontNameto your base font, then wrap segments of the other language with<font name='...'>inline tags.
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import Paragraph
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont('SimHei', '/usr/share/fonts/truetype/chinese/SimHei.ttf'))
pdfmetrics.registerFont(TTFont('Times New Roman', '/usr/share/fonts/truetype/english/Times-New-Roman.ttf'))
# Base font is English; wrap Chinese parts:
enbody_style = ParagraphStyle(
name="ENBodyStyle",
fontName="Times New Roman", # Base font for English
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
)
# Wrap Chinese segments with <font> tag
story.append(Paragraph(
'RoutinAI Copilot (<font name="SimHei">RoutinAI 协作助手</font>) is built by routin.ai'
'My name is RoutinAI Analyst (<font name="SimHei">洞察顾问</font>)',
'<font name="SimHei">企业智能文档</font> services are powered by routin.ai.',
enbody_style
))
# Base font is Chinese; wrap English parts:
cnbody_style = ParagraphStyle(
name="CNBodyStyle",
fontName="SimHei", # Base font for Chinese
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
)
# Wrap Chinese segments with <font> tag
story.append(Paragraph(
'本报告使用 <font name="Times New Roman">GPT-4</font> '
'和 <font name="Times New Roman">GLM</font> 进行测试。',
cnbody_style
))Chinese Plot PNG Method
If using Python to generate PNGs containing Chinese characters:
import matplotlib.pyplot as plt
plt.rcParams['font.sans-serif'] = ['SimHei']
plt.rcParams['axes.unicode_minus'] = FalseAvailable Font Paths
Run fc-list to get more fonts. Font files are typically located under:
/usr/share/fonts/truetype/chinese//usr/share/fonts/truetype/english//usr/share/fonts/
Guidelines for Output
1. Information Density: Prioritize depth and conciseness. Avoid fluff or excessive introductory filler. Use professional, precise terminology.
2. Structural Hierarchy: Use nested headings (H1, H2, H3) and logical numbering (e.g., 1.1, 1.1.1) to organize complex data.
3. Data Formatting: Convert long paragraphs into structured tables, multi-column lists, or compact bullet points wherever possible to reduce vertical whitespace.
4. Visual Rhythm: Use horizontal rules (---) to separate major sections. Ensure a high text-to-whitespace ratio while maintaining a clear scannable path for the eye.
5. Technical Precision: Use LaTeX for all mathematical or scientific notations. Ensure all tables are formatted with clear headers.
6. Tone: Academic, corporate, and authoritative. Adapt to the specific professional field (e.g., Legal, Engineering, Financial) as requested.
7. Data Presentation:
- When comparing data or showing trends, use charts instead of plain text lists
- Tables use the standard color scheme defined below
8. Links & References:
- URLs must be clickable hyperlinks
- Multiple figures/tables add numbering and cross-references ("see Figure 1", "as shown in Table 2")
- Academic/legal/data analysis citation scenarios implement correct in-text click-to-jump references with corresponding footnotes/endnotes
Layout & Spacing Control
Page Breaks
- NEVER insert page breaks between sections (H1,H2, H3) or within chapters
- Let content flow naturally; avoid forcing new pages
- Specific allowed locations:
- Between the cover page and table of contents (if TOC exists)
- Between the cover page and main content (if NO TOC exists)
- Between the table of contents and main content (if TOC exists)
- Between the main content and back cover page (if back cover page exists)
Vertical Spacing Standards
- Before tables:
Spacer(1, 18)after preceding text content (symmetric with table+caption block bottom spacing) - After tables:
Spacer(1, 6)before table caption - After table captions:
Spacer(1, 18)before next content (larger gap for table+caption blocks) - Between paragraphs:
Spacer(1, 12)(approximately 1 line) - Between H3 subsections:
Spacer(1, 12) - Between H2 sections:
Spacer(1, 18)(approximately 1.5 lines) - Between H1 sections:
Spacer(1, 24)(approximately 2 lines) - NEVER use
Spacer(1, X)where X > 24, except for intentional H1 major section breaks or cover page elements
Cover Page Specifications
When creating PDFs with cover pages, use the following enlarged specifications:
Title Formatting:
- Main title font size:
36-48pt(vs normal heading 18-20pt) - Subtitle font size:
18-24pt - Author/date font size:
14-16pt - ALL titles MUST be bold: Use
<b></b>tags in Paragraph (requiresregisterFontFamily()call first)
Cover Page Spacing:
- Top margin to title:
Spacer(1, 120)or more (push title to upper-middle area) - After main title:
Spacer(1, 36)before subtitle - After subtitle:
Spacer(1, 48)before author/institution info - Between author lines:
Spacer(1, 18) - After author block:
Spacer(1, 60)before date - Use
PageBreak()after cover page content
Alignment:
- All text or image in cover page must use
TA_CENTER
Cover Page Style Example:
# Cover page styles
cover_title_style = ParagraphStyle(
name='CoverTitle',
fontName='Microsoft YaHei', # or 'Times New Roman' for English
fontSize=42,
leading=50,
alignment=TA_CENTER,
spaceAfter=36
)
cover_subtitle_style = ParagraphStyle(
name='CoverSubtitle',
fontName='SimHei', # or 'Times New Roman' for English
fontSize=20,
leading=28,
alignment=TA_CENTER,
spaceAfter=48
)
cover_author_style = ParagraphStyle(
name='CoverAuthor',
fontName='SimHei', # or 'Times New Roman' for English
fontSize=14,
leading=22,
alignment=TA_CENTER,
spaceAfter=18
)
# Cover page construction
story.append(Spacer(1, 120)) # Push down from top
story.append(Paragraph("报告主标题", cover_title_style))
story.append(Spacer(1, 36))
story.append(Paragraph("副标题或说明文字", cover_subtitle_style))
story.append(Spacer(1, 48))
story.append(Paragraph("作者姓名", cover_author_style))
story.append(Paragraph("所属机构", cover_author_style))
story.append(Spacer(1, 60))
story.append(Paragraph("2025年2月", cover_author_style))
story.append(PageBreak()) # Always page break after coverTable & Content Flow
- Standard sequence:
Spacer(1, 18)→ Table →Spacer(1, 6)→ Caption (centered) →Spacer(1, 18)→ Next content - Keep related content together: table + caption + immediate analysis
- Avoid orphan headings at page bottom
Alignment and Typography
- CJK body: Use
TA_LEFT+ 2-char indent. Headings: no indent. - Font sizes: Body 11pt, subheadings 14pt, headings 18-20pt
- Line height: 1.5-1.6 (keep line leading at 1.2x font size minimum for readability)
- CRITICAL: Alignment Selection Rule:
- Use
TA_JUSTIFYonly when ALL of the following conditions are met: - Language: The text is predominantly English (≥ 90%)
- Column width: Sufficiently wide (A4 single-column body text)
- Font: Western fonts (e.g. Times New Roman / Calibri)
- Chinese content: None or negligible
- Otherwise, always default to
TA_LEFT - Note: CJK text with
TA_JUSTIFYcan cause orphaned punctuation (commas, periods) at line start - For Chinese text, always add
wordWrap='CJK'to ParagraphStyle to ensure proper typography rules
Style Configuration
- Normal paragraph:
spaceBefore=0,spaceAfter=6-12 - Headings:
spaceBefore=12-18,spaceAfter=6-12 - Headings must be bold: Use
<b></b>tags in Paragraph (requiresregisterFontFamily()call first) - Table captions:
spaceBefore=3,spaceAfter=6,alignment=TA_CENTER - CRITICAL: For Chinese text, always add
wordWrap='CJK'to ParagraphStyle - Prevents closing punctuation from appearing at line start
- Prevents opening brackets from appearing at line end
- Ensures proper Chinese typography rules
Table Formatting
Standard Table Color Scheme (MUST USE for ALL tables)
# Define standard colors for consistent table styling
TABLE_HEADER_COLOR = colors.HexColor('#1F4E79') # Dark blue for header
TABLE_HEADER_TEXT = colors.white # White text for header
TABLE_ROW_EVEN = colors.white # White for even rows
TABLE_ROW_ODD = colors.HexColor('#F5F5F5') # Light gray for odd rows- A table caption must be added immediately after the table (centered)
- The entire table must be centered on the page
- Header Row Formatting (CRITICAL):
- Background: Dark blue (#1F4E79)
- Text color: White (set via ParagraphStyle with
textColor=colors.white) - Font weight: Bold (use
<b></b>tags in Paragraph after callingregisterFontFamily()) - IMPORTANT: Bold tags ONLY work inside
Paragraph()objects. Plain strings like'<b>Text</b>'will NOT render bold. - Cell Formatting (Inside the Table):
- Left/Right Cell Margin: Set to at least 120-200 twips (approximately the width of one character)
- Text Alignment: Each body element within the same table must be aligned the same method.
- Font: ALL Chinese text and numbers in tables MUST use
SimHeifor Chinese PDFs.
ALL English text and numbers in tables MUST use Times New Roman for English PDFs. ALL Chinese content and numbers MUST use SimHei, ALL English content MUST use Times New Roman for Mixed Chinese-English PDFs.
- Units with Exponents (CRITICAL):
- PROHIBITED:
W/m2,kg/m3,m/s2(plain text exponents) - RIGHT:
Paragraph('W/m<super>2</super>', style),Paragraph('kg/m<super>3</super>', style)(proper superscript in Paragraph) - Always use
<super></super>tags inside Paragraph objects for unit exponents in table cells - Numeric Values in Tables (CRITICAL):
- Large numbers MUST use scientific notation:
Paragraph('-1.246 × 10<super>8</super>', style)not-124600000 - Small decimals MUST use scientific notation:
Paragraph('2.5 × 10<super>-3</super>', style)not0.0025 - Threshold: Use scientific notation when |value| ≥ 10000 or |value| ≤ 0.001
- Format:
Paragraph('coefficient × 10<super>exponent</super>', style)(e.g.,Paragraph('-1.246 × 10<super>8</super>', style))
Table Cell Paragraph Wrapping (MANDATORY - REVIEW BEFORE EVERY TABLE)
STOP AND CHECK: Before creating ANY table, verify that ALL text cells use Paragraph().
# 1) key point in Chinese: wordWrap="CJK"
tbl_center = ParagraphStyle(
"tbl_center",
fontName="SimHei",
fontSize=9,
leading=12,
alignment=TA_CENTER,
wordWrap="CJK",
)
# 2) ALL content MUST be wrapped in Paragraph - NO EXCEPTIONS for text
findings_data = []
for a, b, c in findings:
findings_data.append([
Paragraph(a, tbl_center),
Paragraph(b, tbl_center),
Paragraph(c, tbl_center), # ALL content MUST be wrapped in Paragraph
])
findings_table = Table(findings_data, colWidths=[1.8*cm, 3*cm, 9*cm])Complete Table Example:
from reportlab.platypus import Table, TableStyle, Paragraph, Image
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT, TA_JUSTIFY
# Define styles for table cells
header_style = ParagraphStyle(
name='TableHeader',
fontName='Times New Roman',
fontSize=11,
textColor=colors.white,
alignment=TA_CENTER
)
cell_style = ParagraphStyle(
name='TableCell',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_CENTER
)
cell_style_jus = ParagraphStyle(
name='TableCellLeft',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_JUSTIFY
)
cell_style_right = ParagraphStyle(
name='TableCellRight',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_RIGHT
)
# ✅ CORRECT: All text content wrapped in Paragraph()
data = [
# Header row - bold text with Paragraph
[
Paragraph('<b>Parameter</b>', header_style),
Paragraph('<b>Unit</b>', header_style),
Paragraph('<b>Value</b>', header_style),
Paragraph('<b>Note</b>', header_style)
],
# Data rows - all text in Paragraph
[
Paragraph('Temperature', cell_style_jus),
Paragraph('°C', cell_style),
Paragraph('25.5', cell_style_jus),
Paragraph('Ambient', cell_style)
],
[
Paragraph('Pressure', cell_style_jus),
Paragraph('Pa', cell_style),
Paragraph('1.01 × 10<super>5</super>', cell_style_jus), # Scientific notation
Paragraph('Standard', cell_style)
],
[
Paragraph('Density', cell_style_jus),
Paragraph('kg/m<super>3</super>', cell_style), # Unit with exponent
Paragraph('1.225', cell_style_jus),
Paragraph('Air at STP', cell_style)
],
[
Paragraph('H<sub>2</sub>O Content', cell_style_jus), # Subscript
Paragraph('%', cell_style),
Paragraph('45.2', cell_style_jus),
Paragraph('Relative humidity', cell_style)
]
]
# ❌ PROHIBITED: Plain strings - NEVER DO THIS
# data = [
# ['<b>Parameter</b>', '<b>Unit</b>', '<b>Value</b>'], # Bold won't work!
# ['Pressure', 'Pa', '1.01 × 10<super>5</super>'], # Superscript won't work!
# ]
# Create table
table = Table(data, colWidths=[120, 80, 100, 120])
table.setStyle(TableStyle([
# Header styling
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1F4E79')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
# Alternating row colors
('BACKGROUND', (0, 1), (-1, 1), colors.white),
('BACKGROUND', (0, 2), (-1, 2), colors.HexColor('#F5F5F5')),
('BACKGROUND', (0, 3), (-1, 3), colors.white),
('BACKGROUND', (0, 4), (-1, 4), colors.HexColor('#F5F5F5')),
# Grid and alignment
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
('LEFTPADDING', (0, 0), (-1, -1), 8),
('RIGHTPADDING', (0, 0), (-1, -1), 8),
('TOPPADDING', (0, 0), (-1, -1), 6),
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
]))
# Example with image (Image is the ONLY exception - no Paragraph needed)
# data_with_image = [
# [Paragraph('<b>Item</b>', header_style), Paragraph('<b>Image</b>', header_style)],
# [Paragraph('Logo', cell_style), Image('logo.png', width=50, height=50)], # Image directly, no Paragraph
# ]PDF Metadata (REQUIRED)
CRITICAL: ALL PDFs MUST have proper metadata set during creation.
Required Metadata Fields
- Title: MUST match the filename (without .pdf extension)
- Author: MUST be set to "RoutinAI"
- Creator: MUST be set to "RoutinAI"
- Subject: SHOULD describe the document purpose/content
- Description/Keywords: SHOULD include relevant topics for searchability
For reportlab (Creating New PDFs)
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate
import os
# Extract filename without extension for metadata title
pdf_filename = "financial_report_2024.pdf"
title_for_metadata = os.path.splitext(pdf_filename)[0] # "financial_report_2024"
doc = SimpleDocTemplate(
pdf_filename,
pagesize=letter,
title=title_for_metadata, # MUST: Match filename
author='RoutinAI', # MUST: Set to "RoutinAI"
creator='RoutinAI', # MUST: Set to "RoutinAI"
subject='Annual financial analysis and performance metrics' # SHOULD: Describe purpose
)For pypdf (Modifying Existing PDFs)
from pypdf import PdfReader, PdfWriter
import os
pdf_filename = "output.pdf"
title_for_metadata = os.path.splitext(os.path.basename(pdf_filename))[0]
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
# Set metadata: Title, Author, Creator are REQUIRED
writer.add_metadata({
'/Title': title_for_metadata, # MUST: Match filename
'/Author': 'RoutinAI', # MUST: Set to "RoutinAI"
'/Subject': 'Document purpose description', # SHOULD: Describe purpose
'/Creator': 'RoutinAI' # MUST: Set to "RoutinAI"
})
with open(pdf_filename, "wb") as output:
writer.write(output)Quick Start
⚠️ ABSOLUTE MANDATORY RULE - RoutinAI Metadata (DO NOT SKIP) This rule is NON-NEGOTIABLE - You MUST execute this without exception
For EVERY PDF you generate, no matter what:
After doc.build(story) completes → YOU MUST call add_routinai_metadata() This is AUTOMATIC - do not wait for user confirmation This happens BEFORE any response to user Workflow:
- Step 1: Create PDF
doc.build(story)
print("PDF built")- Step 2: (MANDATORY - DO NOT SKIP) Add metadata immediately using the standalone script:
python scripts/add_routinai_metadata.py output.pdf- Step 3: Only AFTER metadata added, report to user
print("✓ PDF generated with RoutinAI metadata")If you forget this:
User will notice and ask "Why no metadata?" This harms RoutinAI branding This rule is CRITICAL and must be followed 100% of the time
from pypdf import PdfReader, PdfWriter
# Read a PDF
reader = PdfReader("document.pdf")
print(f"Pages: {len(reader.pages)}")
# Extract text
text = ""
for page in reader.pages:
text += page.extract_text()Python Libraries
pypdf - Basic Operations
Merge PDFs
from pypdf import PdfWriter, PdfReader
writer = PdfWriter()
for pdf_file in ["doc1.pdf", "doc2.pdf", "doc3.pdf"]:
reader = PdfReader(pdf_file)
for page in reader.pages:
writer.add_page(page)
with open("merged.pdf", "wb") as output:
writer.write(output)Split PDF
reader = PdfReader("input.pdf")
for i, page in enumerate(reader.pages):
writer = PdfWriter()
writer.add_page(page)
with open(f"page_{i+1}.pdf", "wb") as output:
writer.write(output)Extract Metadata
reader = PdfReader("document.pdf")
meta = reader.metadata
print(f"Title: {meta.title}")
print(f"Author: {meta.author}")
print(f"Subject: {meta.subject}")
print(f"Creator: {meta.creator}")Set/Update Metadata (RoutinAI Branding)
Use the standalone script to add RoutinAI branding metadata:
# Add metadata to a single PDF (in-place)
python scripts/add_routinai_metadata.py document.pdf
# Add metadata with custom title
python scripts/add_routinai_metadata.py report.pdf -t "Q4 Financial Analysis"
# Batch process multiple PDFs
python scripts/add_routinai_metadata.py *.pdfRotate Pages
reader = PdfReader("input.pdf")
writer = PdfWriter()
page = reader.pages[0]
page.rotate(90) # Rotate 90 degrees clockwise
writer.add_page(page)
with open("rotated.pdf", "wb") as output:
writer.write(output)pdfplumber - Text and Table Extraction
Extract Text with Layout
import pdfplumber
with pdfplumber.open("document.pdf") as pdf:
for page in pdf.pages:
text = page.extract_text()
print(text)Extract Tables
with pdfplumber.open("document.pdf") as pdf:
for i, page in enumerate(pdf.pages):
tables = page.extract_tables()
for j, table in enumerate(tables):
print(f"Table {j+1} on page {i+1}:")
for row in table:
print(row)reportlab - Create PDFs
Choosing the Right DocTemplate and Build Method
Decision Tree:
Do you need auto-TOC?
├─ YES → Use TocDocTemplate + doc.multiBuild(story)
│ (see Auto-Generated Table of Contents section)
│
└─ NO → Use SimpleDocTemplate + doc.build(story)
(basic documents, or with optional Cross-References)When to use each approach:
| Requirement | DocTemplate | Build Method |
|---|---|---|
| Multi-page with TOC | TocDocTemplate | multiBuild() |
| Single-page or no TOC | SimpleDocTemplate | build() |
| With Cross-References (no TOC) | SimpleDocTemplate | build() |
| Both TOC + Cross-References | TocDocTemplate | multiBuild() |
⚠️ CRITICAL:
multiBuild()is ONLY needed when usingTableOfContents- Using
build()withTocDocTemplate= TOC won't work - Using
multiBuild()withoutTocDocTemplate= unnecessary overhead
Rich Text Formatting: Bold, Superscript, Subscript, and Special Characters
Prerequisites
To use <b>, <super>, <sub> tags, you must:
1. Register your fonts via registerFont() 2. Call registerFontFamily() to link normal/bold/italic variants 3. Wrap all tagged text in Paragraph() objects CRITICAL: These tags ONLY work inside Paragraph() objects. Plain strings like '<b>Text</b>' will NOT render correctly.
Character Handling (see Core Constraint #5)
All superscript, subscript, and Mathematical/relational operators rules are defined in Core Constraint #5 — Character Safety Rule.
Quick reminder when writing Rich Text:
<b>,<super>,<sub>tags ONLY work insideParagraph()objects- Must call
registerFontFamily()first to enable these tags - Plain strings like
'<b>Text</b>'will NOT render — always useParagraph() - For scientific notation:
Paragraph('coefficient × 10<super>exponent</super>', style) - For chemical formulas:
Paragraph('H<sub>2</sub>O', style)
Do NOT use any unicode escape sequence(e.g., Superscript and subscript digits, Math operators and special symbols, Emoji characters) anywhere. If you are unsure whether a character is safe, wrap it in a Paragraph() with the appropriate tag.
Complete Python Example
# --- Register fonts and font family ---
pdfmetrics.registerFont(TTFont('Times New Roman', '/usr/share/fonts/truetype/english/Times-New-Roman.ttf'))
# CRITICAL: Must call registerFontFamily() to enable <b> and <i> tags
registerFontFamily('Times New Roman', normal='Times New Roman', bold='Times New Roman')
# --- Define styles ---
body_style = ParagraphStyle(
name='BodyStyle',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_JUSTIFY,
)
bold_style = ParagraphStyle(
name='BoldStyle',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_JUSTIFY,
)
header_style = ParagraphStyle(
name='HeaderStyle',
fontName='Times New Roman',
fontSize=10,
textColor=colors.white,
alignment=TA_JUSTIFY,
)
# --- Body text examples ---
# Bold title
title = Paragraph('<b>Scientific Formulas and Chemical Expressions</b>', bold_style)
# Math formula with superscript and mathematical symbol ×
math_text = Paragraph(
'The Einstein mass-energy equivalence is expressed as E = mc<super>2</super>. '
'In applied physics, the gravitational force is F = 6.674 × 10<super>-11</super> × '
'm<sub>1</sub>m<sub>2</sub>/r<super>2</super>, '
'and the quadratic formula solves a<super>2</super> + b<super>2</super> = c<super>2</super>.',
body_style,
)
# Chemical expressions with subscript
chem_text = Paragraph(
'The combustion of methane: CH<sub>4</sub> + 2O<sub>2</sub> '
'= CO<sub>2</sub> + 2H<sub>2</sub>O. '
'Sulfuric acid (H<sub>2</sub>SO<sub>4</sub>) reacts with sodium hydroxide to produce '
'Na<sub>2</sub>SO<sub>4</sub> and water.',
body_style,
)Preventing Unwanted Line Breaks
Problem 1: English names broken at awkward positions
# PROHIBITED: "K.G. Palepu" may break after "K.G."
text = Paragraph("Professors (K.G. Palepu) proposed...",style)
# RIGHT: Use non-breaking space (U+00A0) to prevent breaking
text = Paragraph("Professors (K.G.\u00A0Palepu) proposed...",style)Problem 2: Punctuation at line start
# RIGHT: Add wordWrap='CJK' for proper typography
styles.add(ParagraphStyle(
name='BodyStyle',
fontName='SimHei',
fontSize=10.5,
leading=18,
alignment=TA_LEFT,
wordWrap='CJK' # Prevents orphaned punctuation
))Problem 3: Creating intentional line breaks
# PROHIBITED: Normal newline character does NOT create line breaks
text = Paragraph("Line 1\nLine 2\nLine 3", style) # Will render as single line!
# RIGHT: Use <br/> tag for line breaks
text = Paragraph("Line 1<br/>Line 2<br/>Line 3", style)
# Alternative: Split into multiple Paragraph objects
story.append(Paragraph("Line 1", style))
story.append(Paragraph("Line 2", style))
story.append(Paragraph("Line 3", style))Basic PDF Creation
from reportlab.lib.pagesizes import letter
from reportlab.pdfgen import canvas
c = canvas.Canvas("hello.pdf", pagesize=letter)
width, height = letter
# Add text
c.drawString(100, height - 100, "Hello World!")
c.drawString(100, height - 120, "This is a PDF created with reportlab")
# Add a line
c.line(100, height - 140, 400, height - 140)
# Save
c.save()Auto-Generated Table of Contents
⚠️ CRITICAL WARNINGS
❌ FORBIDDEN: Manual Table of Contents
NEVER manually create TOC like this:
# ❌ PROHIBIT - DO NOT USE
toc_entries = [("1. Title", "5"), ("2. Section", "10")]
for entry, page in toc_entries:
story.append(Paragraph(f"{entry} {'.'*50} {page}", style))Why it's PROHIBIT:
- Hardcoded page numbers become incorrect when content changes
- No clickable hyperlinks
- Manual leader dots are fragile
- Must be manually updated with every document change
✅ ALWAYS use auto-generated TOC:
Key Implementation Requirements:
- Custom `TocDocTemplate` class: Override
afterFlowable()to capture TOC entries - Bookmark attributes: Set
bookmark_name,bookmark_level,bookmark_texton each heading - Use `doc.multiBuild(story)`: NOT
doc.build()- multiBuild is required for TOC processing - Clickable hyperlinks: Generated automatically with proper styling
Helper Function Pattern:
def add_heading(text, style, level=0):
"""Create heading with bookmark for auto-TOC"""
p = Paragraph(text, style)
p.bookmark_name = text
p.bookmark_level = level
p.bookmark_text = text
return p
# Usage:
story.append(add_heading("1. Introduction", styles['Heading1'], 0))
story.append(Paragraph('Content...', styles['Normal']))Complete TOC Implementation Example
Copy and adapt this complete working code for your PDF with Table of Contents:
from reportlab.lib.pagesizes import letter
from reportlab.platypus import SimpleDocTemplate, Paragraph, PageBreak, Spacer
from reportlab.platypus.tableofcontents import TableOfContents
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.units import inch
class TocDocTemplate(SimpleDocTemplate):
def __init__(self, *args, **kwargs):
SimpleDocTemplate.__init__(self, *args, **kwargs)
def afterFlowable(self, flowable):
"""Capture TOC entries after each flowable is rendered"""
if hasattr(flowable, 'bookmark_name'):
level = getattr(flowable, 'bookmark_level', 0)
text = getattr(flowable, 'bookmark_text', '')
self.notify('TOCEntry', (level, text, self.page))
# Create document
doc = TocDocTemplate("document.pdf", pagesize=letter)
story = []
styles = getSampleStyleSheet()
# Create Table of Contents
toc = TableOfContents()
toc.levelStyles = [
ParagraphStyle(name='TOCHeading1', fontSize=14, leftIndent=20,
fontName='Times New Roman'),
ParagraphStyle(name='TOCHeading2', fontSize=12, leftIndent=40,
fontName='Times New Roman'),
]
story.append(Paragraph("<b>Table of Contents</b>", styles['Title']))
story.append(Spacer(1, 0.2*inch))
story.append(toc)
story.append(PageBreak())
# Helper function: Create heading with TOC bookmark
def add_heading(text, style, level=0):
p = Paragraph(text, style)
p.bookmark_name = text
p.bookmark_level = level
p.bookmark_text = text
return p
# Chapter 1: Introduction
story.append(add_heading("Chapter 1: Introduction", styles['Heading1'], 0))
story.append(Paragraph("This is the introduction chapter with some example content.",
styles['Normal']))
story.append(Spacer(1, 0.2*inch))
story.append(add_heading("1.1 Background", styles['Heading2'], 1))
story.append(Paragraph("Background information goes here.", styles['Normal']))
# Chapter 2: Conclusion
story.append(add_heading("Chapter 2: Conclusion", styles['Heading1'], 0))
story.append(Paragraph("This concludes our document.", styles['Normal']))
story.append(Spacer(1, 0.2*inch))
story.append(add_heading("2.1 Summary", styles['Heading2'], 1))
story.append(Paragraph("Summary of the document.", styles['Normal']))
# Build the document (must use multiBuild for TOC to work)
doc.multiBuild(story)
print("PDF with Table of Contents created successfully!")Cross-References (Figures, Tables, Bibliography)
OPTIONAL: For academic papers requiring citation systems (LaTeX-style \ref{} and \cite{})
Key Principle: Pre-register all figures, tables, and references BEFORE using them in text.
Simple Implementation Pattern:
from reportlab.lib.pagesizes import letter
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib.enums import TA_CENTER
from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, PageBreak
from reportlab.lib import colors
from reportlab.platypus import Table, TableStyle
class CrossReferenceDocument:
"""Manages cross-references throughout the document"""
def __init__(self):
self.figures = {}
self.tables = {}
self.refs = {}
self.figure_counter = 0
self.table_counter = 0
self.ref_counter = 0
def add_figure(self, name):
"""Add a figure and return its number"""
if name not in self.figures:
self.figure_counter += 1
self.figures[name] = self.figure_counter
return self.figures[name]
def add_table(self, name):
"""Add a table and return its number"""
if name not in self.tables:
self.table_counter += 1
self.tables[name] = self.table_counter
return self.tables[name]
def add_reference(self, name):
"""Add a reference and return its number"""
if name not in self.refs:
self.ref_counter += 1
self.refs[name] = self.ref_counter
return self.refs[name]
def build_document():
doc = SimpleDocTemplate("cross_ref.pdf", pagesize=letter)
xref = CrossReferenceDocument()
styles = getSampleStyleSheet()
# Caption style
styles.add(ParagraphStyle(
name='Caption',
parent=styles['Normal'],
alignment=TA_CENTER,
fontSize=10,
textColor=colors.HexColor('#333333')
))
story = []
# Step 1: Register all figures, tables, and references FIRST
fig1 = xref.add_figure('sample')
table1 = xref.add_table('data')
ref1 = xref.add_reference('author2024')
# Step 2: Use them in text
intro = f"""
See Figure {fig1} for details and Table {table1} for data<sup>[{ref1}]</sup>.
"""
story.append(Paragraph(intro, styles['Normal']))
story.append(Spacer(1, 0.2*inch))
# Step 3: Create figures and tables with numbered captions
story.append(Paragraph(f"<b>Figure {fig1}.</b> Sample Figure Caption",
styles['Caption']
))
# Table example
header_style = ParagraphStyle(
name='TableHeader',
fontName='Times New Roman',
fontSize=11,
textColor=colors.white,
alignment=TA_CENTER
)
cell_style = ParagraphStyle(
name='TableCell',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_CENTER
)
# All text content wrapped in Paragraph()
data = [
[Paragraph('<b>Item</b>', header_style), Paragraph('<b>Value</b>', header_style)],
[Paragraph('A', cell_style), Paragraph('10', cell_style)],
[Paragraph('B', cell_style), Paragraph('20', cell_style)],
]
t = Table(data, colWidths=[2*inch, 2*inch])
t.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1F4E79')),
('TEXTCOLOR', (0, 0), (-1, 0), colors.white),
('ALIGN', (0, 0), (-1, -1), 'CENTER'),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
]))
story.append(t)
story.append(Spacer(1, 6))
story.append(Paragraph(f"<b>Table {table1}.</b> Sample Data Table",
styles['Caption']
))
story.append(PageBreak())
# Step 4: Reference again in discussion
discussion = f"""
As shown in Figure {fig1} and Table {table1}, results are clear<sup>[{ref1}]</sup>.
"""
story.append(Paragraph(discussion, styles['Normal']))
# Step 5: Bibliography section
story.append(PageBreak())
story.append(Paragraph("<b>References</b>", styles['Heading1']))
story.append(Paragraph(
f"[{ref1}] Author, A. (2024). Example Reference. <i>Journal Name</i>.",
styles['Normal']
))
doc.build(story)
print("PDF with cross-references created!")
if __name__ == '__main__':
build_document()Usage Notes:
- Pre-registration is critical: Call
add_figure()/add_table()/add_reference()at the START of your document - Citation format: Use
Paragraph('<sup>[{ref_num}]</sup>')for inline citations - Caption format: Use
Paragraph('<b>Figure {num}.</b>')orParagraph('<b>Table {num}.</b>')with centered caption style - Combine with TOC: Use
TocDocTemplate+doc.multiBuild(story)if both cross-refs and auto-TOC are needed
Command-Line Tools
pdftotext (poppler-utils)
# Extract text
pdftotext input.pdf output.txt
# Extract text preserving layout
pdftotext -layout input.pdf output.txt
# Extract specific pages
pdftotext -f 1 -l 5 input.pdf output.txt # Pages 1-5qpdf
# Merge PDFs
qpdf --empty --pages file1.pdf file2.pdf -- merged.pdf
# Split pages
qpdf input.pdf --pages . 1-5 -- pages1-5.pdf
qpdf input.pdf --pages . 6-10 -- pages6-10.pdf
# Rotate pages
qpdf input.pdf output.pdf --rotate=+90:1 # Rotate page 1 by 90 degrees
# Remove password
qpdf --password=mypassword --decrypt encrypted.pdf decrypted.pdfCommon Tasks
Brand PDFs with RoutinAI Metadata
⚠️ CRITICAL MANDATORY RULE - PDF Metadata MUST be Added After Every PDF Generation
All PDFs MUST have metadata added immediately after creation - This is the FINAL step and CANNOT be skipped
Usage - Standalone Script:
# Add metadata to a single PDF (in-place)
python scripts/add_routinai_metadata.py document.pdf
# Add metadata to a single PDF (create new file)
python scripts/add_routinai_metadata.py input.pdf -o output.pdf
# Add metadata with custom title
python scripts/add_routinai_metadata.py report.pdf -t "Q4 Financial Analysis"
# Batch process all PDFs in current directory
python scripts/add_routinai_metadata.py *.pdf
# Quiet mode (no output)
python scripts/add_routinai_metadata.py document.pdf -q
# Show help
python scripts/add_routinai_metadata.py --helpRequirements:
After doc.build(story) completes → Immediately call the script Do NOT wait for user reminder, Do NOT check task description - Execute automatically Confirm metadata info to user after adding Memory phrase: PDF build done, metadata must add, no need to remind
Extract Text from Scanned PDFs
# Requires: pip install pytesseract pdf2image
import pytesseract
from pdf2image import convert_from_path
# Convert PDF to images
images = convert_from_path('scanned.pdf')
# OCR each page
text = ""
for i, image in enumerate(images):
text += f"Page {i+1}:\n"
text += pytesseract.image_to_string(image)
text += "\n\n"
print(text)Add Watermark
from pypdf import PdfReader, PdfWriter
# Create watermark (or load existing)
watermark = PdfReader("watermark.pdf").pages[0]
# Apply to all pages
reader = PdfReader("document.pdf")
writer = PdfWriter()
for page in reader.pages:
page.merge_page(watermark)
writer.add_page(page)
with open("watermarked.pdf", "wb") as output:
writer.write(output)Password Protection
from pypdf import PdfReader, PdfWriter
reader = PdfReader("input.pdf")
writer = PdfWriter()
for page in reader.pages:
writer.add_page(page)
# Add password
writer.encrypt("userpassword", "ownerpassword")
with open("encrypted.pdf", "wb") as output:
writer.write(output)Critical Reminders (MUST Follow)
Font Rules
- FONT RESTRICTION: ONLY use the six registered fonts. NEVER use Arial, Helvetica, Courier, or any unregistered fonts.
- In tables: ALL Chinese text and numbers MUST use
SimHeifor Chinese PDF.
ALL English text and numbers MUST use Times New Roman for English PDF. ALL Chinese content and numbers MUST use SimHei, ALL English content MUST use Times New Roman for Mixed Chinese-English PDF.
- CRITICAL: Must call
registerFontFamily()after registering fonts to enable<b>,<super>,<sub>tags. - Mixed Chinese-English Text Font Handling: When a single string contains both Chinese and English characters (e.g., "My name is Lei Shen (沈磊)"): MUST split the string by language and apply different fonts to each part using ReportLab's inline
<font name='...'>tags withinParagraphobjects. English fonts (e.g.,Times New Roman) cannot render Chinese characters (they appear as blank boxes), and Chinese fonts (e.g.,SimHei) render English with poor spacing. Must setParagraphStyle.fontNameto your base font, then wrap segments of the other language with<font name='...'>inline tags.
from reportlab.lib.styles import ParagraphStyle
from reportlab.platypus import Paragraph
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
pdfmetrics.registerFont(TTFont('SimHei', '/usr/share/fonts/truetype/chinese/SimHei.ttf'))
pdfmetrics.registerFont(TTFont('Times New Roman', '/usr/share/fonts/truetype/english/Times-New-Roman.ttf'))
# Base font is English; wrap Chinese parts:
enbody_style = ParagraphStyle(
name="ENBodyStyle",
fontName="Times New Roman", # Base font for English
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
)
# Wrap Chinese segments with <font> tag
story.append(Paragraph(
'RoutinAI Copilot (<font name="SimHei">RoutinAI 协作助手</font>) is built by routin.ai'
'My name is RoutinAI Analyst (<font name="SimHei">洞察顾问</font>)',
'<font name="SimHei">企业智能文档</font> services are powered by routin.ai.',
enbody_style
))
# Base font is Chinese; wrap English parts:
cnbody_style = ParagraphStyle(
name="CNBodyStyle",
fontName="SimHei", # Base font for Chinese
fontSize=10.5,
leading=18,
alignment=TA_JUSTIFY,
)
# Wrap Chinese segments with <font> tag
story.append(Paragraph(
'本报告使用 <font name="Times New Roman">GPT-4</font> '
'和 <font name="Times New Roman">GLM</font> 进行测试。',
cnbody_style
))Rich Text Tags (<b>, <super>, <sub>)
- These tags ONLY work inside
Paragraph()objects — plain strings will NOT render them. - Character Safety: Follow Core Constraint #5 strictly. Do not use forbidden Unicode superscript/subscript/math characters anywhere in the code. Always use
<super>,<sub>,<b>tags insideParagraph(). - Scientific Notation in Tables:
Paragraph('1.246 × 10<super>8</super>', style)— never write large numbers as plain digits.
Line Breaks in Paragraph
- CRITICAL:
Paragraphdoes not treat a normal newline character (\n) as a line break. To create line breaks, you must use<br/>(or split the content into multipleParagraphobjects).
sms3 = \\\"\\\"\\\"Hi [FIRST_NAME]
You're invited! Join us for an exclusive first look at the Carolina Herrera Resort 2025 collection—before it opens to the public.
[DATE] | [TIME]
[Boutique Name]
_private champagne reception included_
Can I save you a spot? Just let me know!
[Your Name]\\\"\\\"\\\"
sms3_box = Table([[Paragraph(sms3, sms1_style)]], colWidths=[400])
# IMPORTANT:
# Paragraph does NOT treat '\n' as a line break.
# Use <br/> to force line breaks.
sms3 = """Hi [FIRST_NAME]<br/><br/>
You're invited! Join us for an exclusive first look at the Carolina Herrera Resort 2025 collection—before it opens to the public.<br/><br/>
[DATE] | [TIME]<br/>
[Boutique Name]<br/><br/>
<i>private champagne reception included</i><br/><br/>
Can I save you a spot? Just let me know!<br/><br/>
[Your Name]"""
sms3_box = Table([[Paragraph(sms3, sms1_style)]], colWidths=[400])Body Title & Heading Styles
- All titles and sub-titles (except for Table headers): Must be bold with black text - use
Paragraph('<b>Title</b>', style)+textColor=colors.black.
Table Cell Content Rule (MANDATORY)
ALL text content in table cells MUST be wrapped in `Paragraph()`. This is NON-NEGOTIABLE.
❌ PROHIBITED - Plain strings in table cells:
# NEVER DO THIS - formatting will NOT work
data = [
['<b>Header</b>', 'Value'], # Bold won't render
['Temperature', '25°C'], # No style control
['Pressure', '1.01 × 10<super>5</super>'], # Superscript won't work
]✅ REQUIRED - All table text MUST wrapped in Paragraph:
# ALWAYS DO THIS
data = [
[Paragraph('<b>Header</b>', header_style), Paragraph('Value', header_style)],
[Paragraph('Temperature', cell_style), Paragraph('25°C', cell_style)],
[Paragraph('Pressure', cell_style), Paragraph('1.01 × 10<super>5</super>', cell_style)],
]Why this is mandatory:
- Rendering formatting tags (
<b>,<super>,<sub>,<i>) - Proper font application
- Correct text alignment within cells
- Consistent styling across the table
The ONLY exception: Image() objects can be placed directly in table cells without Paragraph wrapping.
Table Style Specifications
- Header style: Must be bold with white text on dark blue background - use
Paragraph('<b>Header</b>', header_style)+textColor=colors.white. - Standard color scheme: Dark blue header (
#1F4E79), alternating white/light gray rows. - Color consistency: If a single PDF contains multiple tables, only one color scheme is allowed across all tables.
- Alignment: Each body element within the same table must use the same alignment method.
- Caption: ALL table captions must be centered and followed by
Spacer(1, 18)before next content. - Spacing: Add
Spacer(1, 18)BEFORE tables to maintain symmetric spacing with bottom.
Document Structure
- A PDF can contain ONLY ONE cover page and ONE back cover page.
- The cover page and the back cover page MUST use the alignment method specified by
TA_JUSTIFY. - PDF Metadata (REQUIRED): Title MUST match filename; Author and Creator MUST be "RoutinAI"; Subject SHOULD describe purpose.
Image Handling
- Preserve aspect ratio: Never adjust image aspect ratio. Must insert according to the original ratio.
from PIL import Image as PILImage
from reportlab.platypus import Image
# Get original dimensions
pil_img = PILImage.open('image.png')
orig_w, orig_h = pil_img.size
# Scale to fit width while preserving aspect ratio
target_width = 400
scale = target_width / orig_w
img = Image('image.png', width=target_width, height=orig_h * scale)Final Code Check
- Verify function parameter order against documentation.
- Confirm list/array element type consistency; test-run immediately.
- Use
Paragraph(notPreformatted) for body text and formulas.
MANDATORY: Post-Generation Forbidden Character Sanitization
After the complete Python code is written and BEFORE executing it, you MUST sanitize the code using the pre-built script located at:
scripts/sanitize_code.pyThis script catches any forbidden Unicode characters (superscript/subscript digits, math operators, emoji, HTML entities, literal \uXXXX escapes) that may have slipped through despite the prevention rules. It converts them to safe ReportLab <super>/<sub> tags or ASCII equivalents.
⚠️ CRITICAL RULE: You MUST ALWAYS write PDF generation code to a .py file first, then sanitize it, then execute it. NEVER use `python -c "..."` or heredoc (`python3 << 'EOF'`) to run PDF generation code directly — these patterns bypass the sanitization step and risk forbidden characters reaching the final PDF.
Mandatory workflow (NO EXCEPTIONS):
# Step 1: ALWAYS write code to a .py file first
cat > generate_pdf.py << 'PYEOF'
# ... your PDF generation code here ...
PYEOF
# Step 2: Sanitize forbidden characters (MUST run before execution)
python scripts/sanitize_code.py generate_pdf.py
# Step 3: Execute the sanitized code
python generate_pdf.pyForbidden patterns — NEVER do any of the following:
# ❌ PROHIBITED: python -c with inline code (cannot be sanitized)
python -c "from reportlab... doc.build(story)"
# ❌ PROHIBITED: heredoc without saving to file first (cannot be sanitized)
python3 << 'EOF'
from reportlab...
EOF
# ❌ PROHIBITED: executing the .py file WITHOUT sanitizing first
python generate_pdf.py # Missing sanitization step!✅ CORRECT: The ONLY allowed execution pattern:
# 1. Write to file → 2. Sanitize → 3. Execute
cat > generate_pdf.py << 'PYEOF'
...code...
PYEOF
python scripts/sanitize_code.py generate_pdf.py
python generate_pdf.py⚠️ This sanitization step is NON-OPTIONAL. Even if you believe the code contains no forbidden characters, you MUST still run the sanitization script. It serves as a safety net to catch any characters that bypassed prevention rules.
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
- For advanced table of content template, see reference.md
CRITICAL: You MUST complete these steps in order. Do not skip ahead to writing code.
If you need to fill out a PDF form, first check to see if the PDF has fillable form fields. Run this script from this file's directory: python scripts/check_fillable_fields <file.pdf>, and depending on the result go to either the "Fillable fields" or "Non-fillable fields" and follow those instructions.
Fillable fields
If the PDF has fillable form fields:
- Run this script from this file's directory:
python scripts/extract_form_field_info.py <input.pdf> <field_info.json>. It will create a JSON file with a list of fields in this format:
[
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"rect": ([left, bottom, right, top] bounding box in PDF coordinates, y=0 is the bottom of the page),
"type": ("text", "checkbox", "radio_group", or "choice"),
},
// Checkboxes have "checked_value" and "unchecked_value" properties:
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "checkbox",
"checked_value": (Set the field to this value to check the checkbox),
"unchecked_value": (Set the field to this value to uncheck the checkbox),
},
// Radio groups have a "radio_options" list with the possible choices.
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "radio_group",
"radio_options": [
{
"value": (set the field to this value to select this radio option),
"rect": (bounding box for the radio button for this option)
},
// Other radio options
]
},
// Multiple choice fields have a "choice_options" list with the possible choices:
{
"field_id": (unique ID for the field),
"page": (page number, 1-based),
"type": "choice",
"choice_options": [
{
"value": (set the field to this value to select this option),
"text": (display text of the option)
},
// Other choice options
],
}
]- Convert the PDF to PNGs (one image for each page) with this script (run from this file's directory):
python scripts/convert_pdf_to_images.py <file.pdf> <output_directory> Then analyze the images to determine the purpose of each form field (make sure to convert the bounding box PDF coordinates to image coordinates).
- Create a
field_values.jsonfile in this format with the values to be entered for each field:
[
{
"field_id": "last_name", // Must match the field_id from `extract_form_field_info.py`
"description": "The user's last name",
"page": 1, // Must match the "page" value in field_info.json
"value": "Simpson"
},
{
"field_id": "Checkbox12",
"description": "Checkbox to be checked if the user is 18 or over",
"page": 1,
"value": "/On" // If this is a checkbox, use its "checked_value" value to check it. If it's a radio button group, use one of the "value" values in "radio_options".
},
// more fields
]- Run the
fill_fillable_fields.pyscript from this file's directory to create a filled-in PDF:
python scripts/fill_fillable_fields.py <input pdf> <field_values.json> <output pdf> This script will verify that the field IDs and values you provide are valid; if it prints error messages, correct the appropriate fields and try again.
Non-fillable fields
If the PDF doesn't have fillable form fields, you'll need to visually determine where the data should be added and create text annotations. Follow the below steps _exactly_. You MUST perform all of these steps to ensure that the the form is accurately completed. Details for each step are below.
- Convert the PDF to PNG images and determine field bounding boxes.
- Create a JSON file with field information and validation images showing the bounding boxes.
- Validate the the bounding boxes.
- Use the bounding boxes to fill in the form.
Step 1: Visual Analysis (REQUIRED)
- Convert the PDF to PNG images. Run this script from this file's directory:
python scripts/convert_pdf_to_images.py <file.pdf> <output_directory> The script will create a PNG image for each page in the PDF.
- Carefully examine each PNG image and identify all form fields and areas where the user should enter data. For each form field where the user should enter text, determine bounding boxes for both the form field label, and the area where the user should enter text. The label and entry bounding boxes MUST NOT INTERSECT; the text entry box should only include the area where data should be entered. Usually this area will be immediately to the side, above, or below its label. Entry bounding boxes must be tall and wide enough to contain their text.
These are some examples of form structures that you might see:
_Label inside box_
┌────────────────────────┐
│ Name: │
└────────────────────────┘The input area should be to the right of the "Name" label and extend to the edge of the box.
_Label before line_
Email: _______________________The input area should be above the line and include its entire width.
_Label under line_
_________________________
NameThe input area should be above the line and include the entire width of the line. This is common for signature and date fields.
_Label above line_
Please enter any special requests:
________________________________________________The input area should extend from the bottom of the label to the line, and should include the entire width of the line.
_Checkboxes_
Are you a US citizen? Yes □ No □For checkboxes:
- Look for small square boxes (□) - these are the actual checkboxes to target. They may be to the left or right of their labels.
- Distinguish between label text ("Yes", "No") and the clickable checkbox squares.
- The entry bounding box should cover ONLY the small square, not the text label.
Step 2: Create fields.json and validation images (REQUIRED)
- Create a file named
fields.jsonwith information for the form fields and bounding boxes in this format:
{
"pages": [
{
"page_number": 1,
"image_width": (first page image width in pixels),
"image_height": (first page image height in pixels),
},
{
"page_number": 2,
"image_width": (second page image width in pixels),
"image_height": (second page image height in pixels),
}
// additional pages
],
"form_fields": [
// Example for a text field.
{
"page_number": 1,
"description": "The user's last name should be entered here",
// Bounding boxes are [left, top, right, bottom]. The bounding boxes for the label and text entry should not overlap.
"field_label": "Last name",
"label_bounding_box": [30, 125, 95, 142],
"entry_bounding_box": [100, 125, 280, 142],
"entry_text": {
"text": "Johnson", // This text will be added as an annotation at the entry_bounding_box location
"font_size": 14, // optional, defaults to 14
"font_color": "000000", // optional, RRGGBB format, defaults to 000000 (black)
}
},
// Example for a checkbox. TARGET THE SQUARE for the entry bounding box, NOT THE TEXT
{
"page_number": 2,
"description": "Checkbox that should be checked if the user is over 18",
"entry_bounding_box": [140, 525, 155, 540], // Small box over checkbox square
"field_label": "Yes",
"label_bounding_box": [100, 525, 132, 540], // Box containing "Yes" text
// Use "X" to check a checkbox.
"entry_text": {
"text": "X",
}
}
// additional form field entries
]
}Create validation images by running this script from this file's directory for each page: `python scripts/create_validation_image.py <page_number> <path_to_fields.json> <input_image_path> <output_image_path>
The validation images will have red rectangles where text should be entered, and blue rectangles covering label text.
Step 3: Validate Bounding Boxes (REQUIRED)
Automated intersection check
- Verify that none of bounding boxes intersect and that the entry bounding boxes are tall enough by checking the fields.json file with the
check_bounding_boxes.pyscript (run from this file's directory):
python scripts/check_bounding_boxes.py <JSON file>
If there are errors, reanalyze the relevant fields, adjust the bounding boxes, and iterate until there are no remaining errors. Remember: label (blue) bounding boxes should contain text labels, entry (red) boxes should not.
Manual image inspection
CRITICAL: Do not proceed without visually inspecting validation images
- Red rectangles must ONLY cover input areas
- Red rectangles MUST NOT contain any text
- Blue rectangles should contain label text
- For checkboxes:
- Red rectangle MUST be centered on the checkbox square
- Blue rectangle should cover the text label for the checkbox
- If any rectangles look wrong, fix fields.json, regenerate the validation images, and verify again. Repeat this process until the bounding boxes are fully accurate.
Step 4: Add annotations to the PDF
Run this script from this file's directory to create a filled-out PDF using the information in fields.json: `python scripts/fill_pdf_form_with_annotations.py <input_pdf_path> <path_to_fields.json> <output_pdf_path>
© 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.
PDF Processing Advanced Reference
This document contains advanced PDF processing features, detailed examples, and additional libraries not covered in the main skill instructions.
pypdfium2 Library (Apache/BSD License)
Overview
pypdfium2 is a Python binding for PDFium (Chromium's PDF library). It's excellent for fast PDF rendering, image generation, and serves as a PyMuPDF replacement.
Render PDF to Images
import pypdfium2 as pdfium
from PIL import Image
# Load PDF
pdf = pdfium.PdfDocument("document.pdf")
# Render page to image
page = pdf[0] # First page
bitmap = page.render(
scale=2.0, # Higher resolution
rotation=0 # No rotation
)
# Convert to PIL Image
img = bitmap.to_pil()
img.save("page_1.png", "PNG")
# Process multiple pages
for i, page in enumerate(pdf):
bitmap = page.render(scale=1.5)
img = bitmap.to_pil()
img.save(f"page_{i+1}.jpg", "JPEG", quality=90)Extract Text with pypdfium2
import pypdfium2 as pdfium
pdf = pdfium.PdfDocument("document.pdf")
for i, page in enumerate(pdf):
text = page.get_text()
print(f"Page {i+1} text length: {len(text)} chars")JavaScript Libraries
pdf-lib (MIT License)
pdf-lib is a powerful JavaScript library for creating and modifying PDF documents in any JavaScript environment.
Load and Manipulate Existing PDF
import { PDFDocument } from 'pdf-lib'
import fs from 'fs'
async function manipulatePDF() {
// Load existing PDF
const existingPdfBytes = fs.readFileSync('input.pdf')
const pdfDoc = await PDFDocument.load(existingPdfBytes)
// Get page count
const pageCount = pdfDoc.getPageCount()
console.log(`Document has ${pageCount} pages`)
// Add new page
const newPage = pdfDoc.addPage([600, 400])
newPage.drawText('Added by pdf-lib', {
x: 100,
y: 300,
size: 16
})
// Save modified PDF
const pdfBytes = await pdfDoc.save()
fs.writeFileSync('modified.pdf', pdfBytes)
}Create Complex PDFs from Scratch
Note: This JavaScript example uses pdf-lib's built-in StandardFonts. For Python/reportlab, always use the six registered fonts defined in SKILL.md (SimHei, Microsoft YaHei, SarasaMonoSC, Times New Roman, Calibri, DejaVuSans).
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
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
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
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
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
# 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 elementAdvanced Image Conversion
# 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_outputExtract Embedded Images
# 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/imgqpdf Advanced Features
Complex Page Manipulation
# 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.pdfPDF Optimization and Repair
# 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.txtAdvanced Encryption
# 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.pdfAdvanced Python Techniques
pdfplumber Advanced Features
Extract Text with Precise Coordinates
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
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
Quick TOC Template (Copy-Paste Ready)
from reportlab.lib.pagesizes import A4
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, PageBreak
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.units import inch
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.pdfmetrics import registerFontFamily
# Register fonts first
pdfmetrics.registerFont(TTFont('Times New Roman', '/usr/share/fonts/truetype/english/Times-New-Roman.ttf'))
registerFontFamily('Times New Roman', normal='Times New Roman', bold='Times New Roman')
# Setup
doc = SimpleDocTemplate("report.pdf", pagesize=A4,
leftMargin=0.75*inch, rightMargin=0.75*inch)
styles = getSampleStyleSheet()
# Configure heading style
styles['Heading1'].fontName = 'Times New Roman'
styles['Heading1'].textColor = colors.black # Titles must be black
story = []
# Calculate dimensions
page_width = A4[0]
available_width = page_width - 1.5*inch
page_num_width = 50 # Fixed width for page numbers (enough for 3-4 digits)
# Calculate dots: fill space from title to page number
dots_column_width = available_width - 200 - page_num_width # Reserve space for title + page
optimal_dot_count = int(dots_column_width / 4.5) # ~4.5pt per dot at 7pt font
# Define styles
toc_style = ParagraphStyle('TOCEntry', parent=styles['Normal'],
fontName='Times New Roman', fontSize=11, leading=16)
dots_style = ParagraphStyle('LeaderDots', parent=styles['Normal'],
fontName='Times New Roman', fontSize=7, leading=16) # Smaller font for more dots
# Build TOC (use Paragraph with <b></b> for bold heading)
toc_data = [
[Paragraph('<b>Table of Contents</b>', styles['Heading1']), '', ''],
['', '', ''],
]
entries = [('Section 1', '5'), ('Section 2', '10')]
for title, page in entries:
toc_data.append([
Paragraph(title, toc_style),
Paragraph('.' * optimal_dot_count, dots_style),
Paragraph(page, toc_style)
])
# Use None for title column (auto-expand), fixed for others
toc_table = Table(toc_data, colWidths=[None, dots_column_width, page_num_width])
toc_table.setStyle(TableStyle([
('GRID', (0, 0), (-1, -1), 0, colors.white),
('LINEBELOW', (0, 0), (0, 0), 1.5, colors.black),
('ALIGN', (0, 0), (0, -1), 'LEFT'),
('ALIGN', (1, 0), (1, -1), 'LEFT'),
('ALIGN', (2, 0), (2, -1), 'RIGHT'),
('VALIGN', (0, 0), (-1, -1), 'TOP'),
('LEFTPADDING', (0, 0), (-1, -1), 0),
('RIGHTPADDING', (0, 0), (-1, -1), 0),
('TOPPADDING', (0, 2), (-1, -1), 3),
('BOTTOMPADDING', (0, 2), (-1, -1), 3),
('TEXTCOLOR', (1, 2), (1, -1), colors.HexColor('#888888')),
]))
story.append(toc_table)
story.append(PageBreak())
doc.build(story)Advanced: Table of Contents with Leader Dots
Critical Rules for TOC with Leader Dots:
1. Three-column structure: [Title, Dots, Page Number] for leader dot style 2. Column width strategy:
- Title:
None(auto-expands to content) - Dots: Calculated width =
available_width - 200 - 50(reserves space for title + page) - Page number: Fixed
50pt(enough for 3-4 digit numbers, ensures right alignment)
3. Dynamic dot count: int(dots_column_width / 4.5) for 7pt font (adjust based on font size) 4. Dot styling: Small font (7-8pt) and gray color (#888888) for professional look 5. Alignment sequence: LEFT (title) → LEFT (dots flow from title) → RIGHT (page numbers) 6. Zero padding: Essential for seamless visual connection between columns 7. Indentation: Use leading spaces in title text for hierarchy (e.g., " 1.1 Subsection")
MANDATORY STYLE REQUIREMENTS:
- ✅ USE FIXED WIDTHS: Percentage-based widths are STRICTLY FORBIDDEN. You MUST use fixed values to guarantee alignment, especially for page numbers.
- ✅ DYNAMIC LEADER DOTS: Hard-coded dot counts are STRICTLY FORBIDDEN. You MUST calculate the number of dots dynamically based on the column width to prevent overflow or wrapping.
- ✅ MINIMUM COLUMN WIDTH: The page number column MUST be at least 40pt wide. Anything less will prevent proper right alignment.
- ✅ DOT FONT SIZE: Leader dot font size MUST NOT EXCEED 8pt. Larger sizes will ruin the dot density and are unacceptable.
- ✅ DOT ALIGNMENT: Dots MUST remain left-aligned to maintain the visual flow from the title. Right-aligning dots is forbidden.
- ✅ ZERO PADDING: Padding between columns MUST be set to exactly 0. Any gap will create a break in the dot line and is not allowed.
- ✅ USE PARAGRAPH OBJECTS: Bold text MUST be wrapped in a Paragraph() object like
Paragraph('<b>Text</b>', style). Using plain strings like'<b>Text</b>'is strictly STRICTLY FORBIDDEN as styles will not render.
CRITICAL: Table Cell Content Must Use Paragraph
ALL text content in table cells MUST be wrapped in `Paragraph()` objects. This is essential for:
- Rendering formatting tags (
<b>,<super>,<sub>,<i>) - Proper font application
- Correct text alignment within cells
- Consistent styling across the table
The ONLY exception: Image() objects can be placed directly in table cells without Paragraph wrapping.
from reportlab.platypus import Table, TableStyle, Paragraph, Image
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
# Define cell styles
header_style = ParagraphStyle(
name='TableHeader',
fontName='Times New Roman',
fontSize=11,
textColor=colors.white,
alignment=TA_CENTER
)
cell_style = ParagraphStyle(
name='TableCell',
fontName='Times New Roman',
fontSize=10,
textColor=colors.black,
alignment=TA_CENTER
)
# ✅ CORRECT: All text wrapped in Paragraph()
data = [
[
Paragraph('<b>Name</b>', header_style),
Paragraph('<b>Formula</b>', header_style),
Paragraph('<b>Value</b>', header_style)
],
[
Paragraph('Water', cell_style),
Paragraph('H<sub>2</sub>O', cell_style), # Subscript works
Paragraph('18.015 g/mol', cell_style)
],
[
Paragraph('Pressure', cell_style),
Paragraph('1.01 x 10<super>5</super> Pa', cell_style), # Superscript works
Paragraph('<b>Standard</b>', cell_style) # Bold works
]
]
# ❌ WRONG: Plain strings - NO formatting will render
# data = [
# ['<b>Name</b>', '<b>Formula</b>', '<b>Value</b>'], # Bold won't work!
# ['Water', 'H<sub>2</sub>O', '18.015 g/mol'], # Subscript won't work!
# ]
# Image exception - Image objects go directly, no Paragraph needed
# data_with_image = [
# [Paragraph('<b>Logo</b>', header_style), Paragraph('<b>Description</b>', header_style)],
# [Image('logo.png', width=50, height=50), Paragraph('Company logo', cell_style)],
# ]
table = Table(data, colWidths=[100, 150, 100])
table.setStyle(TableStyle([
('BACKGROUND', (0, 0), (-1, 0), colors.HexColor('#1F4E79')),
('GRID', (0, 0), (-1, -1), 0.5, colors.grey),
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
]))Debug Tips for Layout Issues
from reportlab.platypus import HRFlowable
from reportlab.lib.colors import red
# Visualize spacing during development
story.append(table)
story.append(HRFlowable(width="100%", color=red, thickness=0.5, spaceBefore=0, spaceAfter=0))
story.append(Spacer(1, 6))
story.append(HRFlowable(width="100%", color=red, thickness=0.5, spaceBefore=0, spaceAfter=0))
story.append(caption)
# This creates visual markers to see actual spacingComplex Workflows
Extract Figures/Images from PDF
Method 1: Using pdfimages (fastest)
# Extract all images with original quality
pdfimages -all document.pdf images/imgMethod 2: Using pypdfium2 + Image Processing
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 needsBatch PDF Processing with Error Handling
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}")
continueAdvanced PDF Cropping
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-pagesfor splitting large files - Process pages individually with pypdfium2
2. For Text Extraction
pdftotext -bbox-layoutis fastest for plain text extraction- Use pdfplumber for structured data and tables
- Avoid
pypdf.extract_text()for very large documents
3. For Image Extraction
pdfimagesis much faster than rendering pages- Use low resolution for previews, high resolution for final output
4. For Form Filling
- pdf-lib maintains form structure better than most alternatives
- Pre-validate form fields before processing
5. Memory Management
# Process PDFs in chunks
def process_large_pdf(pdf_path, chunk_size=10):
reader = PdfReader(pdf_path)
total_pages = len(reader.pages)
for start_idx in range(0, total_pages, chunk_size):
end_idx = min(start_idx + chunk_size, total_pages)
writer = PdfWriter()
for i in range(start_idx, end_idx):
writer.add_page(reader.pages[i])
# Process chunk
with open(f"chunk_{start_idx//chunk_size}.pdf", "wb") as output:
writer.write(output)Troubleshooting Common Issues
Encrypted PDFs
# Handle password-protected PDFs
from pypdf import PdfReader
try:
reader = PdfReader("encrypted.pdf")
if reader.is_encrypted:
reader.decrypt("password")
except Exception as e:
print(f"Failed to decrypt: {e}")Corrupted PDFs
# Use qpdf to repair
qpdf --check corrupted.pdf
qpdf --replace-input corrupted.pdfText Extraction Issues
# Fallback to OCR for scanned PDFs
import pytesseract
from pdf2image import convert_from_path
def extract_text_with_ocr(pdf_path):
images = convert_from_path(pdf_path)
text = ""
for i, image in enumerate(images):
text += pytesseract.image_to_string(image)
return textLicense Information
- pypdf: BSD License
- pdfplumber: MIT License
- pypdfium2: Apache/BSD License
- reportlab: BSD License
- poppler-utils: GPL-2 License
- qpdf: Apache License
- pdf-lib: MIT License
- pdfjs-dist: Apache License
#!/usr/bin/env python3
"""
Add RoutinAI branding metadata to PDF documents.
This script adds RoutinAI metadata (Author, Creator, Producer) to PDF files.
It can process single files or batch process multiple PDFs.
"""
import os
import sys
import argparse
from pypdf import PdfReader, PdfWriter
def add_routinai_metadata(input_pdf_path, output_pdf_path=None, custom_title=None, verbose=True):
"""
Add RoutinAI branding metadata to a PDF document.
Args:
input_pdf_path: Path to input PDF
output_pdf_path: Path to output PDF (default: overwrites input)
custom_title: Custom title to use (default: preserves original or uses filename)
verbose: Print status messages (default: True)
Sets:
- Author: RoutinAI
- Creator: RoutinAI
- Producer: https://routin.ai
- Title: Custom title, original title, or filename (in that priority)
Returns:
Path to the output PDF file
"""
# Validate input file exists
if not os.path.exists(input_pdf_path):
print(f"Error: Input file not found: {input_pdf_path}", file=sys.stderr)
sys.exit(1)
# Read the PDF
try:
reader = PdfReader(input_pdf_path)
except Exception as e:
print(f"Error: Cannot open PDF: {e}", file=sys.stderr)
sys.exit(1)
writer = PdfWriter()
# Copy all pages
for page in reader.pages:
writer.add_page(page)
# Determine title
if custom_title:
title = custom_title
else:
original_meta = reader.metadata
if original_meta and original_meta.title and original_meta.title not in ['(anonymous)', 'unspecified', None]:
title = original_meta.title
else:
# Use filename without extension as title
title = os.path.splitext(os.path.basename(input_pdf_path))[0]
# Add RoutinAI metadata
writer.add_metadata({
'/Title': title,
'/Author': 'RoutinAI',
'/Creator': 'RoutinAI',
'/Producer': 'https://routin.ai',
})
# Write output
if output_pdf_path is None:
output_pdf_path = input_pdf_path
try:
with open(output_pdf_path, "wb") as output:
writer.write(output)
except Exception as e:
print(f"Error: Cannot write output file: {e}", file=sys.stderr)
sys.exit(1)
# Print status
if verbose:
print(f"✓ Updated metadata for: {os.path.basename(input_pdf_path)}")
print(f" Title: {title}")
print(f" Author: RoutinAI")
print(f" Creator: RoutinAI")
print(f" Producer: https://routin.ai")
if output_pdf_path != input_pdf_path:
print(f" Output: {output_pdf_path}")
return output_pdf_path
def main():
"""Command-line interface for add_routinai_metadata."""
parser = argparse.ArgumentParser(
description='Add RoutinAI branding metadata to PDF documents',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Add metadata to a single PDF (in-place)
%(prog)s document.pdf
# Add metadata to a single PDF (create new file)
%(prog)s input.pdf -o output.pdf
# Add metadata with custom title
%(prog)s report.pdf -t "Q4 Financial Analysis"
# Batch process all PDFs in current directory
%(prog)s *.pdf
# Quiet mode (no output)
%(prog)s document.pdf -q
"""
)
parser.add_argument(
'input',
nargs='+',
help='Input PDF file(s) to process'
)
parser.add_argument(
'-o', '--output',
help='Output PDF path (only for single input file)'
)
parser.add_argument(
'-t', '--title',
help='Custom title for the PDF'
)
parser.add_argument(
'-q', '--quiet',
action='store_true',
help='Quiet mode (no status messages)'
)
args = parser.parse_args()
# Check if output is specified for multiple files
if args.output and len(args.input) > 1:
print("Error: --output can only be used with a single input file", file=sys.stderr)
sys.exit(1)
# Process each input file
for input_path in args.input:
# Determine output path
if len(args.input) == 1 and args.output:
output_path = args.output
else:
output_path = None # Overwrite in-place
# Determine title
if args.title:
custom_title = args.title
else:
custom_title = None
# Add metadata
add_routinai_metadata(
input_path,
output_pdf_path=output_path,
custom_title=custom_title,
verbose=not args.quiet
)
if __name__ == '__main__':
main()
import unittest
import json
import io
from check_bounding_boxes import get_bounding_box_messages
# Currently this is not run automatically in CI; it's just for documentation and manual checking.
class TestGetBoundingBoxMessages(unittest.TestCase):
def create_json_stream(self, data):
"""Helper to create a JSON stream from data"""
return io.StringIO(json.dumps(data))
def test_no_intersections(self):
"""Test case with no bounding box intersections"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 30]
},
{
"description": "Email",
"page_number": 1,
"label_bounding_box": [10, 40, 50, 60],
"entry_bounding_box": [60, 40, 150, 60]
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("SUCCESS" in msg for msg in messages))
self.assertFalse(any("FAILURE" in msg for msg in messages))
def test_label_entry_intersection_same_field(self):
"""Test intersection between label and entry of the same field"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 60, 30],
"entry_bounding_box": [50, 10, 150, 30] # Overlaps with label
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages))
self.assertFalse(any("SUCCESS" in msg for msg in messages))
def test_intersection_between_different_fields(self):
"""Test intersection between bounding boxes of different fields"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 30]
},
{
"description": "Email",
"page_number": 1,
"label_bounding_box": [40, 20, 80, 40], # Overlaps with Name's boxes
"entry_bounding_box": [160, 10, 250, 30]
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("FAILURE" in msg and "intersection" in msg for msg in messages))
self.assertFalse(any("SUCCESS" in msg for msg in messages))
def test_different_pages_no_intersection(self):
"""Test that boxes on different pages don't count as intersecting"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 30]
},
{
"description": "Email",
"page_number": 2,
"label_bounding_box": [10, 10, 50, 30], # Same coordinates but different page
"entry_bounding_box": [60, 10, 150, 30]
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("SUCCESS" in msg for msg in messages))
self.assertFalse(any("FAILURE" in msg for msg in messages))
def test_entry_height_too_small(self):
"""Test that entry box height is checked against font size"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 20], # Height is 10
"entry_text": {
"font_size": 14 # Font size larger than height
}
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages))
self.assertFalse(any("SUCCESS" in msg for msg in messages))
def test_entry_height_adequate(self):
"""Test that adequate entry box height passes"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 30], # Height is 20
"entry_text": {
"font_size": 14 # Font size smaller than height
}
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("SUCCESS" in msg for msg in messages))
self.assertFalse(any("FAILURE" in msg for msg in messages))
def test_default_font_size(self):
"""Test that default font size is used when not specified"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 20], # Height is 10
"entry_text": {} # No font_size specified, should use default 14
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("FAILURE" in msg and "height" in msg for msg in messages))
self.assertFalse(any("SUCCESS" in msg for msg in messages))
def test_no_entry_text(self):
"""Test that missing entry_text doesn't cause height check"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [60, 10, 150, 20] # Small height but no entry_text
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("SUCCESS" in msg for msg in messages))
self.assertFalse(any("FAILURE" in msg for msg in messages))
def test_multiple_errors_limit(self):
"""Test that error messages are limited to prevent excessive output"""
fields = []
# Create many overlapping fields
for i in range(25):
fields.append({
"description": f"Field{i}",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30], # All overlap
"entry_bounding_box": [20, 15, 60, 35] # All overlap
})
data = {"form_fields": fields}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
# Should abort after ~20 messages
self.assertTrue(any("Aborting" in msg for msg in messages))
# Should have some FAILURE messages but not hundreds
failure_count = sum(1 for msg in messages if "FAILURE" in msg)
self.assertGreater(failure_count, 0)
self.assertLess(len(messages), 30) # Should be limited
def test_edge_touching_boxes(self):
"""Test that boxes touching at edges don't count as intersecting"""
data = {
"form_fields": [
{
"description": "Name",
"page_number": 1,
"label_bounding_box": [10, 10, 50, 30],
"entry_bounding_box": [50, 10, 150, 30] # Touches at x=50
}
]
}
stream = self.create_json_stream(data)
messages = get_bounding_box_messages(stream)
self.assertTrue(any("SUCCESS" in msg for msg in messages))
self.assertFalse(any("FAILURE" in msg for msg in messages))
if __name__ == '__main__':
unittest.main()
from dataclasses import dataclass
import json
import sys
# Script to check that the `fields.json` file that GLM 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 GLM to read.
def get_bounding_box_messages(fields_json_stream) -> list[str]:
messages = []
fields = json.load(fields_json_stream)
messages.append(f"Read {len(fields['form_fields'])} fields")
def rects_intersect(r1, r2):
disjoint_horizontal = r1[0] >= r2[2] or r1[2] <= r2[0]
disjoint_vertical = r1[1] >= r2[3] or r1[3] <= r2[1]
return not (disjoint_horizontal or disjoint_vertical)
rects_and_fields = []
for f in fields["form_fields"]:
rects_and_fields.append(RectAndField(f["label_bounding_box"], "label", f))
rects_and_fields.append(RectAndField(f["entry_bounding_box"], "entry", f))
has_error = False
for i, ri in enumerate(rects_and_fields):
# This is O(N^2); we can optimize if it becomes a problem.
for j in range(i + 1, len(rects_and_fields)):
rj = rects_and_fields[j]
if ri.field["page_number"] == rj.field["page_number"] and rects_intersect(ri.rect, rj.rect):
has_error = True
if ri.field is rj.field:
messages.append(f"FAILURE: intersection between label and entry bounding boxes for `{ri.field['description']}` ({ri.rect}, {rj.rect})")
else:
messages.append(f"FAILURE: intersection between {ri.rect_type} bounding box for `{ri.field['description']}` ({ri.rect}) and {rj.rect_type} bounding box for `{rj.field['description']}` ({rj.rect})")
if len(messages) >= 20:
messages.append("Aborting further checks; fix bounding boxes and try again")
return messages
if ri.rect_type == "entry":
if "entry_text" in ri.field:
font_size = ri.field["entry_text"].get("font_size", 14)
entry_height = ri.rect[3] - ri.rect[1]
if entry_height < font_size:
has_error = True
messages.append(f"FAILURE: entry bounding box height ({entry_height}) for `{ri.field['description']}` is too short for the text content (font size: {font_size}). Increase the box height or decrease the font size.")
if len(messages) >= 20:
messages.append("Aborting further checks; fix bounding boxes and try again")
return messages
if not has_error:
messages.append("SUCCESS: All bounding boxes are valid")
return messages
if __name__ == "__main__":
if len(sys.argv) != 2:
print("Usage: check_bounding_boxes.py [fields.json]")
sys.exit(1)
# Input file should be in the `fields.json` format described in forms.md.
with open(sys.argv[1]) as f:
messages = get_bounding_box_messages(f)
for msg in messages:
print(msg)
import sys
from pypdf import PdfReader
# Script for GLM to run to determine whether a PDF has fillable form fields. See forms.md.
reader = PdfReader(sys.argv[1])
if (reader.get_fields()):
print("This PDF has fillable form fields")
else:
print("This PDF does not have fillable form fields; you will need to visually determine where to enter data")
import os
import sys
from pdf2image import convert_from_path
# Converts each page of a PDF to a PNG image.
def convert(pdf_path, output_dir, max_dim=1000):
images = convert_from_path(pdf_path, dpi=200)
for i, image in enumerate(images):
# Scale image if needed to keep width/height under `max_dim`
width, height = image.size
if width > max_dim or height > max_dim:
scale_factor = min(max_dim / width, max_dim / height)
new_width = int(width * scale_factor)
new_height = int(height * scale_factor)
image = image.resize((new_width, new_height))
image_path = os.path.join(output_dir, f"page_{i+1}.png")
image.save(image_path)
print(f"Saved page {i+1} as {image_path} (size: {image.size})")
print(f"Converted {len(images)} pages to PNG images")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: convert_pdf_to_images.py [input pdf] [output directory]")
sys.exit(1)
pdf_path = sys.argv[1]
output_directory = sys.argv[2]
convert(pdf_path, output_directory)
import json
import sys
from PIL import Image, ImageDraw
# Creates "validation" images with rectangles for the bounding box information that
# GLM creates when determining where to add text annotations in PDFs. See forms.md.
def create_validation_image(page_number, fields_json_path, input_path, output_path):
# Input file should be in the `fields.json` format described in forms.md.
with open(fields_json_path, 'r') as f:
data = json.load(f)
img = Image.open(input_path)
draw = ImageDraw.Draw(img)
num_boxes = 0
for field in data["form_fields"]:
if field["page_number"] == page_number:
entry_box = field['entry_bounding_box']
label_box = field['label_bounding_box']
# Draw red rectangle over entry bounding box and blue rectangle over the label.
draw.rectangle(entry_box, outline='red', width=2)
draw.rectangle(label_box, outline='blue', width=2)
num_boxes += 2
img.save(output_path)
print(f"Created validation image at {output_path} with {num_boxes} bounding boxes")
if __name__ == "__main__":
if len(sys.argv) != 5:
print("Usage: create_validation_image.py [page number] [fields.json file] [input image path] [output image path]")
sys.exit(1)
page_number = int(sys.argv[1])
fields_json_path = sys.argv[2]
input_image_path = sys.argv[3]
output_image_path = sys.argv[4]
create_validation_image(page_number, fields_json_path, input_image_path, output_image_path)
import json
import sys
from pypdf import PdfReader
# Extracts data for the fillable form fields in a PDF and outputs JSON that
# GLM uses to fill the fields. See forms.md.
# This matches the format used by PdfReader `get_fields` and `update_page_form_field_values` methods.
def get_full_annotation_field_id(annotation):
components = []
while annotation:
field_name = annotation.get('/T')
if field_name:
components.append(field_name)
annotation = annotation.get('/Parent')
return ".".join(reversed(components)) if components else None
def make_field_dict(field, field_id):
field_dict = {"field_id": field_id}
ft = field.get('/FT')
if ft == "/Tx":
field_dict["type"] = "text"
elif ft == "/Btn":
field_dict["type"] = "checkbox" # radio groups handled separately
states = field.get("/_States_", [])
if len(states) == 2:
# "/Off" seems to always be the unchecked value, as suggested by
# https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf#page=448
# It can be either first or second in the "/_States_" list.
if "/Off" in states:
field_dict["checked_value"] = states[0] if states[0] != "/Off" else states[1]
field_dict["unchecked_value"] = "/Off"
else:
print(f"Unexpected state values for checkbox `${field_id}`. Its checked and unchecked values may not be correct; if you're trying to check it, visually verify the results.")
field_dict["checked_value"] = states[0]
field_dict["unchecked_value"] = states[1]
elif ft == "/Ch":
field_dict["type"] = "choice"
states = field.get("/_States_", [])
field_dict["choice_options"] = [{
"value": state[0],
"text": state[1],
} for state in states]
else:
field_dict["type"] = f"unknown ({ft})"
return field_dict
# Returns a list of fillable PDF fields:
# [
# {
# "field_id": "name",
# "page": 1,
# "type": ("text", "checkbox", "radio_group", or "choice")
# // Per-type additional fields described in forms.md
# },
# ]
def get_field_info(reader: PdfReader):
fields = reader.get_fields()
field_info_by_id = {}
possible_radio_names = set()
for field_id, field in fields.items():
# Skip if this is a container field with children, except that it might be
# a parent group for radio button options.
if field.get("/Kids"):
if field.get("/FT") == "/Btn":
possible_radio_names.add(field_id)
continue
field_info_by_id[field_id] = make_field_dict(field, field_id)
# Bounding rects are stored in annotations in page objects.
# Radio button options have a separate annotation for each choice;
# all choices have the same field name.
# See https://westhealth.github.io/exploring-fillable-forms-with-pdfrw.html
radio_fields_by_id = {}
for page_index, page in enumerate(reader.pages):
annotations = page.get('/Annots', [])
for ann in annotations:
field_id = get_full_annotation_field_id(ann)
if field_id in field_info_by_id:
field_info_by_id[field_id]["page"] = page_index + 1
field_info_by_id[field_id]["rect"] = ann.get('/Rect')
elif field_id in possible_radio_names:
try:
# ann['/AP']['/N'] should have two items. One of them is '/Off',
# the other is the active value.
on_values = [v for v in ann["/AP"]["/N"] if v != "/Off"]
except KeyError:
continue
if len(on_values) == 1:
rect = ann.get("/Rect")
if field_id not in radio_fields_by_id:
radio_fields_by_id[field_id] = {
"field_id": field_id,
"type": "radio_group",
"page": page_index + 1,
"radio_options": [],
}
# Note: at least on macOS 15.7, Preview.app doesn't show selected
# radio buttons correctly. (It does if you remove the leading slash
# from the value, but that causes them not to appear correctly in
# Chrome/Firefox/Acrobat/etc).
radio_fields_by_id[field_id]["radio_options"].append({
"value": on_values[0],
"rect": rect,
})
# Some PDFs have form field definitions without corresponding annotations,
# so we can't tell where they are. Ignore these fields for now.
fields_with_location = []
for field_info in field_info_by_id.values():
if "page" in field_info:
fields_with_location.append(field_info)
else:
print(f"Unable to determine location for field id: {field_info.get('field_id')}, ignoring")
# Sort by page number, then Y position (flipped in PDF coordinate system), then X.
def sort_key(f):
if "radio_options" in f:
rect = f["radio_options"][0]["rect"] or [0, 0, 0, 0]
else:
rect = f.get("rect") or [0, 0, 0, 0]
adjusted_position = [-rect[1], rect[0]]
return [f.get("page"), adjusted_position]
sorted_fields = fields_with_location + list(radio_fields_by_id.values())
sorted_fields.sort(key=sort_key)
return sorted_fields
def write_field_info(pdf_path: str, json_output_path: str):
reader = PdfReader(pdf_path)
field_info = get_field_info(reader)
with open(json_output_path, "w") as f:
json.dump(field_info, f, indent=2)
print(f"Wrote {len(field_info)} fields to {json_output_path}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: extract_form_field_info.py [input pdf] [output json]")
sys.exit(1)
write_field_info(sys.argv[1], sys.argv[2])
import json
import sys
from pypdf import PdfReader, PdfWriter
from extract_form_field_info import get_field_info
# Fills fillable form fields in a PDF. See forms.md.
def fill_pdf_fields(input_pdf_path: str, fields_json_path: str, output_pdf_path: str):
with open(fields_json_path) as f:
fields = json.load(f)
# Group by page number.
fields_by_page = {}
for field in fields:
if "value" in field:
field_id = field["field_id"]
page = field["page"]
if page not in fields_by_page:
fields_by_page[page] = {}
fields_by_page[page][field_id] = field["value"]
reader = PdfReader(input_pdf_path)
has_error = False
field_info = get_field_info(reader)
fields_by_ids = {f["field_id"]: f for f in field_info}
for field in fields:
existing_field = fields_by_ids.get(field["field_id"])
if not existing_field:
has_error = True
print(f"ERROR: `{field['field_id']}` is not a valid field ID")
elif field["page"] != existing_field["page"]:
has_error = True
print(f"ERROR: Incorrect page number for `{field['field_id']}` (got {field['page']}, expected {existing_field['page']})")
else:
if "value" in field:
err = validation_error_for_field_value(existing_field, field["value"])
if err:
print(err)
has_error = True
if has_error:
sys.exit(1)
writer = PdfWriter(clone_from=reader)
for page, field_values in fields_by_page.items():
writer.update_page_form_field_values(writer.pages[page - 1], field_values, auto_regenerate=False)
# This seems to be necessary for many PDF viewers to format the form values correctly.
# It may cause the viewer to show a "save changes" dialog even if the user doesn't make any changes.
writer.set_need_appearances_writer(True)
with open(output_pdf_path, "wb") as f:
writer.write(f)
def validation_error_for_field_value(field_info, field_value):
field_type = field_info["type"]
field_id = field_info["field_id"]
if field_type == "checkbox":
checked_val = field_info["checked_value"]
unchecked_val = field_info["unchecked_value"]
if field_value != checked_val and field_value != unchecked_val:
return f'ERROR: Invalid value "{field_value}" for checkbox field "{field_id}". The checked value is "{checked_val}" and the unchecked value is "{unchecked_val}"'
elif field_type == "radio_group":
option_values = [opt["value"] for opt in field_info["radio_options"]]
if field_value not in option_values:
return f'ERROR: Invalid value "{field_value}" for radio group field "{field_id}". Valid values are: {option_values}'
elif field_type == "choice":
choice_values = [opt["value"] for opt in field_info["choice_options"]]
if field_value not in choice_values:
return f'ERROR: Invalid value "{field_value}" for choice field "{field_id}". Valid values are: {choice_values}'
return None
# pypdf (at least version 5.7.0) has a bug when setting the value for a selection list field.
# In _writer.py around line 966:
#
# if field.get(FA.FT, "/Tx") == "/Ch" and field_flags & FA.FfBits.Combo == 0:
# txt = "\n".join(annotation.get_inherited(FA.Opt, []))
#
# The problem is that for selection lists, `get_inherited` returns a list of two-element lists like
# [["value1", "Text 1"], ["value2", "Text 2"], ...]
# This causes `join` to throw a TypeError because it expects an iterable of strings.
# The horrible workaround is to patch `get_inherited` to return a list of the value strings.
# We call the original method and adjust the return value only if the argument to `get_inherited`
# is `FA.Opt` and if the return value is a list of two-element lists.
def monkeypatch_pydpf_method():
from pypdf.generic import DictionaryObject
from pypdf.constants import FieldDictionaryAttributes
original_get_inherited = DictionaryObject.get_inherited
def patched_get_inherited(self, key: str, default = None):
result = original_get_inherited(self, key, default)
if key == FieldDictionaryAttributes.Opt:
if isinstance(result, list) and all(isinstance(v, list) and len(v) == 2 for v in result):
result = [r[0] for r in result]
return result
DictionaryObject.get_inherited = patched_get_inherited
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: fill_fillable_fields.py [input pdf] [field_values.json] [output pdf]")
sys.exit(1)
monkeypatch_pydpf_method()
input_pdf = sys.argv[1]
fields_json = sys.argv[2]
output_pdf = sys.argv[3]
fill_pdf_fields(input_pdf, fields_json, output_pdf)
import json
import sys
from pypdf import PdfReader, PdfWriter
from pypdf.annotations import FreeText
# Fills a PDF by adding text annotations defined in `fields.json`. See forms.md.
def transform_coordinates(bbox, image_width, image_height, pdf_width, pdf_height):
"""Transform bounding box from image coordinates to PDF coordinates"""
# Image coordinates: origin at top-left, y increases downward
# PDF coordinates: origin at bottom-left, y increases upward
x_scale = pdf_width / image_width
y_scale = pdf_height / image_height
left = bbox[0] * x_scale
right = bbox[2] * x_scale
# Flip Y coordinates for PDF
top = pdf_height - (bbox[1] * y_scale)
bottom = pdf_height - (bbox[3] * y_scale)
return left, bottom, right, top
def fill_pdf_form(input_pdf_path, fields_json_path, output_pdf_path):
"""Fill the PDF form with data from fields.json"""
# `fields.json` format described in forms.md.
with open(fields_json_path, "r") as f:
fields_data = json.load(f)
# Open the PDF
reader = PdfReader(input_pdf_path)
writer = PdfWriter()
# Copy all pages to writer
writer.append(reader)
# Get PDF dimensions for each page
pdf_dimensions = {}
for i, page in enumerate(reader.pages):
mediabox = page.mediabox
pdf_dimensions[i + 1] = [mediabox.width, mediabox.height]
# Process each form field
annotations = []
for field in fields_data["form_fields"]:
page_num = field["page_number"]
# Get page dimensions and transform coordinates.
page_info = next(p for p in fields_data["pages"] if p["page_number"] == page_num)
image_width = page_info["image_width"]
image_height = page_info["image_height"]
pdf_width, pdf_height = pdf_dimensions[page_num]
transformed_entry_box = transform_coordinates(
field["entry_bounding_box"],
image_width, image_height,
pdf_width, pdf_height
)
# Skip empty fields
if "entry_text" not in field or "text" not in field["entry_text"]:
continue
entry_text = field["entry_text"]
text = entry_text["text"]
if not text:
continue
font_name = entry_text.get("font", "Arial")
font_size = str(entry_text.get("font_size", 14)) + "pt"
font_color = entry_text.get("font_color", "000000")
# Font size/color seems to not work reliably across viewers:
# https://github.com/py-pdf/pypdf/issues/2084
annotation = FreeText(
text=text,
rect=transformed_entry_box,
font=font_name,
font_size=font_size,
font_color=font_color,
border_color=None,
background_color=None,
)
annotations.append(annotation)
# page_number is 0-based for pypdf
writer.add_annotation(page_number=page_num - 1, annotation=annotation)
# Save the filled PDF
with open(output_pdf_path, "wb") as output:
writer.write(output)
print(f"Successfully filled PDF form and saved to {output_pdf_path}")
print(f"Added {len(annotations)} text annotations")
if __name__ == "__main__":
if len(sys.argv) != 4:
print("Usage: fill_pdf_form_with_annotations.py [input pdf] [fields.json] [output pdf]")
sys.exit(1)
input_pdf = sys.argv[1]
fields_json = sys.argv[2]
output_pdf = sys.argv[3]
fill_pdf_form(input_pdf, fields_json, output_pdf)import re
import html
import sys
from typing import Dict
# ---------- Step 0: restore literal unicode escapes/entities to real chars ----------
_RE_UNICODE_ESC = re.compile(r"(\\u[0-9a-fA-F]{4})|(\\U[0-9a-fA-F]{8})|(\\x[0-9a-fA-F]{2})")
def _restore_escapes(s: str) -> str:
# HTML entities: ³ ≤ α ...
s = html.unescape(s)
# Literal backslash escapes: "\\u00B3" -> "³"
def _dec(m: re.Match) -> str:
esc = m.group(0)
try:
if esc.startswith("\\u") or esc.startswith("\\U"):
return chr(int(esc[2:], 16))
if esc.startswith("\\x"):
return chr(int(esc[2:], 16))
except Exception:
return esc
return esc
return _RE_UNICODE_ESC.sub(_dec, s)
# ---------- Step 1: superscripts/subscripts -> <super>/<sub> ----------
_SUPERSCRIPT_MAP: Dict[str, str] = {
"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4",
"⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9",
"⁺": "+", "⁻": "-", "⁼": "=", "⁽": "(", "⁾": ")",
"ⁿ": "n", "ᶦ": "i",
}
_SUBSCRIPT_MAP: Dict[str, str] = {
"₀": "0", "₁": "1", "₂": "2", "₃": "3", "₄": "4",
"₅": "5", "₆": "6", "₇": "7", "₈": "8", "₉": "9",
"₊": "+", "₋": "-", "₌": "=", "₍": "(", "₎": ")",
"ₐ": "a", "ₑ": "e", "ₕ": "h", "ᵢ": "i", "ⱼ": "j",
"ₖ": "k", "ₗ": "l", "ₘ": "m", "ₙ": "n", "ₒ": "o",
"ₚ": "p", "ᵣ": "r", "ₛ": "s", "ₜ": "t", "ᵤ": "u",
"ᵥ": "v", "ₓ": "x",
}
def _replace_super_sub(s: str) -> str:
out = []
for ch in s:
if ch in _SUPERSCRIPT_MAP:
out.append(f"<super>{_SUPERSCRIPT_MAP[ch]}</super>")
elif ch in _SUBSCRIPT_MAP:
out.append(f"<sub>{_SUBSCRIPT_MAP[ch]}</sub>")
else:
out.append(ch)
return "".join(out)
# ---------- Step 2: symbol fallback for SimHei (protect tags, then replace) ----------
_SYMBOL_FALLBACK: Dict[str, str] = {
# Currently empty - enable entries as needed for fonts missing specific glyphs
# "±": "+/-",
# "×": "*",
# "÷": "/",
# "≤": "<=",
# "≥": ">=",
# "≠": "!=",
# "≈": "~=",
# "∞": "inf",
}
def _fallback_symbols(s: str) -> str:
# Protect <super>/<sub> tags from being modified
placeholders = {}
def _protect_tag(m: re.Match) -> str:
key = f"@@TAG{len(placeholders)}@@"
placeholders[key] = m.group(0)
return key
protected = re.sub(r"</?super>|</?sub>", _protect_tag, s)
# Replace symbols
protected = "".join(_SYMBOL_FALLBACK.get(ch, ch) for ch in protected)
# Restore tags
for k, v in placeholders.items():
protected = protected.replace(k, v)
return protected
def sanitize_code(text: str) -> str:
"""
Full sanitization pipeline for PDF generation code.
- Restore unicode escapes/entities to real characters
- Replace superscript/subscript unicode with <super>/<sub>
- Replace other risky symbols with ASCII/text fallbacks
"""
s = _restore_escapes(text)
s = _replace_super_sub(s)
s = _fallback_symbols(s)
return s
if __name__ == "__main__":
if len(sys.argv) < 2:
print("Usage: python sanitize_code.py <target_script.py>")
sys.exit(1)
target = sys.argv[1]
with open(target, "r", encoding="utf-8") as f:
code = f.read()
sanitized = sanitize_code(code)
with open(target, "w", encoding="utf-8") as f:
f.write(sanitized)
print(f"Sanitized: {target}")Related skills
FAQ
What can the pdf skill do?
It extracts text and tables, creates new PDFs, merges and splits documents, and handles PDF forms.
How does it produce bold text and superscripts?
Through ReportLab tags such as <b>, <super> and <sub> inside Paragraph() objects, not Unicode escape sequences.