Ten self-contained PDF tasks that probe how well a model creates, edits, merges, splits, and fills PDF files β from simple pdf-lib text pages to AcroForm filling, static form annotation, and multi-step document pipelines. Each task has a copy-paste prompt, a verify command, and clear pass criteria.
These tasks test the pdf skill β the model's ability to produce and manipulate PDF files using pdf-lib, qpdf, the project's PDF scripts, and the DOCXβPDF conversion pipeline. Tasks 1β3 cover pdf-lib creation. Tasks 4β5 use qpdf for splitting and rotating. Task 6 covers the DOCXβPDF route. Tasks 7β9 cover extraction, AcroForm filling, and static form annotation. Task 10 is a comprehensive multi-step pipeline. Start at Task 1 and work up.
These tasks use pdf-lib for programmatic creation, qpdf for document manipulation, and the project's own PDF scripts for form handling and annotation. Knowing these conventions helps you evaluate whether the model followed the skill correctly.
| Concept | Rule | Why |
|---|---|---|
| pdf-lib | Use for programmatic PDF creation from scratch | Zero extra deps, already installed |
| DOCXβPDF | Preferred for formatted documents | LibreOffice gives proper typography |
| qpdf | merge, split, rotate, decrypt | System CLI, powerful document surgery |
| rgb() | pdf-lib colors: rgb(0, 0, 0) | Values 0.0β1.0, not 0β255 |
| Standard fonts | Embed via StandardFonts.Helvetica | No external font files needed |
| Page size | US Letter = 612Γ792 points | Default; set explicitly when needed |
| AcroForm | Check with check_fillable_fields.js | Detect if form is fillable or static |
| Static forms | extract_form_structure.js β annotate | Find text position first, then overlay |
| Validation | check_bounding_boxes.js | Catch overlapping annotation boxes |
| Images | convert_pdf_to_images.js (needs poppler) | Visual QA of PDF output |
You need one terminal with the Aperio server running. Paste each task prompt into the chat. The model will write scripts and use CLI tools to produce .pdf files.
npm run start:local
Paste each task prompt into the chat. Use a fresh conversation per task when possible. Verify commands use pdf-lib, qpdf, and the project's PDF scripts.
Some tasks require system tools not installed by default:
# qpdf β needed for Tasks 4, 5, 10
brew install qpdf
# LibreOffice β needed for Task 6
brew install --cask libreoffice
# poppler β needed for convert_pdf_to_images.js (optional)
brew install poppler
Clean up output files before re-running against a different model:
rm -f trash/test-*.pdf trash/create-*.js trash/fields-*.json
Ordered by complexity: pdf-lib creation first (single page, multi-page, merge), then qpdf manipulation, DOCXβPDF conversion, text extraction, AcroForm filling, static form annotation, and finally a full pipeline. Each task lists dependencies, difficulty, what to look for, the prompt to paste, and the verify command.
Probes: pdf-lib API, text positioning, font embedding, page setup
Files:1 script + 1 output (trash/create-title.js β trash/test-title.pdf)
Deps: pdf-lib (built-in)
trash/create-title.js that creates trash/test-title.pdf using pdf-lib:
- US Letter page (612 Γ 792 points)
- Embed Helvetica-Bold font via StandardFonts.HelveticaBold
- Draw a title: "Aperio β Personal Memory Layer" at x=72, y=650, size=28pt, dark navy color (rgb(0.1, 0.15, 0.27))
- Draw a subtitle: "One brain. Every agent. Nothing forgotten." at x=72, y=600, size=14pt, gray color (rgb(0.39, 0.42, 0.45))
- Draw a horizontal line using drawLine from x=72 to x=540, at y=580, thickness=1.5, purple color (rgb(0.48, 0.23, 0.93))
- Draw a date line: "Generated: July 2026" at x=72, y=540, size=10pt, gray
- Use ES module import syntax and top-level await
- Save with writeFileSync%PDF- magic bytes?head -c 5 trash/test-title.pdf | grep -q '%PDF-' && echo "Valid PDF header OK" || echo "NOT a PDF"node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-title.pdf');const d=await PDFDocument.load(b);console.log('Pages:',d.getPageCount());process.exit(d.getPageCount()===1?0:1)})"Probes: addPage, multi-page document, repeated elements
Files:1 script + 1 output (trash/create-report.js β trash/test-report.pdf)
Deps: pdf-lib (built-in)
trash/create-report.js that creates a 3-page PDF at trash/test-report.pdf:
Page 1 (Cover): Title "Q2 2026 Performance Report" (28pt, bold), subtitle "Confidential" (12pt, italic, gray), date line
Page 2 (Content): Heading "Revenue Analysis" (18pt, bold), 4 paragraphs of text about quarterly revenue trends (use drawText with line wrapping β approximate by drawing multiple lines manually). Section heading "Cost Breakdown" (16pt, bold) with bullet-like text lines below.
Page 3 (Summary): Heading "Key Takeaways" (18pt, bold), 3 bullet-like text lines, footer "β End of Report β" centered at bottom.
Each page should have:
- A thin header line near the top (use drawLine)
- Page number footer: "Page X of Y" centered at bottom (x=306, y=30)
Use Helvetica font. US Letter size. ES module syntax.node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-report.pdf');const d=await PDFDocument.load(b);console.log('Pages:',d.getPageCount());process.exit(d.getPageCount()===3?0:1)})"Probes: PDFDocument.copyPages, multi-document merge, page ordering
Files:1 script + 1 output (trash/create-merge.js β trash/test-merged.pdf)
Deps: pdf-lib (built-in)
trash/create-merge.js that:
1. Creates two separate PDFs in memory using pdf-lib:
- PDF A (1 page): Title "Part 1 β Introduction" (24pt), centered. Helvetica font.
- PDF B (2 pages): Page 1 heading "Part 2 β Analysis" (20pt) with a paragraph of body text. Page 2 heading "Part 2 β Conclusion" (20pt) with a paragraph.
2. Merges them into a single PDF: PDF A then PDF B (3 total pages).
Use copyPages to copy pages from each source document into a new document.
3. Adds a centered page number "Page X of 3" at the bottom of each merged page.
4. Saves to trash/test-merged.pdf.
Use ES module syntax. US Letter size.copyPages used (not regenerating content)?node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-merged.pdf');const d=await PDFDocument.load(b);console.log('Pages:',d.getPageCount());process.exit(d.getPageCount()===3?0:1)})"Probes: qpdf CLI usage, page range extraction, file management
Files:Script + output (first create source β then split β trash/test-split-pages2-3.pdf)
Deps: qpdf (system tool β brew install qpdf)
trash/create-source.js that creates trash/test-source.pdf with 4 pages. Each page should have a large page number visible (e.g., "Page 1", "Page 2", etc.) drawn with drawText at center.
Step 2 β Split
Use qpdf to split pages 2 and 3 into a separate file:
qpdf trash/test-source.pdf --pages . 2-3 -- trash/test-split-pages2-3.pdf
Step 3 β Verify
Check that the output has exactly 2 pages (formerly pages 2 and 3) using pdf-lib:
node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-split-pages2-3.pdf');const d=await PDFDocument.load(b);console.log('Pages:',d.getPageCount());process.exit(d.getPageCount()===2?0:1)})"qpdf --pages correctly?head -c 5 trash/test-split-pages2-3.pdf | grep -q '%PDF-' && echo "Valid PDF"node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-split-pages2-3.pdf');const d=await PDFDocument.load(b);console.log('Split pages:',d.getPageCount());process.exit(d.getPageCount()===2?0:1)})"Probes: qpdf page rotation, multi-page PDF with mixed orientations
Files:Script + output (create β rotate β trash/test-rotated.pdf)
Deps: qpdf (system tool β brew install qpdf)
trash/create-rotate-source.js that creates trash/test-rotate-source.pdf. Page 1: "Normal Page" (portrait orientation). Page 2: "This text should rotate" β also drawn in portrait but will be rotated with qpdf.
Step 2 β Rotate page 2
qpdf trash/test-rotate-source.pdf --rotate=+90:2 -- trash/test-rotated.pdf
Step 3 β Verify
Check that the output has 2 pages and that the rotation was applied. The verify command loads the PDF and checks that page 2 has a non-zero rotation.--rotate flag syntax correct?node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-rotated.pdf');const d=await PDFDocument.load(b);const p2=d.getPage(1);const rot=p2.getRotation().angle;console.log('Pages:',d.getPageCount(),'Rotation pg2:',rot);process.exit(d.getPageCount()===2&&(rot===90||rot===270)?0:1)})"Probes: generate_docx tool, soffice.py conversion pipeline, file path handling
Files:DOCX β PDF (via generate_docx + soffice.py)
Deps: LibreOffice (brew install --cask libreoffice)
generate_docx MCP tool to create trash/test-source.docx β a structured report with:
- Title: "Aperio Technical Report β Q3 2026" (Heading 1)
- Section 1 "Architecture Overview" (Heading 2) β 2 paragraphs of text
- Section 2 "Performance Benchmarks" (Heading 2) β a table with 3 columns (Metric, Result, Status) and 4 data rows
- Arial font, professional styling
Step 2 β Convert to PDF
Use the LibreOffice conversion script. Note the absolute path of the docx from the Saved at: output:
python3 skills/docx/scripts/office/soffice.py --headless --convert-to pdf /absolute/path/to/trash/test-source.docx --outdir /absolute/path/to/trash
The output will be trash/test-source.pdf.
Step 3 β Verify
Check the resulting PDF is valid and has multiple pages using pdf-lib.soffice.py called with correct args?head -c 5 trash/test-source.pdf | grep -q '%PDF-' && echo "Valid PDF"node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-source.pdf');const d=await PDFDocument.load(b);console.log('Pages:',d.getPageCount());process.exit(d.getPageCount()>=1?0:1)})"Probes: pdfjs-dist usage, text content extraction, page iteration
Files:1 script + 1 output (create β then read back with pdfjs-dist)
Deps: pdfjs-dist (built-in)
trash/create-text-source.js that creates trash/test-text-source.pdf (2 pages) with these exact strings embedded:
- Page 1: "Aperio Memory Layer" (title), "Context is everything." (subtitle)
- Page 2: "Architecture Overview", "SQLite and Postgres backends"
Use Helvetica. US Letter. ES module syntax.
Step 2 β Read & verify
Write a script trash/read-pdf.js that uses pdfjs-dist (import from "pdfjs-dist/legacy/build/pdf.mjs") to load trash/test-text-source.pdf, extract text from all pages, and print it to console. The script should count pages and confirm specific strings exist in the extracted text.
Run with: node trash/read-pdf.jsnode -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-text-source.pdf');const d=await PDFDocument.load(b);console.log('Source pages:',d.getPageCount());process.exit(d.getPageCount()===2?0:1)})"node trash/read-pdf.js 2>&1 | head -20Probes: pdf-lib form creation, AcroForm fields, field extraction, field filling
Files:Scripts + JSON + output (form β extract β fill)
Deps: pdf-lib (built-in)
trash/create-form.js that creates trash/test-form.pdf with 3 AcroForm text fields using doc.createForm():
- Field "name" at position (x=100, y=650, w=300, h=30) β label: "Full Name:"
- Field "email" at position (x=100, y=580, w=300, h=30) β label: "Email Address:"
- Field "company" at position (x=100, y=510, w=300, h=30) β label: "Company:"
- Title at top: "Registration Form" (24pt, bold)
- Each field should have a border and be easily fillable
Step 2 β Extract field info
Run: node skills/pdf/scripts/check_fillable_fields.js trash/test-form.pdf
Then: node skills/pdf/scripts/extract_form_field_info.js trash/test-form.pdf trash/fields-info.json
Read the fields-info.json to confirm the 3 fields.
Step 3 β Fill the form
Create trash/fields-values.json by adding "value" keys to each field entry from step 2. Values:
- name: "Dr. Sarah Chen"
- email: "sarah.chen@novacloud.io"
- company: "NovaCloud Systems"
Run: node skills/pdf/scripts/fill_fillable_fields.js trash/test-form.pdf trash/fields-values.json trash/test-filled.pdf
Step 4 β Verify
Confirm the output PDF has filled fields using pdf-lib's doc.getForm().node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-filled.pdf');const d=await PDFDocument.load(b);const f=d.getForm();const fields=f.getFields();console.log('Filled fields:',fields.length);fields.forEach(f=>console.log(' ',f.getName(),f.getText()));process.exit(fields.length>=3?0:1)})"Probes: extract_form_structure.js, fill_pdf_form_with_annotations.js, bounding boxes
Files:Script pipeline (create static form β extract structure β annotate β fill)
Deps: pdf-lib (built-in), project scripts
trash/create-static-form.js that creates trash/test-static-form.pdf. This should look like a form but NOT have AcroForm fields:
- Title: "Employee Onboarding Form" (20pt, bold)
- Label "Employee Name:" at (x=72, y=650) followed by a horizontal line (underscore area) at (x=220, y=648, w=300) for the user to write on
- Label "Department:" at (x=72, y=600) with underscore line
- Label "Start Date:" at (x=72, y=550) with underscore line
- A bordered box section "HR Use Only" at the bottom
Step 2 β Analyze structure
Run: node skills/pdf/scripts/check_fillable_fields.js trash/test-static-form.pdf β it should report no fillable fields.
Run: node skills/pdf/scripts/extract_form_structure.js trash/test-static-form.pdf trash/structure.json β this finds label positions, lines, and checkbox rectangles.
Step 3 β Create field values JSON
Create trash/annotate-fields.json with entries for each field. Use PDF coordinates (in points, origin at bottom-left). Each entry needs:
{"entry_bounding_box": [x1, y1, x2, y2], "entry_text": "value"}
Step 4 β Annotate and fill
Run node skills/pdf/scripts/check_bounding_boxes.js trash/annotate-fields.json to validate coordinates.
Then: node skills/pdf/scripts/fill_pdf_form_with_annotations.js trash/test-static-form.pdf trash/annotate-fields.json trash/test-annotated.pdf
Step 5 β Verify
Check the output PDF is valid and has correct page count.check_fillable_fields.js correctly report no AcroForm fields?extract_form_structure.js find the labels and lines?check_bounding_boxes.js validate the coordinates?head -c 5 trash/test-annotated.pdf | grep -q '%PDF-' && echo "Valid PDF"node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-annotated.pdf');const d=await PDFDocument.load(b);console.log('Annotated pages:',d.getPageCount());process.exit(d.getPageCount()===1?0:1)})"Probes: end-to-end PDF workflow, multiple tools, output verification at each step
Files:Multi-step pipeline (create β split β rotate β merge β extract)
Deps: pdf-lib, qpdf, pdfjs-dist (all available)
trash/:
- test-a.pdf: Title "Section A β Overview" (1 page)
- test-b.pdf: Title "Section B β Technical Details" (2 pages: Technical Details, Architecture)
- test-c.pdf: Title "Section C β Appendices" (2 pages: Appendix 1, Appendix 2)
Step 2 β Split test-c
Use qpdf to split test-c.pdf pages 1 (Appendix 1) into trash/test-appendix1.pdf:
qpdf trash/test-c.pdf --pages . 1 -- trash/test-appendix1.pdf
Step 3 β Rotate test-b page 2
Rotate the Architecture page 90Β° clockwise:
qpdf trash/test-b.pdf --rotate=+90:2 -- trash/test-b-rotated.pdf
Step 4 β Merge all parts
Use qpdf to merge: test-a.pdf + test-b-rotated.pdf + test-appendix1.pdf into trash/test-final.pdf:
qpdf --empty --pages trash/test-a.pdf trash/test-b-rotated.pdf trash/test-appendix1.pdf -- trash/test-final.pdf
Step 5 β Verify with pdf-lib
Check the merged output has 4 total pages (1 + 2 + 1) and page 2 (from test-b) has the rotation applied.node -e "import('pdf-lib').then(async({PDFDocument})=>{const b=require('fs').readFileSync('trash/test-final.pdf');const d=await PDFDocument.load(b);const p2=d.getPage(1);const rot=p2.getRotation().angle;console.log('Final pages:',d.getPageCount(),'Page2 rotation:',rot);process.exit(d.getPageCount()===4&&(rot===90||rot===270)?0:1)})"Apply these five signals to each task after running the verify command. They measure not just "does the file exist" but also "did the model use the right tool for the job and verify its own work".
| Signal | 0 = fail | 1 = pass |
|---|---|---|
| Output is valid PDF | corrupt / missing / wrong format | starts with %PDF-, loads in pdf-lib |
| Correct tool for the job | wrote Python/reportlab when pdf-lib is available, or script when qpdf is simpler | pdf-lib for creation, qpdf for manipulation, project scripts for forms |
| Correct page count and content | wrong pages, missing content | expected pages, expected strings present |
| Follows API conventions | rgb values in 0-255 range, "#" in hex colors, wrong font API | correct rgb(0-1), no "#", proper font embedding |
| Model verified its own work | claimed success without checking | ran pdf-lib load, qpdf check, or read script |
Results fill in automatically as you click the buttons above.
| Test | Result | Notes |
|---|---|---|
| 1. pdf-lib Title Page | β | |
| 2. Multi-Page Report | β | |
| 3. Merge PDFs | β | |
| 4. Split Pages (qpdf) | β | |
| 5. Rotate Page (qpdf) | β | |
| 6. DOCXβPDF Conversion | β | |
| 7. PDF Text Extraction | β | |
| 8. AcroForm Fill | β | |
| 9. Static Form Annotation | β | |
| 10. Full Pipeline | β | |
| TOTAL: ____ / 10 passed | ||
qpdf. Install with brew install qpdf.brew install --cask libreoffice.skills/pdf/scripts/..pdf files.The task order traces a curve from shallow to deep PDF-creation understanding:
pdf-lib single page (1) β multi-page document (2) β merge documents (3) β qpdf split (4) β qpdf rotate (5) β DOCXβPDF (6) β text extraction (7) β AcroForm fill pipeline (8) β static form annotation (9) β full create/split/rotate/merge pipeline (10)
A model that drops off after Task 3 can create PDFs but not manipulate them. One that drops off after Task 6 understands creation and basic manipulation but not forms. A model that passes all 10 understands the complete PDF toolchain.