| name | document-skills-pdf |
| description | Create, extract, transform, or analyze PDF documents. Use this Skill whenever a .pdf file is the input or output - generating reports, one-pagers, posters or structured documents as real selectable-text PDFs; extracting text, tables or images from existing PDFs; merging, splitting, rotating, or watermarking pages; filling forms; checking that a produced PDF has the right page count and extractable text. |
PDF workbench
PDF tasks fail when the agent mixes incompatible libraries or generates pages as screenshots.
Route by job, use one tool per job, verify the artifact.
Step 0 - Check the toolchain
python -c "import reportlab, pypdf, fitz; print('reportlab', reportlab.Version, '| pypdf ok | pymupdf ok')"
reportlab - creation (preferred: selectable text, controlled layout).
pypdf - page-level transforms: split, merge, rotate, encrypt, form fields.
pymupdf (fitz) - extraction (text with coordinates, tables, images), rasterization,
page-level inspection.
Missing any -> say so and degrade to the ones present; do not substitute screenshot pipelines.
Step 1 - Pick exactly one tool for the job
| Task | Tool | Reference |
|---|
| Build a new structured PDF (report, one-pager, letter, checklist) | ReportLab | references/create.md |
| Extract text / tables / images | PyMuPDF | references/extract.md |
| Merge, split, rotate, watermark, encrypt, forms | pypdf | references/transform.md |
| Inspect: page count, sizes, fonts, links, blank-page detection | PyMuPDF | references/inspect.md |
Chaining tools on one artifact is fine (create -> inspect). Using two creation libraries on one
file is not.
Step 2 - Shared rules
- Text-first: a generated PDF must contain extractable text. Image-of-text is acceptable
only when the user asked for a rasterized look or provides only images.
- Page geometry is explicit: A4 = 595.27 x 841.89 pt, US Letter = 612 x 792 pt. Declare
the target size and margins up front; re-check fit after generation.
- Fonts: the standard 14 fonts cover only limited encodings; they do not automatically
support arbitrary Unicode. Check that the selected face contains every requested character.
If any glyph is unsupported - including CJK, Cyrillic, Arabic, Devanagari, or emoji - embed
one or more licensed TTF/OTF fonts with the required coverage and use them for those runs.
If no suitable embeddable font is available, report the limitation instead of emitting tofu.
- Overflow is a defect: content that spills past the last page or the margin must be
detected in postcheck and fixed (shrink, paginate, or cut), never shipped.
- Write output to a new path; keep inputs untouched unless in-place was requested.
- Accessibility claims require tagged output. ReportLab does not produce a tagged PDF/UA
document, so do not describe ordinary ReportLab output as accessible or screen-reader-ready.
When PDF/UA or assistive-technology compatibility is required, use a toolchain that can create
and validate tags, reading order, structure roles, language, and alternative text. If that
toolchain is unavailable, stop and report the limitation; offer an accessible DOCX or HTML
deliverable instead of silently returning an untagged PDF.
Step 3 - Postcheck (mandatory)
import os
import fitz
import pypdf
output_path = "output.pdf"
expected_page_count = 1
password = os.environ.get("PDF_PASSWORD")
r = pypdf.PdfReader(output_path)
if r.is_encrypted:
if r.decrypt("") == 0:
if not password:
raise RuntimeError("set PDF_PASSWORD so the encrypted output can be postchecked")
if r.decrypt(password) == 0:
raise RuntimeError("PDF_PASSWORD could not decrypt the output")
def require(condition, message):
if not condition:
raise ValueError(message)
page_count = len(r.pages)
require(
page_count == expected_page_count,
f"expected {expected_page_count} pages, got {page_count}",
)
render_doc = fitz.open(output_path)
if render_doc.needs_pass and render_doc.authenticate("") <= 0:
if not password:
raise RuntimeError("set PDF_PASSWORD so encrypted widgets can be rendered")
if render_doc.authenticate(password) <= 0:
raise RuntimeError("PDF_PASSWORD could not decrypt the output for widget rendering")
page_texts = {
number: (page.extract_text() or "").strip()
for number, page in enumerate(r.pages, start=1)
}
def normalized_box(box):
x0, y0, x1, y1 = (float(value) for value in box)
left, right = sorted((x0, x1))
bottom, top = sorted((y0, y1))
return left, bottom, right, top
def normalized_size(box):
left, bottom, right, top = normalized_box(box)
return right - left, top - bottom
def widget_count(page, rendered_page):
non_viewable_flags = 1 | 2 | 32
count = 0
for ref in page.get("/Annots") or []:
widget = ref.get_object()
if widget.get("/Subtype") != "/Widget":
continue
flags = int(widget.get("/F", 0))
rectangle = widget.get("/Rect")
if flags & non_viewable_flags or rectangle is None:
continue
left, bottom, right, top = normalized_box(rectangle)
crop_left, crop_bottom, crop_right, crop_top = normalized_box(page.cropbox)
media_left, media_bottom, media_right, media_top = normalized_box(page.mediabox)
visible_left = max(crop_left, media_left)
visible_bottom = max(crop_bottom, media_bottom)
visible_right = min(crop_right, media_right)
visible_top = min(crop_top, media_top)
intersects_visible_page = (
min(right, visible_right) > max(left, visible_left)
and min(top, visible_top) > max(bottom, visible_bottom)
)
if right <= left or top <= bottom or not intersects_visible_page:
continue
xref = getattr(ref, "idnum", None)
if xref is None:
continue
try:
rendered_widget = rendered_page.load_widget(xref)
clip = (
rendered_widget.rect * rendered_page.rotation_matrix
) & rendered_page.rect
if clip.is_empty:
continue
with_widget = rendered_page.get_pixmap(
matrix=fitz.Matrix(2, 2), clip=clip, alpha=False, annots=True,
)
without_widgets = rendered_page.get_pixmap(
matrix=fitz.Matrix(2, 2), clip=clip, alpha=False, annots=False,
)
except (RuntimeError, ValueError):
continue
if with_widget.samples != without_widgets.samples:
count += 1
return count
intentionally_raster_only_pages = set()
missing_text_pages = [
number for number, text in page_texts.items()
if number not in intentionally_raster_only_pages
and not text
and widget_count(r.pages[number - 1], render_doc[number - 1]) == 0
]
require(not missing_text_pages, f"pages without extractable text: {missing_text_pages}")
expected_strings_by_page = {}
for page_number, expected_strings in expected_strings_by_page.items():
missing = [value for value in expected_strings if value not in page_texts[page_number]]
require(not missing, f"page {page_number} is missing {missing}")
expected_page_size = (595.2756, 841.8898)
page_size_tolerance = 0.5
page_sizes = [
normalized_size(page.mediabox) for page in r.pages
]
size_mismatches = [
(number, actual)
for number, actual in enumerate(page_sizes, start=1)
if any(abs(value - expected) > page_size_tolerance
for value, expected in zip(actual, expected_page_size))
]
require(not size_mismatches, f"unexpected page sizes: {size_mismatches}")
overflow_doc = render_doc
overflow_pages = []
for page in overflow_doc:
crop_left, crop_bottom, crop_right, crop_top = normalized_box(page.cropbox)
page_box = fitz.Rect(0, 0, crop_right - crop_left, crop_top - crop_bottom)
text_rects = [
fitz.Rect(block[:4])
for block in page.get_text("blocks", clip=fitz.INFINITE_RECT())
if block[6] == 0
]
image_rects = [
fitz.Rect(0, 0, 1, 1) * fitz.Matrix(*image["transform"])
for image in page.get_image_info()
]
drawing_rects = []
for drawing in page.get_drawings():
rect = fitz.Rect(drawing["rect"])
stroke_pad = (
float(drawing.get("width") or 0) / 2
if "s" in drawing.get("type", "") else 0
)
drawing_rects.append(fitz.Rect(
rect.x0 - stroke_pad, rect.y0 - stroke_pad,
rect.x1 + stroke_pad, rect.y1 + stroke_pad,
))
beyond_box = any(
rect.x0 < page_box.x0 - 0.5 or rect.y0 < page_box.y0 - 0.5
or rect.x1 > page_box.x1 + 0.5 or rect.y1 > page_box.y1 + 0.5
for rect in text_rects + image_rects + drawing_rects
)
if beyond_box:
overflow_pages.append(page.number + 1)
overflow_doc.close()
require(not overflow_pages, f"content extends past the page box on pages: {overflow_pages}")
Confirm: page count matches the request; every page except those explicitly listed in
intentionally_raster_only_pages has extractable text or at least one form widget (a pure
interactive page); each requested key string is listed in expected_strings_by_page and
extracts on the correct page; every value in page_sizes is the declared size within
page_size_tolerance; and no page's text, image placements, or vector drawings extend past
the page box (the overflow check). Report all five. For pixel-sensitive work, render every applicable page
with PyMuPDF at 100 dpi and check that each render is non-blank (mean pixel value).