| name | pdf-editing |
| description | Complete guide for reading and editing PDF documents with PyMuPDF. |
PDF Editing Skill
CRITICAL RULES - READ FIRST
NEVER DO THESE:
- NEVER use strikethrough lines to cross out text
- NEVER rasterize or flatten the PDF to images
- NEVER convert PDF pages to PNG/JPG and draw on them
- NEVER use pdf-to-image-to-pdf workflows
- NEVER use add_redact_annot() with BLACK fill (use WHITE fill instead)
- NEVER add text NEXT TO old values - REPLACE them at the SAME position
TWO APPROACHES - CHOOSE THE RIGHT ONE:
-
For REPLACING text (e.g., updating name, email, DOB):
- Use
draw_rect() with white fill to cover old text
- Use
insert_text() at the SAME position
- Text layer is preserved
-
For TRUE REDACTION of sensitive data (e.g., student ID):
- Use
add_redact_annot(rect, fill=(1,1,1)) with WHITE fill
- Call
apply_redactions() to REMOVE text from PDF structure
- Then
insert_text() to add masked value (e.g., "****5678")
- Original text is completely removed, not just covered
Overview
USE PYTHON WITH PyMuPDF (fitz) - it is pre-installed and produces the best results.
PyMuPDF preserves the text layer properly, making text extractable after editing.
JavaScript libraries like pdf-lib may create text that tools like pypdf cannot extract.
python3 -c "import fitz; print('PyMuPDF ready')"
Reading PDF Content
import fitz
doc = fitz.open("input.pdf")
page = doc[0]
text = page.get_text()
print(text)
Finding Text Positions
rects = page.search_for("Label Text")
if rects:
rect = rects[0]
print(f"Found at: ({rect.x0}, {rect.y0}) to ({rect.x1}, {rect.y1})")
Inserting Text
page.insert_text(
(x_position, y_position),
"text to insert",
fontsize=11,
color=(0, 0, 0)
)
doc.save("output.pdf")
Common Pattern: Fill Empty Form Fields
When a form field is empty (no existing value), insert text next to the label:
import fitz
doc = fitz.open("input.pdf")
page = doc[0]
label_rect = page.search_for("FIELD LABEL:")[0]
page.insert_text((label_rect.x1 + 5, label_rect.y1), "value", fontsize=11)
from datetime import datetime
date_rect = page.search_for("Date")[0]
today = datetime.now().strftime("%Y/%m/%d")
page.insert_text((date_rect.x1 + 5, date_rect.y1), today, fontsize=11)
sig_rect = page.search_for("signature")[0]
page.insert_text((sig_rect.x1 + 5, sig_rect.y1), "Full Name", fontsize=12)
doc.save("output.pdf")
Replacing Existing Text (CRITICAL - MUST FOLLOW)
When you need to replace text that's already in the PDF with new/correct values:
- First extract the PDF text to see what's currently there
- Compare with the correct values from your input source
- For any value that needs to change: COVER with white rectangle, then insert new text AT THE SAME POSITION
WRONG vs RIGHT approaches:
WRONG - DO NOT DO THIS:
page.insert_text((old_rect.x1 + 10, old_rect.y1), new_value)
page.draw_line(start, end, color=(0,0,0))
pix = page.get_pixmap()
RIGHT - DO THIS:
import fitz
doc = fitz.open("input.pdf")
page = doc[0]
current_text = page.get_text()
print(current_text)
old_value = "..."
new_value = "..."
rects = page.search_for(old_value)
if rects:
rect = rects[0]
page.draw_rect(rect, color=(1, 1, 1), fill=(1, 1, 1), width=0)
page.insert_text((rect.x0, rect.y1), new_value, fontsize=11, color=(0, 0, 0))
doc.save("output.pdf")
Key points:
draw_rect(rect, fill=(1,1,1), width=0) draws a WHITE filled rectangle
(1, 1, 1) is white in RGB (0-1 scale)
- Insert new text at
rect.x0 (same X position), NOT rect.x1 + offset
- The old text becomes invisible under the white rectangle
- The new text appears in the same location
- Text layer is preserved (text remains extractable)
TRUE REDACTION for Sensitive Data (IMPORTANT!)
For sensitive data like student IDs, you must use TRUE REDACTION that removes the original text from the PDF structure. A white rectangle only VISUALLY covers text - tools like pypdf can still extract the hidden text!
CRITICAL DISTINCTION:
draw_rect() = Visual cover only (text still extractable by machines)
add_redact_annot() + apply_redactions() = TRUE redaction (text removed from PDF)
Example: "A12345678" should become "****5678" (show only last 4 digits)
import fitz
doc = fitz.open("input.pdf")
page = doc[0]
pdf_text = page.get_text()
original_in_pdf = "A88888888"
digits = ''.join(c for c in original_in_pdf if c.isdigit())
masked = "****" + digits[-4:]
rects = page.search_for(original_in_pdf)
if rects:
rect = rects[0]
tight_rect = fitz.Rect(rect.x0, rect.y0 + 8, rect.x1, rect.y1 - 2)
page.add_redact_annot(tight_rect, fill=(1, 1, 1))
page.apply_redactions()
page.insert_text((rect.x0, rect.y1), masked, fontsize=11, color=(0, 0, 0))
doc.save("output.pdf")
Why this works:
add_redact_annot(rect, fill=(1, 1, 1)) - marks area with WHITE rectangle
apply_redactions() - REMOVES the underlying text from PDF structure
insert_text() - adds the masked value
WRONG approaches:
page.add_redact_annot(rect, fill=(0, 0, 0))
page.draw_rect(rect, fill=(1, 1, 1))
page.insert_text(...)
page.insert_text((rect.x1 + 10, rect.y1), masked)
Workflow: Compare and Update
import fitz
doc = fitz.open("input.pdf")
page = doc[0]
pdf_text = page.get_text()
print(pdf_text)
replacements = {}
for old_val, new_val in replacements.items():
if old_val == new_val:
continue
rects = page.search_for(old_val)
if rects:
rect = rects[0]
page.draw_rect(rect, color=(1, 1, 1), fill=(1, 1, 1), width=0)
page.insert_text((rect.x0, rect.y1), new_val, fontsize=11, color=(0, 0, 0))
doc.save("output.pdf")
Important Guidelines
- COVER then REPLACE - Use white rectangle to cover old text, insert new text at SAME position
- Never use strikethrough - No lines through text, no crossing out
- Never rasterize - No converting PDF to images, no get_pixmap() workflows
- Never add text NEXT TO old values - Replace AT the same position
- Never use add_redact_annot() with black - It creates black boxes
- Preserve all labels - Form labels should remain visible
- Preserve text layer - Text must remain extractable after editing
- Match font size - Typically 10-12pt for forms
Alternative: JavaScript with pdf-lib (NOT RECOMMENDED)
WARNING: pdf-lib may create text that cannot be extracted by pypdf.
Use Python with PyMuPDF instead whenever possible.
If you must use Node.js/JavaScript, use pdf-lib with the same approach:
const { PDFDocument, rgb } = require('pdf-lib');
const fs = require('fs');
async function editPdf() {
const pdfBytes = fs.readFileSync('input.pdf');
const pdfDoc = await PDFDocument.load(pdfBytes);
const page = pdfDoc.getPages()[0];
const { height } = page.getSize();
page.drawRectangle({
x: oldTextX,
y: oldTextY,
width: oldTextWidth,
height: oldTextHeight,
color: rgb(1, 1, 1),
});
page.drawText('New Value', {
x: oldTextX,
y: oldTextY,
size: 11,
color: rgb(0, 0, 0),
});
fs.writeFileSync(, pdfDoc.());
}
WRONG with pdf-lib:
page.drawText('New Value', {
x: oldTextX + oldTextWidth + 10,
...
});
page.drawLine({
start: { x: x1, y: y },
end: { x: x2, y: y },
color: rgb(0, 0, 0),
});