Claude Skill

pdf-explore

Use this skill when the user has attached a PDF, paper, report, or other document and the answer needs its content: summarize a section, compare sections, read specific pages, check the table of contents, or read a value off a figure. The `read` tool cannot parse PDF binary — pyt

LLM Mart · 0 points · 15 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download xuzhougeng-wisp-science-skills_pdf-explore-79e6416.zip · 7 KB
Part of xuzhougeng/wisp-science — 25 skills

Install

skills CLI npx skills add https://github.com/xuzhougeng/wisp-science/tree/main/skills/pdf-explore
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install xuzhougeng-wisp-science@llmmart
Git git clone https://github.com/xuzhougeng/wisp-science.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole xuzhougeng/wisp-science collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Read PDFs page-by-page, not wholesale

read chokes on PDF binary, and pasting a 50-page document costs 40K+ tokens. The sidecar parses once into the persistent Python kernel (memory + disk cached), after which you pull exactly the pages the question needs.

Setup, once per session: run the exec(...) line from the "Python Runtime Sidecar" section at the end of this skill's use_skill output. Definitions survive across cells until the kernel restarts. pypdfium2 is required (pillow too for image mode); if the first call raises ImportError, follow its hint and re-run.

Pick the entry point

call use for gives
pdf_outline(path) any structured document — start here [{page, heading, level}] from embedded bookmarks, [] + hint when absent
pdf_pages(path, pages=[...], mode="text") the specific pages you need [{page, text, n_chars}]
pdf_pages(path, mode="image", dpi=200, pages=[N]) figures, scans one PNG per page in .cache/pdf-explore/, for view_image
default mode="auto" unknown file text, auto-switching to images when pages have no text layer

Map the document first

toc = pdf_outline("report.pdf")
for entry in toc:
    indent = "  " * (entry["level"] - 1)
    print(f'p{entry["page"]:>3} {indent}{entry["heading"]}')

Costs nothing when bookmarks exist (LaTeX-compiled papers almost always have them). On [], there is no LLM fallback here — print the opening lines of each page from pdf_pages(path, mode="text") and build the map yourself. Watch for the [pdf_outline] offset warning: some PDFs bookmark logical page numbers, which are shifted from file page numbers by the front matter.

A handful of pages: print them

hits = pdf_pages("report.pdf", pages=[12, 13], mode="text")
for h in hits:
    print(f'\n[page {h["page"]}]\n{h["text"]}')

Fine up to roughly five pages (~2–4KB each). Kernel output past the ~16KB context budget is head/tail-truncated at ingestion, so anything larger goes through a file instead.

Whole sections: go through a file

For "summarize the methods", cross-section comparisons, or any multi-range pull, write all wanted pages in one call and read the result — read output enters context untruncated:

section_pages = [5, *range(21, 26), 62, 63, 64]     # from the outline
chunks = pdf_pages("report.pdf", pages=section_pages, mode="text")
open("pull.txt", "w").write(
    "".join(f'\n[page {c["page"]}]\n{c["text"]}' for c in chunks))
print("bytes:", __import__("os").path.getsize("pull.txt"))

Then read pull.txt, with offset/limit when it's long. As text a page runs ~800 tokens; as an attached image ~8K — and the parse is paid once.

Figures: render high, crop tight

A whole-page render can't resolve axis labels on a dense figure. Render at high dpi, crop to the figure with PIL, and view the crop:

import os
from PIL import Image
page = pdf_pages("report.pdf", mode="image", pages=[7], dpi=200)[0]
crop = os.path.join(os.path.dirname(page["image_path"]), "panel7.png")
Image.open(page["image_path"]).crop((x0, y0, x1, y1)).save(crop)

view_image the crop (or the full image_path once, to locate the figure). Every viewed image stays in context until /compact ages it out — view the few crops that matter, never the whole render set. Crops belong beside the renders under .cache/, never in the project's output directories: they are reading aids, not products.

Boundaries

