Use when the user asks to create, combine, split, preview, or extract content from PDF files. Triggers include "markdown to pdf", "mermaid in pdf", "math/LaTeX in pdf", "merge PDFs", "split a PDF", "extract text from pdf", "fill AcroForm", "preview pdf as image", and similar PDF generation or manipulation tasks.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
npx skills add https://github.com/MatrixFounder/Universal-skills --skill pdf
The command stays on one line. Scroll horizontally to inspect it before copying.
Prefer a local copy? Download the files currently available to SkillsMP.
Use when the user asks to create, combine, split, preview, or extract content from PDF files. Triggers include "markdown to pdf", "mermaid in pdf", "math/LaTeX in pdf", "merge PDFs", "split a PDF", "extract text from pdf", "fill AcroForm", "preview pdf as image", and similar PDF generation or manipulation tasks.
tier
2
version
1
license
LicenseRef-Proprietary
pdf skill
Purpose: Give the agent a small, deterministic set of CLIs for the
common PDF operations: render Markdown to a well-typeset PDF, merge
PDFs, split them by page range or into individual pages, and (via
references) extract text or fill forms. Picking the right library on
the fly is the single biggest source of PDF bugs; delegating to
scripts that embed those choices removes the variance.
1. Red Flags (Anti-Rationalization)
STOP and READ THIS if you are thinking:
"I'll use pypdf to extract the text." โ WRONG for layout-dependent content. pypdf's text extraction is famously unreliable on anything with columns or complex layout; use pdfplumber. See references/library-selection.md.
"I'll improvise a pdfplumber script to convert this PDF to Markdown." โ WRONG to improvise from scratch. Run pdf_extract.py for the structured dump and follow references/pdf-to-markdown.md โ and never skip the scan check: on a scanned PDF pdfplumber returns empty text silently; pdf_extract.py exits 10 instead.
"I'll reach for playwright for Markdown โ PDF because it handles everything." โ WRONG. Playwright pulls a 200 MB Chromium install. weasyprint handles 95% of Markdown/HTML inputs with a fraction of the footprint.
"I'll fill this XFA form with pypdf." โ WRONG. pypdf doesn't fill XFA โ only AcroForm. Detect the form type first (see references/forms.md) and fail loudly if it's XFA rather than silently writing an unchanged file.
"I'll skip checking exit codes from pdf_merge.py." โ WRONG. Missing an input file produces exit 1 and the output is absent โ silently assuming success ships a broken deliverable.
2. Capabilities
Render Markdown (+ optional custom CSS) to a typeset PDF via weasyprint. Fenced ```mermaid blocks pre-render to PNG via mmdc; bundled scripts/mermaid-config.json ships an office-friendly Cyrillic-capable font stack (override with --mermaid-config PATH, opt out with --no-mermaid-config). Inline $โฆ$ and display $$โฆ$$math pre-render to MathML via the bundled katex (weasyprint typesets MathML natively; it runs no JS, so client-side KaTeX/MathJax can't be used) โ currency ($5) and $ inside code/fences are left untouched, and without node/KaTeX formulas degrade to literal text (opt out with --no-math, fail-hard with --strict-math). The PDF carries a navigable outline (bookmarks) auto-built from h1โh6 headings โ no flag needed.
Render HTML / web archives to PDF via html2pdf.py โ same weasyprint pipeline, natively handles .html/.htm, .mhtml/.mht, and .webarchive. Validated across Fern (OpenRouter), Mintlify (Anthropic Claude Code, Discord, Berachain), GitBook (Hyperliquid), Confluence (Atlassian wikis), ะฅะฐะฑั, vc.ru and generic blogs โ 34/34 fixtures pass in both modes. Bundled stylesheet on by default; --no-default-css for fully-styled inputs (BI dashboards, branded reports); --css EXTRA.css stacks on top. --reader-mode extracts the main article body (Safari Reader View parity). --timeout 180 SIGALRM watchdog with $HTML2PDF_TIMEOUT override. Universal preprocessing handles draw.io/Confluence SVG diagrams, table-based code blocks (Fern/Mintlify shiki), Tailwind/FontAwesome icon strip, ARIA-role tables (GitBook), ad-network removal, and pathological-CSS protection (ะฅะฐะฑั content-drop bug, vc.ru CPU-loop bug). Full pipeline + flag semantics + per-platform notes documented in references/html-conversion.md. Output PDFs carry a navigable outline (bookmarks) from h1โh6 headings โ engine-agnostic (weasyprint and --engine chrome; the chrome engine emits a tagged PDF, the mechanism Chromium uses for the outline).
Merge multiple PDFs into one preserving bookmarks ().
3. Execution Mode
Mode: script-first for the bundled operations, prompt-first with library references for extraction and form filling.
Why this mode: The bundled operations (render, merge, split) are stable recipes. Extraction and form filling depend heavily on the specific document and deserve inspection before running โ the references guide the inline work.
pdf_extract.py โ the bounded exception: extracting per-page text + tables to a JSON dump IS a stable recipe, so it is bundled (it also closes the silent-scan failure with code). Markdown composition โ heading levels, reading order, table stitching โ stays prompt-first agent judgement: there is no Markdown-converter script, by design. See references/pdf-to-markdown.md.
python3 scripts/html2pdf.py INPUT OUTPUT.pdf [--page-size letter|a4|legal] [--css EXTRA.css] [--base-url DIR] [--no-default-css] [--reader-mode] [--archive-frame N|main|all|auto] [--list-frames] [--timeout SECONDS] [--engine weasyprint|chrome] [--chrome-js] โ INPUT may be .html/.htm, .mhtml/.mht, or .webarchive; sub-resources in archives are extracted to a temp dir automatically. --reader-mode extracts the main article content (Confluence-priority candidate list with body-ratio guard for <main>; longest-match per selector handles archive pages with multiple .entry divs and Disqus comment threads + title-match LCS bonus for multi-article feed pages), stripping navigation, ads, sidebars, and SPA chrome (ARIA role=navigation\|complementary\|banner\|contentinfo + semantic <aside>/<nav>/<footer> + shallow <header>) โ ideal for browser-saved news/blog/docs pages and hydrated SPAs. --archive-frame N|main|all|auto (pdf-8, 2026-05-05): selects which inner frame in webarchive/MHTML to render โ main (default) = main resource only; N (1-indexed) = specific inner frame; all = concat all "substantial" frames (โฅ 1 KB + 0 <script> + โฅ 30 chars text + not single-<img>-only) with <hr><h2>Frame N</h2> separators + per-frame namespace + sha1 image-dedup + encoding parity; auto = deterministic (0 substantial โ main, 1 โ that frame with main-dominance guard, 2+ โ all). Vendor-agnostic: validated on 9 real fixtures across Angular/Closure/Framer/bare-DOM SPA stacks without a single vendor name in the heuristic. --list-frames (pdf-8): prints inner-frame inventory (index/kind/substantial/bytes/scripts/text-len/url) and exits without rendering โ for picking N deterministically. --timeout (default 180s, $HTML2PDF_TIMEOUT env, 0 disables) caps weasyprint render via signal.SIGALRM for pathological inputs. Exit 1 with envelope on watchdog fire; exit 2 with / envelopes for archive-frame errors. : render engine selector. (default) โ pure-Python typeset PDF, no browser runtime. โ opt-in headless Chromium via Playwright (~150 MB, install with ). Use when weasyprint produces broken output: Material 3 calc/var bugs (Gmail-class), Framer infinite layout loops, ELMA365 inline.py assertion, JS-hydrated content, charts. Chrome path skips weasyprint preprocess (calc-strip, font-face-strip, NORMALIZE_CSS) โ those are weasyprint workarounds Chrome doesn't need; reader-mode and remain engine-agnostic. : strip from HTML + JS-enabled at context level (page can't run own JS โ no Gmail self-destruct, no Angular half-hydration; we keep for surgical DOM normalization; opts page-JS back in for canvas/hydration); stripped (webarchives carry which would route every relative URL to the offline-blocked origin); media forced to (default triggers nav-hiding rules in SPAs); 1280ร1024 viewport (desktop CSS); layout-normalize CSS โ high-specificity body release, icon-font ligature suppression with leaf-only guard (avoids font-size:0 cascading through CSS inheritance to children), exact-word match (not substring โ avoids hiding and similar), image cap (200px), avatar-image cap (48ร48 only on , not bare class so containers aren't shrunk); JS-based DOM normalize via โ width-gate for overflow release (narrow icon-sidebars at 64px stay clipped, no label leak), substantial-modal release ( only when wide AND tall AND text-rich), modal-portal hide (when modal released, hide non-portal body children to remove underlying CRM page); so 1280 px layout fits A4's ~718 px usable width without right-edge cutoff. Validated on 3 SPA archive shapes ร 2 modes (Gmail Closure email, ELMA365 Angular dashboard, Yandex Cloud Console marketplace) โ all 6 combinations produce full content with no overlap, no cutoff, no chrome-icon ligatures, no underlying-page noise. : for email/newsletter/article archives (cleaner article-only render); alone for dashboards/registries/structured UIs (preserves card layout). Exit 1 with envelope if Playwright not installed.
5. Safety Boundaries
Allowed scope: only paths named on the command line.
Default exclusions: do not fetch remote resources unless the user explicitly provides URLs; md2pdf.py --base-url defaults to the input's directory.
Destructive actions: all three scripts overwrite their outputs without prompting.
Optional artifacts: custom CSS via md2pdf.py --css is optional; defaults produce a reasonable layout.
bash scripts/tests/test_e2e.sh โ runs the end-to-end smoke suite (md2pdf, merge, split, fill-form, mermaid, pdf_extract, pdf_ocr). Includes the html2pdf regression battery: ~37 unit tests for html2pdf_lib/ helpers + data-driven fixture battery (6 synthetic micro-fixtures + 6 hand-stripped real-platform slices + N tmp/ originals when present on disk; per-fixture page-count / size / required+forbidden-needle assertions, see tests/battery_signatures.json).
Adding a new platform fixture (e.g. you found a Notion/Stripe page that breaks): drop the .webarchive/.html/.mhtml file into tmp/, run python3 scripts/tests/capture_signatures.py (auto-captures page count + needles + size band; only ADDs new fixtures unless --refresh is passed), hand-add chrome strings to forbidden_needles in battery_signatures.json, commit the JSON delta. Total ~5 min per new site. Detailed in references/html-conversion.md ยงRegression coverage.
python3 scripts/md2pdf.py examples/fixture.md /tmp/invoice.pdf --page-size letter โ produces a non-empty PDF.
python3 -c "from pypdf import PdfReader; r=PdfReader('/tmp/invoice.pdf'); print(len(r.pages))" โ returns at least 1.
CI signal: python3 ../../.claude/skills/skill-creator/scripts/validate_skill.py skills/pdf โ exit 0.
7. Instructions
7.1 Pick the library, not the script first
A full PDFโMarkdown converter is deliberately not bundled โ Markdown
composition (heading levels, reading order, stitching a table across pages) is
agent judgement. Form filling likewise depends on the document.
For PDF โ Markdown: follow references/pdf-to-markdown.md โ its decision tree picks digital-vs-scanned, and pdf_extract.py gives a structured dump. You compose the Markdown from that dump; the script never emits Markdown.
For other extraction (a one-off text/table grab): write inline pdfplumber code, or run pdf_extract.py for a quick structured dump.
For form filling: follow references/forms.md โ detect AcroForm vs XFA first.
7.2 Creating PDFs from Markdown
python3 scripts/md2pdf.py input.md output.pdf covers the common case.
Pass --css custom.css when the user provides brand styling.
For images referenced with relative paths, either put them next to the Markdown file or pass --base-url /absolute/image/root.
For HTML-heavy inputs (embedded <style>, flexbox, columns), weasyprint handles those in the script โ no extra work needed.
7.3 Merging PDFs
Order matters: python3 scripts/pdf_merge.py out.pdf file1.pdf file2.pdf file3.pdf appends in that order.
Bookmarks from each input are preserved and nested under a parent named after the source's stem.
--each-page OUTDIR/ โ one PDF per input page, zero-padded filenames.
--every N OUTDIR/ โ chunks of N pages each.
Page numbers are 1-indexed and inclusive. Invalid ranges exit 1.
7.5 Setup
MUST run bash scripts/install.sh once. It creates scripts/.venv/ locally, installs requirements.txt, probes whether weasyprint can find its native libraries, and prints install hints if not. Idempotent.
External system libraries (checked by install.sh, installed manually per project plan ยง3.3 "ะฒะฝะตัะฝะธะต ะธะฝััััะผะตะฝัั โ ะฝะต ะฑะฐะฝะดะปัััั"):
pango, cairo, gdk-pixbuf โ weasyprint native runtime; required by md2pdf.py. macOS: brew install pango gdk-pixbuf libffi. Debian: sudo apt install libpango-1.0-0 libpangoft2-1.0-0 libharfbuzz0b libcairo2 libgdk-pixbuf2.0-0. See references/weasyprint-setup.md for fuller notes.
tesseract (+ eng/rus data) and ghostscript โ only for pdf_ocr.py; installed by bash scripts/install.sh --with-ocr (which installs ocrmypdf into the venv and probes these). macOS: brew install tesseract tesseract-lang ghostscript. Debian: sudo apt install tesseract-ocr tesseract-ocr-eng tesseract-ocr-rus ghostscript. See references/ocr.md.
Commands that need them fail with a clear error until installed.
8. Workflows (Optional)
Markdown-driven PDF:
- [ ] Draft the Markdown content
- [ ] `python3 scripts/md2pdf.py doc.md doc.pdf`- [ ] Open the PDF, check layout (orphans/widows, table page breaks)
- [ ] Iterate on CSS if needed (`--css brand.css`)
scripts/pdf_watermark.py โ text/image watermark overlay via reportlab + pypdf; per-mediabox overlay caching for heterogeneous decks; cross-7 same-path guard.
scripts/pdf_fill_form.py โ AcroForm inspect/extract/fill/flatten via pypdf; XFA forms detected and refused.
scripts/preview.py โ universal INPUT โ PNG-grid renderer for .pdf (via Poppler) and .docx/.xlsx/.pptx (via LibreOffice + Poppler). Byte-identical across all four office skills.
scripts/pdf_extract.py โ dumps a PDF's per-page text + tables to structured JSON via pdfplumber, with scan detection (image-only document โ exit 10). A dump, not a Markdown converter.
scripts/pdf_ocr.py โ OCR a scanned PDF into a searchable PDF via (default ); soft-optional engine (); imports read-only (no cross-skill replication). See .
pdf_merge.py
Split a PDF by explicit page ranges, one-per-page, or fixed-size chunks (pdf_split.py).
Stamp a text or image watermark on every (or selected) page via pdf_watermark.py (drafts, "CONFIDENTIAL", brand stamps). --position center|top-left|top-right|bottom-left|bottom-right|diagonal, --opacity, --rotation, --pages "1-5,8". Builds one overlay per unique page mediabox, so heterogeneous decks (Letter+A4) keep correct proportions.
Detect, inspect, and fill AcroForm fields via pdf_fill_form.py โ three modes: --check (form-type triage with exit codes 0/11/12 = AcroForm/XFA/none), --extract-fields (dump field schema as JSON for editing), and fill mode (INPUT.pdf DATA.json -o OUT.pdf [--flatten]). XFA forms are detected and refused with a clear message.
Extract text, tables, and layout via pdfplumber (documented; inline usage from the agent is fine).
Dump a PDF's per-page text + tables to structured JSON via pdf_extract.py โ a structured dump, NOT a Markdown converter (it never emits Markdown). Its defining feature is scan detection: an image-only document exits 10 with a DocumentScanned signal instead of silently yielding empty text. Robust word-splitting by default: LaTeX/academic two-column PDFs encode inter-word spacing as positional gaps (no space glyphs), which pdfplumber's absolute tolerance glues into ASurveyonBlockchain; a font-relative x_tolerance_ratio (default 0.15) splits them correctly without regressing real-space PDFs (tune/disable via --x-tolerance-ratio R, see references/pdf-to-markdown.md ยง3.8). Pairs with references/pdf-to-markdown.md for the PDFโMarkdown decision tree and recipe; final Markdown composition stays agent judgement.
OCR a scanned (image-only) PDF into a searchable PDF via pdf_ocr.py โ wraps ocrmypdf to overlay an invisible OCR text layer (default languages eng+rus), the remediation hop for pdf_extract.py exit 10. The OCR engine is soft-optional: install with bash scripts/install.sh --with-ocr (+ system tesseract/eng/rus/ghostscript); a missing engine or language pack fails loud, never silent. See references/ocr.md.
Render any .pdf (or peer-skill .docx/.xlsx/.pptx) into a single PNG-grid preview via preview.py (uses Poppler directly for .pdf; LibreOffice + Poppler for OOXML).
Emit failures as machine-readable JSON to stderr with --json-errors (uniform across all four office skills).
python3 scripts/pdf_extract.py INPUT.pdf [-o OUT.json] [--layout] [--password PW] [--x-tolerance-ratio R] [--json-errors] โ dumps per-page text + tables as structured JSON (NOT Markdown). --x-tolerance-ratio (default 0.15) is the font-relative word-split threshold that un-glues LaTeX/academic PDFs; 0 disables it (legacy absolute tolerance). Exit codes: 0 success; 1 failure (missing / not-a-PDF / corrupt / encrypted-without-password); 2 usage error; 6SelfOverwriteRefused (-o resolves to the input PDF); 10DocumentScanned โ the whole document is image-only, run OCR or read the pages as images. On exit 10 the dump is still emitted; exit 10 + stderr is the loud signal. Default output is stdout; -o writes a file (idempotent). See references/pdf-to-markdown.md.
python3 scripts/pdf_ocr.py INPUT.pdf OUTPUT.pdf [--lang eng+rus] [--skip-text|--redo-ocr|--force-ocr] [--sidecar OUT.txt] [--jobs N] [--password PW] [--deskew] [--rotate-pages] [--clean] [--json-errors] โ OCR a scanned PDF into a searchable PDF via ocrmypdf (default languages eng+rus). --password decrypts an encrypted input; --rotate-pages needs tesseract osd data; --clean needs unpaper. Exit codes: 0 success; 1 failure (type in the envelope: OcrEngineUnavailable / LanguagePackMissing / EncryptedInput / InputUnreadable / PriorOcrFound / OutputWriteFailed / InputNotFound); 2 usage; 6SelfOverwriteRefused. Soft-optional engine โ bash scripts/install.sh --with-ocr first. See references/ocr.md.
All scripts above accept --json-errors to emit failures as a single line of JSON on stderr ({v, error, code, type?, details?}). The schema version v is currently 1; argparse usage errors are routed through the same envelope (type:"UsageError").
Inputs: positional paths; optional flags per command.
Outputs: single PDF files (md2pdf, pdf_merge) or multiple PDFs under a directory (pdf_split). All stdout goes to the output path list.
Failure semantics: non-zero exit on missing inputs, invalid range specs, or library errors. Error detail to stderr.
Idempotency: all three scripts overwrite their outputs on re-run.