Use this skill whenever the user asks for a PDF — generating a new one from scratch (reports, invoices, letters), extracting text/tables from an existing PDF, or filling in an existing PDF form. Does not cover converting an existing Word/PowerPoint/Excel file to PDF (that requires LibreOffice, not available in this environment — see file-conversion).
3,811 stars
0 votes
0 copies
4 views
Added October 1, 2026
ai-agentstypescriptpythongonode
Works with
cli
Security analysis
A100/100
Scanned October 1, 2026
$npx -y skills add pipeshub-ai/pipeshub-ai --skill pdf --agent claude-code
Installs into .claude/skills of the current project.
Are you the author of Pdf?
Add the live security badge to your README. It updates with every re-scan.
[](https://www.skillsdirectory.com/skills/pipeshub-ai-pdf)
---
name: pdf
description: "Use this skill whenever the user asks for a PDF — generating a new one from scratch (reports, invoices, letters), extracting text/tables from an existing PDF, or filling in an existing PDF form. Does not cover converting an existing Word/PowerPoint/Excel file to PDF (that requires LibreOffice, not available in this environment — see file-conversion)."
metadata:
agent-loop:
version: 1.0.0
category: documents
subcategory: pdf
tags: [pdf, pdfkit, pdf-lib, reportlab, pypdf, pdfplumber]
status: active
source: builtin
pack_name: pdf
pack_version: 1.3.0
---
# PDF documents
## Quick Reference
| Task | Default (Node) | Fallback (Python) |
|------|-----------------|--------------------|
| Generate a new PDF from scratch | `pdfkit` — write a TypeScript program (packages: ["pdfkit"]) and run it via your coding tool; pre-installed in the standard sandbox image | `reportlab`'s `platypus` — reach for this for a report with several full pages of tabular data, where `platypus.Table`'s built-in pagination beats hand-rolling page breaks in `pdfkit` (see below) |
| Fill in an existing PDF form (AcroForm) | `pdf-lib` — TypeScript program (packages: ["pdf-lib"], pre-installed); `form.getTextField(name).setText(value)` | `pypdf` (`PdfWriter.update_page_form_field_values`) |
| Extract text from an existing PDF | — (no good Node equivalent) | `pdfplumber` (also gets tables) or `pypdf` (faster, text-only) |
| Extract tables from an existing PDF | — (no good Node equivalent) | `pdfplumber` (`page.extract_tables()`) |
All of the above are already installed/allowlisted — no extra setup either way. **Default to `pdfkit`/`pdf-lib` in TypeScript for generation and form-filling** per this environment's Node-first policy for office-document creation; extraction has no solid Node option here, so stays Python.
## Generating a new PDF (pdfkit)
`pdfkit` is a low-level, stream-based PDF library — it does not have `reportlab.platypus`'s automatic page-flow layout for tables, so you place text yourself and check the vertical position before every element:
```typescript
import PDFDocument from "pdfkit";
import fs from "fs";
const doc = new PDFDocument({ size: "A4", margins: { top: 50, bottom: 50, left: 50, right: 50 } });
doc.pipe(fs.createWriteStream("report.pdf"));
doc.fontSize(18).text("Invoice", { align: "center" });
doc.moveDown();
// doc.text() without an explicit x/y auto-wraps and flows below the previous
// element, including inserting new pages for long paragraphs — only switch
// to explicit x/y positioning (below) for tabular/columnar layout.
doc.fontSize(11).text("Body text wraps and paginates automatically here.");
doc.end();
```
- Always set `size` explicitly (`"A4"` or `"LETTER"`) rather than relying on the default, since it may not match the user's locale expectation.
- Plain flowing text (paragraphs, letters) needs no manual layout — `doc.text()` alone handles wrapping and page breaks. Only tabular/columnar content needs the manual pattern below.
- For tabular data (an invoice's line items, a report's data table), `pdfkit` has no built-in table primitive — draw it with fixed per-column `x` offsets and a manual page-break check before each row:
```typescript
const startX = 50;
const colWidths = { desc: 260, qty: 60, total: 90 };
let y = doc.y;
for (const item of items) {
if (y > doc.page.height - doc.page.margins.bottom - 20) {
doc.addPage();
y = doc.page.margins.top;
}
doc.text(item.desc, startX, y, { width: colWidths.desc });
doc.text(String(item.qty), startX + colWidths.desc, y, { width: colWidths.qty, align: "right" });
doc.text(item.total.toFixed(2), startX + colWidths.desc + colWidths.qty, y, { width: colWidths.total, align: "right" });
// Compute row height from the tallest cell; fixed values cause overlapping when a cell wraps.
const rowHeight = Math.max(
doc.heightOfString(item.desc, { width: colWidths.desc }),
doc.heightOfString(String(item.qty), { width: colWidths.qty }),
doc.heightOfString(item.total.toFixed(2), { width: colWidths.total }),
) + 4; // 4pt bottom padding
y += rowHeight;
}
```
- Always compute row height from `doc.heightOfString(text, { width })` across all cells in the row and use the maximum. A fixed row height causes rows to overlap whenever any cell wraps to a second line.
- Reach for `reportlab.platypus.Table` (Python) instead when the table is long enough that hand-rolling pagination is more work than it's worth — it paginates a table across pages automatically given column widths and a `TableStyle`.
### Wide tables (6+ columns) — preventing overflow
Tables with many columns (ticket lists, dashboards, audit logs) overflow if you use portrait orientation or eyeball column widths. Follow this checklist:
1. **Switch to landscape** when column count ≥ 6:
```typescript
const doc = new PDFDocument({ size: "A4", layout: "landscape", margins: { top: 40, bottom: 40, left: 40, right: 40 } });
```
2. **Compute column widths from available page width**, never hardcode pixel values:
```typescript
const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;
// Give variable-length columns (e.g. "Summary") a proportionally larger share
const fixedColWidth = 70; // narrow cols: status, priority, type, etc.
const fixedCols = 6;
const flexColWidth = pageWidth - fixedCols * fixedColWidth; // remainder for "Summary"
```
3. **Reduce font size** to 8–9pt for dense tables (many rows and columns); header can stay 9–10pt bold.
4. **Truncate long cell text** rather than letting it overflow into the next column:
```typescript
function truncate(original: string, font: string, size: number, maxWidth: number): string {
let text = original;
while (doc.widthOfString(text, { font, size }) > maxWidth && text.length > 0) {
text = text.slice(0, -1);
}
return text.length < original.length ? text + "…" : text;
}
```
Or use `{ width: colWidth, ellipsis: true }` in the `.text()` call (pdfkit supports this natively).
5. **Wrap within cells** as a fallback — pass `{ width: colWidth }` and compute row height with `doc.heightOfString(cellText, { width: colWidth })` across all cells in the row, then use the tallest.
### Wide tables — reportlab (Python, preferred for 20+ row tables)
`reportlab.platypus.Table` handles both pagination and cell wrapping automatically when you give it explicit `colWidths` that sum to the available page width:
```python
from reportlab.lib.pagesizes import A4, landscape
from reportlab.lib.units import mm
from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
from reportlab.lib import colors
from reportlab.lib.enums import TA_LEFT
pagesize = landscape(A4)
doc = SimpleDocTemplate("report.pdf", pagesize=pagesize,
topMargin=15*mm, bottomMargin=15*mm,
leftMargin=15*mm, rightMargin=15*mm)
available_width = pagesize[0] - 30*mm # left + right margins
# Assign proportional widths — flex column gets remainder
fixed = {"key": 45*mm, "type": 30*mm, "status": 30*mm,
"priority": 28*mm, "due": 28*mm, "sprint": 35*mm}
flex_width = available_width - sum(fixed.values()) # "Summary" gets the rest
col_widths = [fixed["key"], flex_width, fixed["type"], fixed["status"],
fixed["priority"], fixed["due"], fixed["sprint"]]
# Every cell must be a Paragraph — plain strings in Table cells do NOT wrap
styles = getSampleStyleSheet()
cell_style = ParagraphStyle("Cell", parent=styles["Normal"], fontSize=8, leading=10,
splitLongWords=True, wordWrap="CJK")
header_style = ParagraphStyle("Header", parent=cell_style, fontSize=9,
textColor=colors.white, fontName="Helvetica-Bold")
def P(text, style=cell_style):
return Paragraph(str(text) if text is not None else "—", style)
table_data = [[P(h, header_style) for h in ["Key", "Summary", "Type", "Status", "Priority", "Due", "Sprint"]]]
for ticket in tickets:
table_data.append([
P(ticket["key"]),
P(ticket["summary"]),
P(ticket["type"]),
P(ticket["status"]),
P(ticket["priority"]),
P(ticket.get("due", "—")),
P(ticket.get("sprint", "—")),
])
table = Table(table_data, colWidths=col_widths, repeatRows=1)
table.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2c3e50")),
# TEXTCOLOR/FONTNAME/FONTSIZE for header are now carried by header_style Paragraph
("FONTSIZE", (0, 1), (-1, -1), 8),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.Color(0.95, 0.95, 0.95)]),
("GRID", (0, 0), (-1, -1), 0.4, colors.grey),
("VALIGN", (0, 0), (-1, -1), "TOP"),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4),
("LEFTPADDING", (0, 0), (-1, -1), 4),
("RIGHTPADDING", (0, 0), (-1, -1), 4),
]))
doc.build([table])
```
Key points:
- **`landscape(A4)`** — always use landscape for 6+ column tables.
- **`colWidths` must sum to `available_width`** — if they exceed it, the table overflows the page; if they're omitted, reportlab auto-sizes but often overflows.
- **Wrap every cell in `Paragraph(..., style)` — including headers and short cells.** Plain strings inside a `Table` cell never wrap; they clip or overflow regardless of `colWidths`. This is the #1 cause of reportlab table overflow. Use a helper `P(text, style=cell_style)` to keep the data-building code readable. Add `splitLongWords=True, wordWrap="CJK"` to `cell_style` so URLs and other long unbreakable tokens are force-split rather than overflowing.
- **`repeatRows=1`** — repeats the header row on every page when the table spans multiple pages.
- **Font size 8–9pt** for body, 9–10pt for header — standard for dense reports.
## Filling in a form (pdf-lib)
```typescript
import { PDFDocument } from "pdf-lib";
import fs from "fs";
const pdfDoc = await PDFDocument.load(fs.readFileSync("form.pdf"));
const form = pdfDoc.getForm();
for (const field of form.getFields()) console.log(field.getName(), field.constructor.name); // discover field names/types first
form.getTextField("first_name").setText("Jane");
fs.writeFileSync("filled.pdf", await pdfDoc.save());
```
Field names are exactly what the form's original author named them (often generic like `Text1`) — always call `form.getFields()` first rather than guessing. `pdf-lib` is also the right tool for merging/splitting existing PDFs (`PDFDocument.copyPages`) if that comes up alongside form-filling.
## Extracting content (Python — no solid Node equivalent for this)
- `pdfplumber`: `page.extract_text()` for reading order-preserving text, `page.extract_tables()` for tabular data — the better default for anything that might contain a table, since `pypdf`'s text extraction does not attempt to reconstruct table structure.
- `pypdf`: faster and sufficient when you only need plain text and know the source has no tables (e.g. extracting a paragraph from a text-heavy report).
- Scanned (image-only) PDFs have no extractable text via either library — check `page.extract_text()` for an empty/near-empty result and tell the user OCR would be needed (not available in this environment) rather than returning an empty string silently.
## Not supported in this environment
- Converting an existing `.docx`/`.pptx`/`.xlsx` to PDF requires a rendering engine (LibreOffice) not installed in the sandbox yet.