The reference host's LLM helpers (pdf_scan page ranking, pdf_extract sweeps, pdf_map per-page summaries) require an in-kernel model bridge Wisp doesn't provide, so they don't exist here. For an exhaustive pass, dump pages to files in chunks (recipe above) and work through them, or hand the on-disk text to the explore subagent.

Files (wisp-science)
  • runtime.py 14.2 KB
    """Sidecar for the pdf-explore skill.
    
    Loaded once per session via the "Python Runtime Sidecar" exec line that
    `use_skill` appends; definitions then persist in the project's Python kernel.
    Top level is definition-only and every non-stdlib import sits inside a
    function body, so loading never fails on a missing package. Names carry a
    ``pdf_`` prefix because the sidecar shares the kernel's ``__main__``.
    
    Public surface:
        pdf_pages(path, ...)  page-level parse → [{page, text, n_chars, image_path?}]
        pdf_outline(path)     embedded-bookmark TOC with an offset sanity check
        pdf_resolve(path)     ~-expansion and a clear error for artifact-id input
    
    The reference host's LLM fan-out helpers (pdf_scan / pdf_extract / pdf_map
    and the LLM outline fallback) need an in-kernel model bridge Wisp does not
    provide; they are absent by design rather than left to fail at call time.
    """
    
    import hashlib
    import os
    import re
    
    PDF_PAGE_CACHE = {}
    """In-memory parse cache, keyed (abs_path, mtime_ns, mode, dpi).
    
    Lets repeated reads of one file skip re-parsing and re-rendering; lives until
    the kernel restarts."""
    
    PDF_AUTO_IMAGE_CHARS_THRESHOLD = 80
    """mode='auto' switches to rendering when the mean page carries fewer
    extractable characters than this — the signature of a scanned or
    image-only PDF."""
    
    PDF_INSTALL_HINT = (
        "Install pypdfium2 (and pillow for mode='image') into the project's "
        "python environment — e.g. `pixi add pypdfium2 pillow` from the "
        "project directory, or pip in the active venv — then re-run."
    )
    
    _ARTIFACT_ID_SHAPE = re.compile(r"[0-9a-fA-F-]{32,36}")
    
    
    def pdf_resolve(path_or_id):
        """Expand ``~`` and hand back a filesystem path.
    
        Wisp has no in-kernel artifact resolver, so a UUID-shaped string that is
        not an existing file raises immediately with an explanation instead of
        being treated as a path that merely doesn't exist yet.
        """
        if not isinstance(path_or_id, str) or not path_or_id:
            raise TypeError("pdf_resolve: path_or_id must be a non-empty str")
        expanded = os.path.expanduser(path_or_id)
        if os.path.exists(expanded):
            return expanded
        if _ARTIFACT_ID_SHAPE.fullmatch(path_or_id.strip()):
            raise FileNotFoundError(
                f"pdf_resolve: {path_or_id!r} looks like an artifact id; "
                f"artifact resolution is not available here — pass a file path."
            )
        return expanded
    
    
    # --------------------------------------------------------------- internals
    
    def _page_indices(total, want):
        """0-based page indices to visit, honoring an optional 1-based filter."""
        if want is None:
            return range(total)
        return sorted(i - 1 for i in want if 1 <= i <= total)
    
    
    def _render_dir(abspath, mtime, dpi):
        """Per-document render directory under ``./.cache/pdf-explore/``.
    
        Keyed on a path hash + mtime + dpi so an in-place edit or a different
        resolution never silently reuses stale PNGs. Living under ``.cache/``
        keeps the renders out of accidental context scans — pages are meant to
        be viewed one at a time via ``view_image`` on their ``image_path``.
        """
        tag = hashlib.sha1(abspath.encode()).hexdigest()[:8]
        d = os.path.join(os.getcwd(), ".cache", "pdf-explore",
                         f"{tag}-{mtime}", f"dpi{int(dpi)}")
        os.makedirs(d, exist_ok=True)
        return d
    
    
    def _password_error(path, unlock_hint):
        return ValueError(
            f"pdf_pages: {path!r} is password-protected. Decrypt it first "
            f"(e.g. `qpdf --decrypt --password=... in out` or {unlock_hint})."
        )
    
    
    def _row(page_number, text, image_path):
        return {"page": page_number, "text": text,
                "n_chars": len(text), "image_path": image_path}
    
    
    def _pick_backends(render):
        """Return (pdfium_module_or_None, fitz_module_or_None) in priority order.
    
        pypdfium2 leads (permissive license). Its ``to_pil()`` lazily imports
        PIL, so when rendering is requested and pillow is missing we demote it —
        otherwise the render path dies with a bare ModuleNotFoundError instead
        of reaching fitz (whose ``pix.save()`` writes PNG natively) or the
        install hint. Text-only pdfium has no pillow dependency.
        """
        try:
            import pypdfium2 as pdfium
        except ImportError:
            pdfium = None
        if pdfium is not None and render:
            try:
                import PIL.Image  # noqa: F401
            except ImportError:
                pdfium = None
        fitz = None
        if pdfium is None:
            try:
                import fitz  # pymupdf — user-installed fallback (AGPL-3.0)
            except ImportError:
                pass
        return pdfium, fitz
    
    
    def _parse_pdfium(pdfium, abspath, path, want, need_text, render, img_dir, dpi, cache):
        try:
            doc = pdfium.PdfDocument(abspath)
        except Exception as e:
            if "password" in str(e).lower():
                raise _password_error(
                    path, "pypdfium2.PdfDocument(path, password=pw)") from e
            raise
        rows = []
        try:
            for i in _page_indices(len(doc), want):
                page = doc[i]
                text = ""
                if need_text:
                    tp = page.get_textpage()
                    # pdfium emits \r\n; normalize so n_chars and the auto-mode
                    # threshold behave identically across backends.
                    text = tp.get_text_bounded().replace("\r\n", "\n")
                    tp.close()
                image_path = None
                if render:
                    image_path = os.path.join(img_dir, f"p{i + 1:03d}.png")
                    if not (cache and os.path.exists(image_path)):
                        bitmap = page.render(scale=float(dpi) / 72.0)  # PDF native is 72dpi
                        bitmap.to_pil().save(image_path)
                rows.append(_row(i + 1, text, image_path))
        finally:
            doc.close()
        return rows
    
    
    def _parse_fitz(fitz, abspath, path, want, need_text, render, img_dir, dpi, cache):
        doc = fitz.open(abspath)
        rows = []
        try:
            if doc.needs_pass:
                raise _password_error(path, "`fitz.open(path).authenticate(pw)`")
            for i in _page_indices(doc.page_count, want):
                page = doc.load_page(i)
                text = page.get_text("text") if need_text else ""
                image_path = None
                if render:
                    image_path = os.path.join(img_dir, f"p{i + 1:03d}.png")
                    if not (cache and os.path.exists(image_path)):
                        zoom = float(dpi) / 72.0  # PDF native is 72dpi
                        page.get_pixmap(matrix=fitz.Matrix(zoom, zoom)).save(image_path)
                rows.append(_row(i + 1, text, image_path))
        finally:
            doc.close()
        return rows
    
    
    def _parse_pypdf(abspath, want):
        try:
            from pypdf import PdfReader
        except ImportError as e:
            raise ImportError(
                "pdf_pages requires pypdfium2 or pypdf. " + PDF_INSTALL_HINT
            ) from e
        reader = PdfReader(abspath)
        return [
            _row(i + 1, reader.pages[i].extract_text() or "", None)
            for i in _page_indices(len(reader.pages), want)
        ]
    
    
    def pdf_pages(path, mode="auto", pages=None, dpi=100, cache=True):
        """Parse a PDF into per-page records, cached on (path, mtime, mode, dpi).
    
        Returns ``[{"page": int (1-based), "text": str, "n_chars": int,
        "image_path": str|None}, ...]``.
    
        mode:
            "auto"   (default) extract text; when the mean page falls below
                     :data:`PDF_AUTO_IMAGE_CHARS_THRESHOLD` characters — a
                     scanned or image-only file — re-parse in image mode. Costs
                     nothing extra on normal text-layer PDFs.
            "text"   extraction only: cheap, blind to figures and scans.
            "image"  render each page to PNG at ``dpi`` (default 100 ≈ 1200×1600
                     for letter) under ``./.cache/pdf-explore/…``.
            "both"   text and renders together.
    
        pages: optional 1-based list/range restriction, e.g. ``[3, 4, 5]`` or
        ``range(1, 11)``. Only a full read populates the in-memory cache; subset
        reads are then served from it, but a *cold* subset read re-parses (page
        PNGs are still reused from disk).
    
        Backends: pypdfium2 first, the user's pymupdf second, pypdf third
        (text-only). ImportError with an install hint when none is present.
        """
        path = pdf_resolve(path)
        if not os.path.exists(path):
            raise FileNotFoundError(f"pdf_pages: {path!r} not found")
        if mode not in ("text", "image", "both", "auto"):
            raise ValueError(
                f"pdf_pages: mode must be 'text'|'image'|'both'|'auto', got {mode!r}"
            )
        # auto-mode calls back into pdf_pages twice with the same `pages`;
        # materialize one-shot iterables so the second pass doesn't receive an
        # exhausted generator and quietly return nothing.
        if pages is not None and not hasattr(pages, "__len__"):
            pages = list(pages)
    
        if mode == "auto":
            text_rows = pdf_pages(path, mode="text", pages=pages, dpi=dpi, cache=cache)
            if not text_rows:
                return text_rows
            mean_chars = sum(r["n_chars"] for r in text_rows) / len(text_rows)
            if mean_chars < PDF_AUTO_IMAGE_CHARS_THRESHOLD:
                return pdf_pages(path, mode="image", pages=pages, dpi=dpi, cache=cache)
            return text_rows
    
        abspath = os.path.abspath(path)
        mtime = os.stat(abspath).st_mtime_ns
        key = (abspath, mtime, mode, int(dpi))
        want = None if pages is None else {int(p) for p in pages}
    
        if cache and key in PDF_PAGE_CACHE:
            stored = PDF_PAGE_CACHE[key]
            if want is None:
                return [dict(r) for r in stored]
            subset = [dict(r) for r in stored if r["page"] in want]
            if len(subset) == len(want):
                return subset
    
        render = mode in ("image", "both")
        need_text = mode in ("text", "both")
        img_dir = _render_dir(abspath, mtime, dpi) if render else None
    
        pdfium, fitz = _pick_backends(render)
        if pdfium is not None:
            rows = _parse_pdfium(pdfium, abspath, path, want, need_text,
                                 render, img_dir, dpi, cache)
        elif fitz is not None:
            rows = _parse_fitz(fitz, abspath, path, want, need_text,
                               render, img_dir, dpi, cache)
        elif render:
            raise ImportError(
                "pdf_pages(mode='image'|'both') requires pypdfium2 and pillow "
                "(PNG encoding). " + PDF_INSTALL_HINT
            )
        else:
            rows = _parse_pypdf(abspath, want)
    
        if cache and want is None:
            PDF_PAGE_CACHE[key] = [dict(r) for r in rows]
        return rows
    
    
    # ------------------------------------------------------------------ outline
    
    def _raw_toc(abspath):
        """Raw ``[[level, title, 1-based page], ...]`` from embedded bookmarks,
        trying pypdfium2 then the user's pymupdf; None when neither yields one."""
        try:
            import pypdfium2 as pdfium
            doc = pdfium.PdfDocument(abspath)
            try:
                entries = []
                for bm in doc.get_toc():
                    dest = bm.get_dest()
                    idx = dest.get_index() if dest else None
                    # Unresolvable destinations become page 0 and are filtered
                    # out by the caller.
                    entries.append([bm.level + 1, bm.get_title(),
                                    (idx + 1) if idx is not None else 0])
                return entries
            finally:
                doc.close()
        except Exception:  # noqa: BLE001
            pass
        try:
            import fitz
            with fitz.open(abspath) as doc:
                return doc.get_toc(simple=True)
        except Exception:  # noqa: BLE001
            return None
    
    
    def _warn_if_toc_offset(entries, abspath):
        """Best-effort detection of logical-vs-file page numbering.
    
        Some PDFs (typically LaTeX theses with front matter prepended after the
        hyperref anchors were fixed) embed bookmarks whose page numbers are
        document-logical, so TOC page N is really file page N+offset. Probe a
        few level-1 headings against the actual page text and warn when none
        match. An empty probe text means a missing text layer — "can't verify",
        not "offset" — so stay quiet then.
        """
        try:
            import unicodedata
    
            def fold(s):
                return "".join(c for c in unicodedata.normalize("NFKD", s)
                               if c.isalnum()).lower()
    
            probes = [e for e in entries if e["level"] == 1][:3] or entries[:3]
            probed = pdf_pages(abspath, pages=[e["page"] for e in probes], mode="text")
            text_by_page = {r["page"]: r["text"] for r in probed}
            matches = sum(
                1 for e in probes
                if fold(e["heading"])[:40]
                and fold(e["heading"])[:40] in fold(text_by_page.get(e["page"], "")[:1200])
            )
            has_text_layer = any(
                len(text_by_page.get(e["page"], "").strip())
                >= PDF_AUTO_IMAGE_CHARS_THRESHOLD
                for e in probes
            )
            if probes and matches == 0 and has_text_layer:
                print(
                    "[pdf_outline] ⚠ embedded TOC page numbers don't match page "
                    f"text for any of {len(probes)} sampled entries — the PDF's "
                    "bookmarks likely use logical page numbers, not file page "
                    "numbers (front-matter offset). Verify one entry against "
                    "pdf_pages(path, pages=[N])[0]['text'] before navigating."
                )
        except Exception:  # noqa: BLE001
            pass
    
    
    def pdf_outline(path):
        """Embedded-bookmark table of contents, in page order:
        ``[{"page": int, "heading": str, "level": int}, ...]``.
    
        Instant and free — most LaTeX-built arXiv PDFs carry bookmarks. When the
        PDF has none, prints a hint and returns ``[]``; there is no LLM fallback
        in this host, so build your own map by skimming
        ``pdf_pages(path, mode="text")``.
    
        First move for any structured document::
    
            for entry in pdf_outline("paper.pdf"):
                print(f"p{entry['page']:>3}", "  " * (entry['level'] - 1) + entry['heading'])
        """
        abspath = os.path.abspath(pdf_resolve(path))
        raw = _raw_toc(abspath)
        if raw:
            entries = [{"page": int(p), "heading": str(t), "level": int(lv)}
                       for lv, t, p in raw if p > 0]
            if entries:
                _warn_if_toc_offset(entries, abspath)
                return entries
        print(
            "[pdf_outline] no embedded outline in this PDF — skim headings via "
            "pdf_pages(path, mode='text') (e.g. print the first lines of each "
            "page) to build your own map."
        )
        return []
    
  • SKILL.md 4.5 KB
    ---
    name: pdf-explore
    description: "Use this skill when the user has attached a PDF, paper, report, or other document and the answer needs its content: summarize a section, compare sections, read specific pages, check the table of contents, or read a value off a figure. The `read` tool cannot parse PDF binary — python is the extraction path. Provides `pdf_pages` (pages as text or rendered PNGs, cached) and `pdf_outline` (embedded-bookmark TOC) in the persistent python kernel; load them once via the Runtime Sidecar exec line that `use_skill` appends. For PDF creation/manipulation, use reportlab/pypdf directly."
    fold_cue: "instead_of=read use=pdf_pages/pdf_outline for PDFs — read cannot parse PDF binary; print ≤5 pages, else write to a file and read that"
    license: Apache-2.0
    ---
    
    # Read PDFs page-by-page, not wholesale
    
    `read` chokes on PDF binary, and pasting a 50-page document costs 40K+
    tokens. The sidecar parses once into the persistent Python kernel (memory +
    disk cached), after which you pull exactly the pages the question needs.
    
    **Setup, once per session:** run the `exec(...)` line from the "Python
    Runtime Sidecar" section at the end of this skill's `use_skill` output.
    Definitions survive across cells until the kernel restarts. `pypdfium2` is
    required (`pillow` too for image mode); if the first call raises
    ImportError, follow its hint and re-run.
    
    ## Pick the entry point
    
    | call | use for | gives |
    |---|---|---|
    | `pdf_outline(path)` | any structured document — start here | `[{page, heading, level}]` from embedded bookmarks, `[]` + hint when absent |
    | `pdf_pages(path, pages=[...], mode="text")` | the specific pages you need | `[{page, text, n_chars}]` |
    | `pdf_pages(path, mode="image", dpi=200, pages=[N])` | figures, scans | one PNG per page in `.cache/pdf-explore/`, for `view_image` |
    | default `mode="auto"` | unknown file | text, auto-switching to images when pages have no text layer |
    
    ## Map the document first
    
    ```python
    toc = pdf_outline("report.pdf")
    for entry in toc:
        indent = "  " * (entry["level"] - 1)
        print(f'p{entry["page"]:>3} {indent}{entry["heading"]}')
    ```
    
    Costs nothing when bookmarks exist (LaTeX-compiled papers almost always
    have them). On `[]`, there is no LLM fallback here — print the opening
    lines of each page from `pdf_pages(path, mode="text")` and build the map
    yourself. Watch for the `[pdf_outline]` offset warning: some PDFs bookmark
    logical page numbers, which are shifted from file page numbers by the
    front matter.
    
    ## A handful of pages: print them
    
    ```python
    hits = pdf_pages("report.pdf", pages=[12, 13], mode="text")
    for h in hits:
        print(f'\n[page {h["page"]}]\n{h["text"]}')
    ```
    
    Fine up to roughly five pages (~2–4KB each). Kernel output past the
    ~16KB context budget is head/tail-truncated at ingestion, so anything
    larger goes through a file instead.
    
    ## Whole sections: go through a file
    
    For "summarize the methods", cross-section comparisons, or any multi-range
    pull, write all wanted pages in one call and `read` the result — `read`
    output enters context untruncated:
    
    ```python
    section_pages = [5, *range(21, 26), 62, 63, 64]     # from the outline
    chunks = pdf_pages("report.pdf", pages=section_pages, mode="text")
    open("pull.txt", "w").write(
        "".join(f'\n[page {c["page"]}]\n{c["text"]}' for c in chunks))
    print("bytes:", __import__("os").path.getsize("pull.txt"))
    ```
    
    Then `read` `pull.txt`, with `offset`/`limit` when it's long. As text a
    page runs ~800 tokens; as an attached image ~8K — and the parse is paid
    once.
    
    ## Figures: render high, crop tight
    
    A whole-page render can't resolve axis labels on a dense figure. Render at
    high dpi, crop to the figure with PIL, and view the crop:
    
    ```python
    import os
    from PIL import Image
    page = pdf_pages("report.pdf", mode="image", pages=[7], dpi=200)[0]
    crop = os.path.join(os.path.dirname(page["image_path"]), "panel7.png")
    Image.open(page["image_path"]).crop((x0, y0, x1, y1)).save(crop)
    ```
    
    `view_image` the crop (or the full `image_path` once, to locate the
    figure). Every viewed image stays in context until `/compact` ages it out —
    view the few crops that matter, never the whole render set. Crops belong
    beside the renders under `.cache/`, never in the project's output
    directories: they are reading aids, not products.
    
    ## Boundaries
    
    The reference host's LLM helpers (`pdf_scan` page ranking, `pdf_extract`
    sweeps, `pdf_map` per-page summaries) require an in-kernel model bridge
    Wisp doesn't provide, so they don't exist here. For an exhaustive pass,
    dump pages to files in chunks (recipe above) and work through them, or hand
    the on-disk text to the `explore` subagent.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related