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:
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:
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:
- Switch to landscape when column count ≥ 6:
const doc = new PDFDocument({ size: "A4", layout: "landscape", margins: { top: 40, bottom: 40, left: 40, right: 40 } });
- Compute column widths from available page width, never hardcode pixel values:
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"
- Reduce font size to 8–9pt for dense tables (many rows and columns); header can stay 9–10pt bold.
- Truncate long cell text rather than letting it overflow into the next column:
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).
- 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:
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)
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.
1---2name: pdf3description: 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).4---56# PDF documents78## Quick Reference910| Task | Default (Node) | Fallback (Python) |11|------|-----------------|--------------------|12| 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) |13| 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`) |14| Extract text from an existing PDF | — (no good Node equivalent) | `pdfplumber` (also gets tables) or `pypdf` (faster, text-only) |15| Extract tables from an existing PDF | — (no good Node equivalent) | `pdfplumber` (`page.extract_tables()`) |1617All 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.1819## Generating a new PDF (pdfkit)2021`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:2223```typescript24import PDFDocument from "pdfkit";25import fs from "fs";2627const doc = new PDFDocument({ size: "A4", margins: { top: 50, bottom: 50, left: 50, right: 50 } });28doc.pipe(fs.createWriteStream("report.pdf"));2930doc.fontSize(18).text("Invoice", { align: "center" });31doc.moveDown();3233// doc.text() without an explicit x/y auto-wraps and flows below the previous34// element, including inserting new pages for long paragraphs — only switch35// to explicit x/y positioning (below) for tabular/columnar layout.36doc.fontSize(11).text("Body text wraps and paginates automatically here.");3738doc.end();39```4041- Always set `size` explicitly (`"A4"` or `"LETTER"`) rather than relying on the default, since it may not match the user's locale expectation.42- 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.43- 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:4445```typescript46const startX = 50;47const colWidths = { desc: 260, qty: 60, total: 90 };48let y = doc.y;4950for (const item of items) {51 if (y > doc.page.height - doc.page.margins.bottom - 20) {52 doc.addPage();53 y = doc.page.margins.top;54 }55 doc.text(item.desc, startX, y, { width: colWidths.desc });56 doc.text(String(item.qty), startX + colWidths.desc, y, { width: colWidths.qty, align: "right" });57 doc.text(item.total.toFixed(2), startX + colWidths.desc + colWidths.qty, y, { width: colWidths.total, align: "right" });58 // Compute row height from the tallest cell; fixed values cause overlapping when a cell wraps.59 const rowHeight = Math.max(60 doc.heightOfString(item.desc, { width: colWidths.desc }),61 doc.heightOfString(String(item.qty), { width: colWidths.qty }),62 doc.heightOfString(item.total.toFixed(2), { width: colWidths.total }),63 ) + 4; // 4pt bottom padding64 y += rowHeight;65}66```6768- 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.69- 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`.7071### Wide tables (6+ columns) — preventing overflow7273Tables with many columns (ticket lists, dashboards, audit logs) overflow if you use portrait orientation or eyeball column widths. Follow this checklist:74751. **Switch to landscape** when column count ≥ 6:76 ```typescript77 const doc = new PDFDocument({ size: "A4", layout: "landscape", margins: { top: 40, bottom: 40, left: 40, right: 40 } });78 ```792. **Compute column widths from available page width**, never hardcode pixel values:80 ```typescript81 const pageWidth = doc.page.width - doc.page.margins.left - doc.page.margins.right;82 // Give variable-length columns (e.g. "Summary") a proportionally larger share83 const fixedColWidth = 70; // narrow cols: status, priority, type, etc.84 const fixedCols = 6;85 const flexColWidth = pageWidth - fixedCols * fixedColWidth; // remainder for "Summary"86 ```873. **Reduce font size** to 8–9pt for dense tables (many rows and columns); header can stay 9–10pt bold.884. **Truncate long cell text** rather than letting it overflow into the next column:89 ```typescript90 function truncate(original: string, font: string, size: number, maxWidth: number): string {91 let text = original;92 while (doc.widthOfString(text, { font, size }) > maxWidth && text.length > 0) {93 text = text.slice(0, -1);94 }95 return text.length < original.length ? text + "…" : text;96 }97 ```98 Or use `{ width: colWidth, ellipsis: true }` in the `.text()` call (pdfkit supports this natively).995. **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.100101### Wide tables — reportlab (Python, preferred for 20+ row tables)102103`reportlab.platypus.Table` handles both pagination and cell wrapping automatically when you give it explicit `colWidths` that sum to the available page width:104105```python106from reportlab.lib.pagesizes import A4, landscape107from reportlab.lib.units import mm108from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph109from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle110from reportlab.lib import colors111from reportlab.lib.enums import TA_LEFT112113pagesize = landscape(A4)114doc = SimpleDocTemplate("report.pdf", pagesize=pagesize,115 topMargin=15*mm, bottomMargin=15*mm,116 leftMargin=15*mm, rightMargin=15*mm)117available_width = pagesize[0] - 30*mm # left + right margins118119# Assign proportional widths — flex column gets remainder120fixed = {"key": 45*mm, "type": 30*mm, "status": 30*mm,121 "priority": 28*mm, "due": 28*mm, "sprint": 35*mm}122flex_width = available_width - sum(fixed.values()) # "Summary" gets the rest123124col_widths = [fixed["key"], flex_width, fixed["type"], fixed["status"],125 fixed["priority"], fixed["due"], fixed["sprint"]]126127# Every cell must be a Paragraph — plain strings in Table cells do NOT wrap128styles = getSampleStyleSheet()129cell_style = ParagraphStyle("Cell", parent=styles["Normal"], fontSize=8, leading=10,130 splitLongWords=True, wordWrap="CJK")131header_style = ParagraphStyle("Header", parent=cell_style, fontSize=9,132 textColor=colors.white, fontName="Helvetica-Bold")133134def P(text, style=cell_style):135 return Paragraph(str(text) if text is not None else "—", style)136137table_data = [[P(h, header_style) for h in ["Key", "Summary", "Type", "Status", "Priority", "Due", "Sprint"]]]138for ticket in tickets:139 table_data.append([140 P(ticket["key"]),141 P(ticket["summary"]),142 P(ticket["type"]),143 P(ticket["status"]),144 P(ticket["priority"]),145 P(ticket.get("due", "—")),146 P(ticket.get("sprint", "—")),147 ])148149table = Table(table_data, colWidths=col_widths, repeatRows=1)150table.setStyle(TableStyle([151 ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#2c3e50")),152 # TEXTCOLOR/FONTNAME/FONTSIZE for header are now carried by header_style Paragraph153 ("FONTSIZE", (0, 1), (-1, -1), 8),154 ("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, colors.Color(0.95, 0.95, 0.95)]),155 ("GRID", (0, 0), (-1, -1), 0.4, colors.grey),156 ("VALIGN", (0, 0), (-1, -1), "TOP"),157 ("TOPPADDING", (0, 0), (-1, -1), 4),158 ("BOTTOMPADDING", (0, 0), (-1, -1), 4),159 ("LEFTPADDING", (0, 0), (-1, -1), 4),160 ("RIGHTPADDING", (0, 0), (-1, -1), 4),161]))162doc.build([table])163```164165Key points:166- **`landscape(A4)`** — always use landscape for 6+ column tables.167- **`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.168- **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.169- **`repeatRows=1`** — repeats the header row on every page when the table spans multiple pages.170- **Font size 8–9pt** for body, 9–10pt for header — standard for dense reports.171172## Filling in a form (pdf-lib)173174```typescript175import { PDFDocument } from "pdf-lib";176import fs from "fs";177178const pdfDoc = await PDFDocument.load(fs.readFileSync("form.pdf"));179const form = pdfDoc.getForm();180for (const field of form.getFields()) console.log(field.getName(), field.constructor.name); // discover field names/types first181182form.getTextField("first_name").setText("Jane");183fs.writeFileSync("filled.pdf", await pdfDoc.save());184```185186Field 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.187188## Extracting content (Python — no solid Node equivalent for this)189190- `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.191- `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).192- 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.193194## Not supported in this environment195196- Converting an existing `.docx`/`.pptx`/`.xlsx` to PDF requires a rendering engine (LibreOffice) not installed in the sandbox yet.