# Paper2assets

> paper2assets — paper PDF → reusable assets

- Skill: `microsoft/paper2assets` (Agent Skill, multi-file: 14 files)
- Install (CLI): `npx skillmds@latest add microsoft/paper2assets`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/paper2assets/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Docs & Writing
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/microsoft/paper2assets

---


# paper2assets — paper PDF → reusable assets

One paper PDF in, a single `<outdir>/` of poster-agnostic assets out, ready for any downstream renderer.

## Output Contract (the shared layout every paper2* skill follows)

paper2assets defines the on-disk shape of **every** deliverable bundle in the pipeline. paper2poster, html2pptx, paper2blog, paper2video, and paper2reel all read from and write to a bundle laid out this way — a teammate adding or changing a downstream skill conforms to this contract.

**Rules**
1. The bundle directory is named after the paper.
2. The bundle's **top level holds ONLY that skill's deliverable FILES** — no loose intermediates, and as few folders as possible.
3. **Every** dependency and intermediate (figures, logos, qr, audio, fonts, captions, slides, the spec / json / txt) lives under one **`assets/`** container.

**Layout**

```
<paper-name>/
|-- <deliverable files>          # see the per-skill table below
|-- manifest.json                # package index (root-relative paths); the one allowed top-level non-deliverable
`-- assets/
    |-- figures/  logos/  qr/  audio/  fonts/   # runtime deps the deliverables reference
    `-- meta/                                    # build intermediates
        |-- paper_spec.md  sections.json  narration.json
        `-- captions.json  figures.json  metadata.json  text.txt
```

Deliverables reference assets with **root-relative** `src` paths -- `assets/figures/...`, `assets/logos/...`, `assets/qr/...`, `assets/audio/...` -- so the bundle is self-contained and movable (no absolute paths leak in). The `path` / `file` fields in `figures.json`, `fetch_logos.py`, and `make_qr.py` output already carry the `assets/` prefix; downstream drops them into `src` verbatim.

**Per-skill deliverables (top-level FILES):**

| Skill | Top-level deliverable files |
|---|---|
| **paper2assets** | `manifest.json` (+ the whole `assets/` package) |
| **paper2poster** | `poster.html`, `poster.pdf`, `poster.png`, `poster.pptx` |
| **paper2blog** | `blog_zh.docx`, `blog_en.docx` |
| **paper2video** | `video.mp4`, `video_no_subtitles.mp4` |
| **paper2reel** | `reel.html` |

`manifest.json`'s `"files"` map records every meta / figure path (root-relative) plus a `"layout": "v2-assets"` marker, so a consumer can locate inputs and tell a new bundle from a legacy flat one without re-walking the tree.

```
   paper.pdf  (+ arXiv id / provided image links)
     │
     ▼  FIGURES — priority: source_figures.py (arXiv source / provided links) → clean assets/figures/*.png + figures.json
     │           └─ per-figure fallback: extract_pdf.py crops only unresolved/composite figures from the rendered PDF
     ▼  scripts/extract_pdf.py   → assets/meta/{text.txt, captions.json} (+ figures.json only on the crop fallback; use --no-figures on the source path)
     │
     ▼  Step 3 (model-driven)    → assets/meta/metadata.json
     │
     ▼  Step 4 (model-driven)    → assets/meta/paper_spec.md  (9 canonical sections)
     │
     ▼  scripts/crop_figure.py   → cleaned assets/figures/*.png  (CROP FALLBACK ONLY — skipped when source_figures.py supplied the originals)
     │
     ▼  scripts/fetch_logos.py   → assets/logos/<slug>.{png,svg}
     ▼  scripts/fetch_conf_logo.py → assets/logos/_venue.png  (conference mark; best-effort, skips on miss)
     │
     ▼  scripts/make_qr.py       → assets/qr/{paper,code}.png
     │
     ▼  scripts/build_package.py → assets/meta/{sections.json, narration.json} + manifest.json
     │
     └──→ <outdir>/ — every downstream renderer reads from here

Downstream renderers (paper2poster, paper2blog, paper2audio, ...) consume this outdir; none should re-derive any of these files.
```

## Output contract

After paper2assets finishes, `<outdir>/` MUST contain:

| File | Source | Purpose |
|---|---|---|
| `assets/meta/text.txt` | Step 2 | Full PDF text via `pdftotext`. Page breaks preserved as `\f`. Authoritative source of numbers/claims for any downstream prose. |
| `assets/meta/captions.json` | Step 2 | `[{page, label, text}, ...]` per "Figure N: ..." caption detected in the PDF text. |
| `assets/meta/figures.json` | Step 2 + 7 | Legacy-compatible per-raster list. Physical fields (`file`, `width`, `height`, `page`, `column`) remain unchanged; Step 7 adds caption provenance/quality, `figure_id`, semantic eligibility/role/confidence, `semantic_roles[]`, negative `section_exclusions[]`, and `section_relevance[]`. Every confidence is `0..1` and every semantic decision carries evidence. `width`/`height` are updated whenever crop_figure.py runs. |
| `assets/figures/<page>_figure<n>.png` | Step 2 + 5 | Cropped figure rasters @ zoom=6 (~432 dpi). Cleaned by Step 5's deterministic pipeline. |
| `assets/figures/_debug/<page>_figure<n>.png.bak` | Step 5 | One-shot backup of the raw extract before Step 5's first crop. Preserved across re-runs (never clobbered). Lives under `_debug/` so the top-level `figures/` listing stays clean — downstream renderers should only ever read from `figures/*.png`. |
| `assets/figures/_debug/<page>_figure<n>.marked-<NN>.png` | Step 5d | Per-iteration overlay showing the bbox each `mark` call proposed (`-01`, `-02`, ...). Audit trail of the bbox-decision history. Never touched by downstream. |
| `assets/meta/metadata.json` | Step 3 | `{title, authors[], author_index_map{}, institutes[], venue, paper_url, code_url?}`. |
| `assets/meta/paper_spec.md` | Step 4 | 9-section structured summary (Problem / Motivation / Contribution / Method / Dataset/Benchmark / Key Result / Ablation Study / Headline Numbers / Takeaway), each section with `Necessary` + `Additional` + `Audio script` subfields. Plus a YAML preamble with title/authors/institutes/venue and audio scripts. |
| `assets/logos/<slug>.{png,svg}` | Step 6 | One approved logo per canonical institute: Wikimedia Commons first, then a MANDATORY WebSearch/WebFetch fallback (`--add-logo`) for any institute in `"missing"`. Every candidate is decoded and visually checked before approval; photos, covers, blank/low-contrast marks, corrupt responses, and duplicate visual encodings are rejected. |
| `assets/logos/logos.json` | Step 6 | Authoritative institution-resource allowlist. `logos[]` contains only approved selections with canonical identity, source, visual fingerprint, and quality metrics. `rejected[]` records failed candidates and reasons; `missing[]` records unresolved institutes. Downstream renderers must not discover arbitrary files by scanning `assets/logos/`. |
| `assets/qr/{paper,code}.png` | Step 6 | QR codes for the paper's links. `make_qr.py` classifies `paper_url`/`project_url`/`code_url` by destination (Paper/Code/Project), **de-duplicates by URL**, and writes up to two slots (slot 0 → `paper.png`, slot 1 → `code.png`) plus a `qr` manifest (path + `label`) into `metadata.json`. A one-link paper yields ONE QR (no `code.png`); the caption follows the URL, not the filename. |
| `assets/meta/sections.json` | Step 7 | `paper_spec.md` parsed to per-section JSON (stable ids + necessary / additional / audio_script) with conservative semantic figure refs. Each ref carries a stable `figure_id`; `figure_ids[]` is the simple join key. Motivation is allowed to have no figure and rejects Method/Result/Ablation figures. Consumed by paper2blog / paper2video. |
| `assets/meta/narration.json` | Step 7 | Audio **script** only — no mp3. TTS clip list `{provider, voice, sections:[{id, heading, text}]}` from the `**Audio script:**` markers (+ the title clip). Downstream renderers synthesize their own audio from this; paper2assets does NOT run TTS. |
| `manifest.json` | Step 7 | Package inventory (file paths + counts + source-PDF sha256) plus a final `package_generation` commit marker and hashes for generated JSON artifacts. |

## Workflow

### Step 0 — Cache check (do this FIRST, before any other work)

Re-extracting a paper costs ~5-10 min of Claude tokens (figure-cleanup
visual review + spec synthesis) and risks clobbering edits the user may
have made to `paper_spec.md`, `metadata.json`, or cropped figures.
Before starting, **check whether the assets already exist** under the
default outdir convention (`outdir = <input_pdf_dir>/<pdf_stem>/`) or
the caller-supplied outdir:

```bash
required=("$outdir/assets/meta/paper_spec.md" "$outdir/assets/meta/text.txt" \
          "$outdir/assets/meta/captions.json" "$outdir/assets/meta/figures.json" \
          "$outdir/assets/meta/metadata.json")
all_present=1
for f in "${required[@]}"; do [[ -f "$f" ]] || all_present=0; done
```

An empty `figures.json` is valid for a paper with no usable figures. If all
five canonical inputs are present, do not trust per-record schema strings as a
cache gate. Reuse the expensive text/rasters/spec, but always refresh Step 7
with the exact `build_package.py --skip-extract --paper-spec ...` command. It
validates the complete semantic contract, atomically replaces each canonical
JSON artifact, and writes `manifest.json` last with generation hashes.
Only after that command succeeds, REPORT the cached state in 1-2 lines and
STOP without re-extracting:

```
[paper2assets] CACHED in <outdir> — assets from prior run, reusing.
  title: "<from metadata.json>"
  figures: N PNGs
  paper_spec.md: <line-count> lines, K sections
```

If the Step 7 refresh rejects a missing, corrupt, partial, or mixed-generation
canonical document, do not report the bundle as ready. Repair the named input
and rerun Step 7. Never send it to a manual/raw-model figure-selection
fallback.

Re-extract ONLY when:
- one of the required artifacts is missing → resume from the missing step
  (Step 2 extract, Step 3 metadata, Step 4 spec, Step 5 cleanup, Step 6 logos/QR)
- the user explicitly requests it ("re-extract", "regenerate the spec",
  "force", "fresh", "from scratch"). In that case, delete or back up
  the existing `<outdir>/` first so the cache check doesn't fire.

### Step 1 — Validate the PDF path and pick an outdir

Required argument: path to a `.pdf` file. If the file doesn't exist or isn't a PDF, abort with a clear message.

Default outdir convention: `outdir = <input_pdf_dir>/<pdf_stem>/` — a folder under `papers/` named after the input PDF's basename (no extension). Example: `/work/job/ResearchStudio-Reel.pdf` → `/work/job/ResearchStudio-Reel/`. The caller may override with an explicit outdir argument.

Create `<outdir>/` if missing.

### Step 2 — Extract text + figures + captions

```bash
python ~/.claude/skills/paper2assets/scripts/extract_pdf.py <pdf> --outdir <outdir>
```

Writes:
- `assets/meta/text.txt` — full text via `pdftotext` (page breaks preserved as `\f`).
- `assets/meta/captions.json` — `[{page, label, text}, ...]` per detected "Figure N: ..." caption.
- `assets/meta/figures.json` — `[{file, width, height, page, layout}, ...]` per extracted figure raster.
- `assets/figures/<page>_figure<n>.png` — raster crop at zoom=6 (~432 dpi). The extractor uses a column-aware boundary heuristic + 50 px symmetric padding around the detected figure region so subsequent Step 5 cleanup has room to work.

**Figures — choose the source by priority (DO THIS; it is the biggest time/token saver).** The paper's ORIGINAL figure graphics are already clean (no baked caption strips, no column-text bleed) and skip the whole Step 5 crop loop (~6 min + heavy tokens). `scripts/source_figures.py` fetches them and writes `assets/figures/*.png` + `figures.json` in seconds:

1. **Provided image links (FIRST):** the user attached/linked figure images →
   `python ~/.claude/skills/paper2assets/scripts/source_figures.py --images <url|path> … --outdir <outdir>`
2. **arXiv (RECOMMENDED):** the paper is on arXiv →
   `python ~/.claude/skills/paper2assets/scripts/source_figures.py --arxiv <id|url> --outdir <outdir>`
   (downloads `arxiv.org/e-print/<id>`, parses the `.tex` figure order + captions, rasterizes each graphic).
3. **Backup (ONLY if 1 & 2 don't apply, or `source_figures.py` exits non-zero):** the PDF crop path below — full `extract_pdf.py` (with figures) **+ Step 5 `crop_figure.py`**.

**If `source_figures.py` succeeded (exit 0):** run `extract_pdf.py <pdf> --outdir <outdir> --no-figures`. It always writes text/captions; when `source_figures.py` left any `source: "pdf-crop-pending"` records, it also aligns their TeX captions to explicit printed `Figure N` labels and fills **only those records** from the rendered PDF. Then run Step 5 only for records whose final `source` is `"pdf-crop"`; skip original source graphics. If there are no such records, skip Step 5 entirely. **Otherwise** run the full `extract_pdf.py <pdf> --outdir <outdir>` (with figures) and do Step 5 for every extracted figure.

`source_figures.py` never selects only the first child of a multi-file TeX
figure. It writes that one figure as `pdf-crop-pending` while preserving clean
arXiv source assets for the other figures. `extract_pdf.py --no-figures`
replaces the pending record only after a unique TeX-caption → printed
`Figure N` alignment and an exact label join. Ambiguous or missing mappings
fail closed instead of falling back to positional order.

**Appendix figures — skipped by DEFAULT.** Process **main-body figures only**. From `text.txt`, find where the appendix / supplementary material begins (the first `Appendix` / `Supplementary` heading, or `A.` / `B.` / `S1`… content after `References`) and note its `\f`-delimited page. **Drop every figure on or after that page** — delete the PNGs from `figures/` and their rows from `figures.json` *before* Step 5, so neither the cleaning loop nor any downstream renderer sees them. **Override only when the user explicitly asks** — e.g. "include the appendix figures" or naming a specific supplementary figure.

### Step 3 — Parse paper metadata

Read `text.txt`'s first page + (if the PDF is from arxiv) the arxiv abs page via WebFetch. Synthesize `<outdir>/assets/meta/metadata.json`:

```json
{
  "title": "...",
  "authors": ["First Last", "Second Author"],
  "author_index_map": {"First Last": [1, 2], "Second Author": [2]},
  "institutes": ["First Institute", "Second Institute"],
  "venue": "NeurIPS 2025",
  "paper_url": "https://arxiv.org/abs/...",
  "code_url": "https://github.com/..."
}
```

- `authors` — display order from the PDF byline.
- `author_index_map` — author → list of 1-indexed institute indices (matches the superscripts in the PDF byline).
- `institutes` — semicolon-separated list, **deduplicated**, in the same order as the numeric indices used in the Authors line (so index `1` = first institute, etc.).
- `venue` — the **real publication venue**: conference / journal short name + year ("NeurIPS 2025", "ICLR 2026", "TPAMI 2026"). **NEVER write "arXiv" (or "Preprint") as the venue** — arXiv is a preprint host, not a publication venue. A paper on arXiv is almost always *also* published at a conference/journal; find that real venue: check the arXiv abs page's **Comments** and **Journal ref** fields (e.g. "Accepted at NeurIPS 2022"), the paper's first-page banner ("Published as a conference paper at ICLR 2024"), or an OpenReview / proceedings listing. For a workshop paper, use the **parent conference** (a NeurIPS 2022 workshop poster → `"NeurIPS 2022"`). Only when no real venue can be found anywhere, leave it an **empty string** (the header then shows no venue badge) — but still never "arXiv".
- `paper_url` — arxiv abs link (preferred) or publisher landing page.
- `code_url` — optional; omit field if no code is released.

### Step 4 — Synthesize the 9-section paper_spec.md

Write `<outdir>/assets/meta/paper_spec.md` with the canonical 9 sections, each carrying three subfields (`Necessary` / `Additional` / `Audio script`) plus a YAML preamble:

```markdown
---
title: <paper title>
authors: <First Last¹, Second Author¹², ...>
institutes: ¹First Institute; ²Second Institute
venue: <venue or empty>
paper_url: <url>
code_url: <url or empty>
title_audio_script: <one-paragraph spoken intro>
---

## Problem
**Necessary:** <≤40 words, the gap this paper addresses>
**Additional:** <≤40 words, supporting context>
**Audio script:** <one paragraph>

## Motivation
...

## Contribution
...

## Method
...
**Key equation:** `$<core formula(s) as clean LaTeX>$`  <!-- 1–3 max: the objective / loss / governing equation that defines the method; transcribe symbols faithfully from text.txt, never fabricate; omit this subfield only if the paper genuinely has no formula -->

## Dataset / Benchmark
...

## Key Result
...

## Ablation Study
...

## Headline Numbers
...

## Takeaway
...
```

Section-by-section guidance:

1. **Problem** — 1–2 sentences naming the gap. `Additional` for context.
2. **Motivation** — 1–2 sentences for *why now*. `Additional` for the failure pattern of prior work.
3. **Contribution** — 1–3 sentences listing what this paper actually contributes. May be omitted by downstream renderers; still write it here.
4. **Method** — 2–4 sentences describing the proposed approach, in the paper's own vocabulary. **Add a `**Key equation:**` subfield** transcribing the paper's 1–3 core formulas (objective, loss, or governing equation) as clean inline LaTeX (`$…$`) — this is the single biggest gap downstream posters have versus author ground-truth. Copy symbols faithfully from `text.txt`; never invent. Omit the subfield only for genuinely formula-free papers (pure systems / empirical).
5. **Dataset / Benchmark** — describes the data the paper introduces OR uses standard benchmarks (renderers decide whether to render this section).
6. **Key Result** — the headline experimental finding in 1–3 sentences with the actual numbers.
7. **Ablation Study** — 1–3 sentences naming the most informative ablation rows. Omit if the paper has no ablations.
8. **Headline Numbers** — 1–4 metrics that summarize impact. Quantitative, traceable to text.txt — NEVER invented.
9. **Takeaway** — 1–2 sentences a passerby could repeat after one read.

`Audio script` subfields are full-sentence spoken paragraphs (3–6 sentences each) suitable for TTS. They are part of the spec because audio narration of *any* downstream rendering should be derivable from this single source.

### Step 5 — Clean every PDF-crop figure (original source figures are skipped)

> Build the worklist from `figures.json`: process every record with `"source": "pdf-crop"` and no record with `"source": "original"`. In a hybrid bundle this may be only one composite figure; in a full crop fallback it is every figure. Skip the entire step only when the worklist is empty.

Step 2 produces raw figure rasters that often carry:
- A 1–10 px **chrome residue** at the top edge (the bottom of a page rule line / banner / running title that the extractor's column-aware boundary couldn't perfectly avoid).
- A **baked-in caption strip** at the bottom (rare — `extract_pdf.py` already clamps to `cap_full.y0 - 1`, but a few papers have caption text fused into the figure raster).
- A uniform **white margin** of arbitrary thickness around the cleaned content (a side effect of the 50 px symmetric pad in Step 2 + caption-clamped tight tops).

Downstream renderers should receive **cleaned PDF crops**, so run the deterministic cleanup pipeline on every `source: "pdf-crop"` record — independent of which figures a downstream renderer picks. Cost is sub-second per crop × 3 commands × N figures. Original source graphics carry none of the chrome / caption-strip / margin defects below and are skipped individually.

**For each PDF-crop record's `figures/<file>.png`, run 5a → 5b → 5c in this exact order:**

**5a. `top-check` — strip top chrome residue.**

```bash
python ~/.claude/skills/paper2assets/scripts/crop_figure.py top-check <outdir>/assets/figures/<file>.png
```

Pattern-matches the chrome signature (1–15 px non-clean prefix + ≥3 px clean gutter + sustained figure content below). On `TOP-CHROME DETECTED — ... cut at y=Z`, re-run with `--apply` to strip. On `TOP clean — ...`, skip to 5b.

**5b. `decaption` — strip baked-in bottom caption strip (when present).**

```bash
python ~/.claude/skills/paper2assets/scripts/crop_figure.py decaption <outdir>/assets/figures/<file>.png
```

Fires only when there's a 1–3 line text band at the bottom separated from the figure body by a clear horizontal whitespace gap. Most figures won't trigger. On `DETECTED bottom caption band`, re-run with `--apply`. Otherwise skip to 5c.

**5c. `autotrim` — strip remaining uniform white margins (always last).**

```bash
python ~/.claude/skills/paper2assets/scripts/crop_figure.py autotrim <outdir>/assets/figures/<file>.png
```

Strips border rows/cols that are 100% near-white, keeping a `--pad 4` margin. Safe — never touches content pixels. **Must come AFTER 5a/5b** — `autotrim` stops at the first dark row, so any uncut chrome/caption traps a thick whitespace band that `autotrim` cannot reach.

All three modes write `figures/_debug/<file>.png.bak` (one-shot, never clobbered on re-runs) and update `figures.json` width/height. The top-level `figures/` directory keeps only the in-progress clean PNG — backups and other debug artifacts live in the hidden `_debug/` subdir so downstream consumers see a clean listing.

**5d. Visual AI cropping review (mandatory for every PDF-crop figure).** The deterministic chain in 5a-5c handles uniform white margins, the chrome-residue pattern at the top edge, and the baked-in caption-strip pattern at the bottom edge. It does NOT handle:

- **Surrounding column body text leaked into the bbox** — when `extract_pdf.py`'s figure-region detection over-reaches into the paper's prose (a vertical strip of column text running alongside the figure, or a few lines of body paragraph above the figure). This is the most common defect, and it's *invisible* to autotrim/decaption: that text isn't a uniform white margin and isn't a thin caption sliver — it's *real ink* that paints similarly to figure content.
- **Caption text not caught by `decaption`'s thin-strip pattern** — captions that are tightly butted against the figure body, or captions that span 4+ lines (decaption refuses on caption blocks taller than ~15% of figure height to avoid amputating real figure content).
- **Adjacent-figure bleed** on multi-figure pages — a vertical strip of the neighboring panel.

These defects need *visual* judgment to identify and cut, but **eyeballing alone is too coarse** — visual estimates routinely miss small panel titles ("mAP", "AP_50"), under-include axis labels by ~50 px, and over-trust gaps that turn out to be content boundaries. So 5d's workflow grounds the visual judgment in two deterministic tools that turn "where exactly is this figure?" into a falsifiable, pixel-level question.

paper2assets owns this responsibility because:
1. Downstream renderers (paper2poster, paper2blog, paper2audio, paper2video) all want figures with **only the figure's own visual content** — surrounding paper text and captions are noise for every renderer.
2. Doing it once here is cheaper than each downstream re-doing it on its picks.
3. **The MAIN "Figure N: …" caption is NEVER part of the figure raster.** Its text is already in `<outdir>/assets/meta/captions.json` as structured data; baking those pixels into the PNG duplicates content and visually collides when downstream HTML/blog renderers add their own `<figcaption>`. **Panel sub-captions** like "(a) Pipeline overview" / "(b) Loss curves" are different — they label individual sub-panels, are part of the figure's visual content, and they STAY INSIDE the raster. Rule of thumb: if cutting it would leave the panels unlabeled, KEEP IT; if cutting it just removes a "Figure N: …" prose sentence already in `captions.json`, CUT IT.

**Workflow per figure (after 5a-5c have run):** the four steps below — analyze → judge → mark → verify → crop — exist as one loop so the bbox decision is committed only after a deterministic-grounded check AND a visual recheck both pass. Skipping the mark step (going straight from judge → crop) is the loop's most common failure mode: visual estimates from a downsampled Read are coarse, and a mis-estimated bbox commits a destructive crop in one shot. Mark first; commit only when the red box visibly encloses what you want.

**Process figures STRICTLY sequentially, ONE figure at a time end-to-end (hard rule).** Do not batch — do not run round-1 on all figures and then round-2 on all figures, do not invoke multiple sub-agent verifiers in parallel, do not Read figure A while reasoning about figure B's bbox. For each figure: complete every step (5d.i analyze → 5d.ii judge → 5d.iii mark → 5d.iv self-check + sub-agent verify ≥2 cycles → 5d.v commit), THEN move to the next figure. Cross-figure batching introduces two failure modes — sub-agent prompts can pick up the wrong figure's marked PNG (cross-context bleed), and your own per-edge reasoning can hallucinate elements from a sibling figure into the figure under judgment (cross-figure attention contamination). The cost of strict serial processing is real (no parallelism), but the verifier ambiguity it eliminates is worth it for correctness on every figure.

   **Step 5d.i — Analyze (deterministic, grounds the bbox decision in pixels):**
   ```bash
   python ~/.claude/skills/paper2assets/scripts/crop_figure.py blocks <outdir>/assets/figures/<file>.png
   ```

   Prints the figure's ROW + COL ink-block structure (every dense band, every gap between bands, in pixel coordinates) plus heuristic hints flagging narrow blocks adjacent to the main body as "INCLUDE in bbox" (axis labels / legend / rotated y-title) versus blocks separated by a wide (>50 px) gap as "EXCLUDE unless visual recheck confirms it's figure content" (likely body-text column or adjacent figure). This output tells you exactly where each structural part of the figure sits in pixel space — no eyeballing dimensions.

   **Step 5d.ii — Judge a tight bbox** `(X0, Y0, X1, Y1)` (PIL convention: top-left origin, x1/y1 exclusive). Apply these rules of inclusion ↔ exclusion:

   | Pattern in `blocks` output | Decision |
   |---|---|
   | Narrow LEFT/RIGHT col block with ≤30 px gap to main body | KEEP — almost always axis labels, a rotated y-axis title ("Accuracy (%)"), or a legend |
   | LEFT/RIGHT col block with >50 px gap to main body | CUT — paper text column (left page-margin) or an adjacent figure bleed |
   | TOP row block separated from the main figure body by a clean gutter, ≤6 px thick | CUT — likely chrome residue (5a should have caught it; this is a backup) |
   | TOP row block separated by a clean gutter, larger | INVESTIGATE — could be a banner ("Published as a conference paper at ICLR 2025") OR could be the figure's actual top legend / panel-title row |
   | TOP small-text region with no clear gutter into the body | INVESTIGATE — could be body-text fragment from above the figure OR could be small panel titles ("mAP", "AP_50") that didn't hit the dense threshold |
   | BOTTOM row block matching "Figure N: ..." pattern (1–3 short rows after a clear gutter) | CUT — main figure caption, already in `captions.json` |
   | BOTTOM small row blocks matching "(a) <description>" / "(b) <description>" / "(c) <description>" pattern positioned right below each subplot | KEEP — sub-captions label the panels and belong with the figure |
   | Wide ROW gap (>200 px) at the top with no ink above it | The figure body starts where the first ink begins — pad `y0` up by 20–30 px to catch small panel titles that fall below the 5% dense threshold |
   | Any visual element straddling an edge (a column, row, panel, legend item, label cluster, sub-caption, bar in a chart) | NEVER a fractional capture. The element must be ENTIRELY inside or ENTIRELY outside the bbox. A half-included element is worse than full exclusion — it shows a confusing partial thing instead of a clean omission. If the rightmost column is "mostly" in but the right edge cuts the last 10% of it, extend the right edge to capture all of it OR pull the right edge in further to exclude it cleanly. |

   The three single most-common mistakes are (a) cutting the axis-label / rotated-title col block on the LEFT because it looks like a separate structure, (b) setting `y0` exactly at the first dense row, which lands inside the panel-title text instead of above it (fix: pad y0 up 20–30 px), and (c) capturing a fractional element at the right or bottom edge — usually a multi-column diagram's last column shows only its header but the right edge cuts the body, or a 4-row stat grid shows only the first 3 rows with the 4th half-visible.

   **Step 5d.iii — Mark + Preview (verification gate, never destructive):**
   ```bash
   python ~/.claude/skills/paper2assets/scripts/crop_figure.py mark    <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1
   python ~/.claude/skills/paper2assets/scripts/crop_figure.py preview <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1
   ```

   Run BOTH commands per round — they produce a paired set:
   - `figures/_debug/<stem>.marked-<NN>.png` — the ORIGINAL raster with a red rectangle overlay showing the proposed bbox. Used by your own per-edge sanity check (Step 5d.iv) because the red line shows where the cut WOULD happen.
   - `figures/_debug/<stem>.preview-<NN>.png` — the cropped image (i.e. what the final figure WOULD look like if this bbox is committed). Used by the sub-agent verifier (Step 5d.v) because there's no geometry to interpret — the preview IS the proposed result.

   Both files rotate suffix (`-01`, `-02`, ...) in lockstep so round-N's mark and preview pair together. The original PNG is untouched by either command.

   **Step 5d.iv — Re-Read the marked image and verify per-edge.** Look at the latest `figures/_debug/<stem>.marked-<NN>.png` and answer four explicit yes/no questions — one per edge. The red box is outside the bbox, so what's INSIDE the box (the content you'll keep) is unobstructed:

   - TOP edge: is everything above the red line truly noise (banner, body-text, top chrome)? Are any panel titles ("mAP", legend) sitting just below the line that you mean to keep?
   - BOTTOM edge: is everything below the line a "Figure N: …" caption or paper text? Are any sub-captions "(a) …" / "(b) …" / "(c) …" sitting below the line that should be inside the box?
   - LEFT edge: is everything left of the line a body-text column from the paper? Are any axis labels / rotated y-axis title / legend column sitting outside the line that should be inside?
   - RIGHT edge: same — body-text vs in-figure (legend on the right of a chart, color bar, etc.).

   If any answer flips (something you meant to keep is outside the box, or something you meant to cut is inside), **adjust the bbox and re-mark**.

   **Conditional iteration: round 2+ only when round 1 verifier wasn't a clean PASS.** Round 1 is always mandatory (mark + preview + sub-agent verify). For round 2+:

   - If round 1's verifier returned a **clean PASS** (all of R1–R7 OK, each citing STEP A justification, no ambiguity), **commit and stop** — running a forced second round when the first attempt is already correct invites the agent to "fix" something not broken, which often introduces a regression (e.g. shifting the box to include a caption it had cleanly excluded).
   - If round 1 returned **FAIL** OR had any rule answered ambiguously (e.g. "OK probably", missing STEP A citation, suggesting a correction even on a stated PASS), **adjust the bbox per the verifier's suggestion** (single-edge extend / single-edge contract / R7 translation shift), produce a fresh mark + preview pair (`-02`), and re-invoke the sub-agent verifier on the new preview. Iterate until clean PASS.

   Why conditional rather than always-≥2: forced second rounds on already-correct first attempts are net-negative (regression risk > confirmation value). Conditional iteration keeps quality on the table only when needed.

   Cap at 5 cycles per figure for genuinely hard cases. Mark + preview are cheap (pure file I/O, no API cost); the sub-agent call is the actual cost driver.

   **MANDATORY independent verification by a sub-agent (hard rule, not a suggestion).**

   Your own per-edge check above is biased: you just decided the bbox seconds ago, so your verification is anchored on that decision. You can't reliably catch over-crops (cutting into figure content) or under-crops (leaving noise) by self-review alone — the verifier and the decider need to be different.

   So after `marked-01` and your own per-edge check, **invoke an independent verification sub-agent via the Task tool** to recheck the bbox. The sub-agent runs in a fresh context — no anchoring on your bbox decision, no prior beliefs about which edges "should" be where. It is the only check that can catch what your own re-Read systematically misses.

   ```
   Task(
     subagent_type="general-purpose",
     description="Verify figure crop bbox",
     prompt="""You are an independent figure-crop verifier. The caller has produced
   a proposed crop of a paper figure raster. Your job is to judge whether the proposed
   crop is correct — by direct visual comparison of two images.

   IMAGE 1 (the ORIGINAL, uncropped extract):
     <outdir>/assets/figures/<stem>.png

   IMAGE 2 (the PREVIEW — what the figure WOULD look like if this bbox is committed;
   this is just IMAGE 1 cropped to the proposed bbox, no annotations):
     <outdir>/assets/figures/_debug/<stem>.preview-<NN>.png

   Read BOTH images. The judgement is a direct comparison: 'is IMAGE 2 a clean,
   complete version of the figure in IMAGE 1?' There is no red rectangle to
   interpret, no inside/outside geometry to puzzle over. You see what the
   caller proposes to keep (IMAGE 2) alongside the original (IMAGE 1), and you
   judge the difference.

   STEP 0 — Grounding (mandatory; never skip). Before any rule check, plainly state:
     - IMAGE 1 dimensions: W₁ × H₁ px. What it contains overall (one sentence).
     - IMAGE 2 dimensions: W₂ × H₂ px. What it contains overall (one sentence).
     - The visible difference: what content does IMAGE 1 contain that IMAGE 2
       has cut away? (e.g. 'IMAGE 1's top ~110 px (a page banner) is absent
       from IMAGE 2', 'IMAGE 1's right ~430 px (an adjacent Figure 2 column)
       is absent from IMAGE 2', 'IMAGE 1's bottom ~50 px ("Figure 1:" caption)
       is absent from IMAGE 2'). Be quantitative — name approximate pixel
       widths of each cut.
   This grounding step anchors you on what the proposed crop actually changes
   before any rule-based reasoning.

   The figure SHOULD include (these elements MUST be present in IMAGE 2):
   - all panels / sub-panels / chart bodies that visually compose the figure
   - axis labels, legends, colorbars, in-figure annotations
   - sub-captions like '(a) ...', '(b) ...' that sit BELOW each subplot and label it
   - decorative borders / frames that are part of the figure's artwork

   The figure should NOT include (these MUST be cut away — present in IMAGE 1,
   absent from IMAGE 2):
   - body-text paragraphs from the surrounding paper column
   - the main 'Figure N: ...' caption sitting below the figure body
   - page banner / running title / arxiv stamp at the top
   - vertical strips of an ADJACENT figure (when two figures share a page row)

   CRITICAL — no fractional captures: every visual element (column, row,
   panel, legend item, label cluster, sub-caption, bar in a chart, axis tick
   set, stat tile) must be either ENTIRELY in IMAGE 2 (kept whole) or
   ENTIRELY absent from IMAGE 2 (cut whole). A half-included element at any
   edge of IMAGE 2 (e.g. 'IMAGE 2's right edge shows the orange panel
   background ending mid-element', 'the rightmost "G" in "118 GB" is
   half-cut at IMAGE 2's right edge', 'only 3 of 4 stat tiles are fully in
   IMAGE 2 with the 4th half-visible') is the most common failure mode —
   flag it as FAIL even if everything else looks right.

   STEP A — Per-edge forced description (mandatory; no verdict allowed
   before completing this). For EACH of the four edges of IMAGE 2, describe
   in concrete words:
     (i) what content sits in the ~50 px strip just inside that edge of
         IMAGE 2 (does it end cleanly, or is it cut?), AND
     (ii) what content sits in the corresponding ~50 px strip of IMAGE 1
          that IMAGE 2 has dropped (is it noise that should be dropped,
          or figure content that should have been kept?).

     TOP edge:
       [IMAGE 2 top ~50 px]: ...
       [IMAGE 1 strip dropped just above IMAGE 2's top]: ...
     BOTTOM edge:
       [IMAGE 2 bottom ~50 px]: ...
       [IMAGE 1 strip dropped just below IMAGE 2's bottom]: ...
     LEFT edge:
       [IMAGE 2 left ~50 px]: ...
       [IMAGE 1 strip dropped just left of IMAGE 2's left]: ...
     RIGHT edge:
       [IMAGE 2 right ~50 px]: ...
       [IMAGE 1 strip dropped just right of IMAGE 2's right]: ...

   Be physical and specific — name actual elements, give approximate pixel
   distances, commit to whether each element ENDS within IMAGE 2 or CROSSES
   the edge.

   STEP B — Per-rule check (mandatory; one line per rule). For each rule
   below, answer VIOLATED or OK, and if VIOLATED quote the exact line from
   STEP A that shows the violation. Do NOT skip rules — answer all in order,
   even when obvious:

     R1 (no body-text paragraphs inside IMAGE 2): ___
     R2 (no main 'Figure N: ...' caption inside IMAGE 2): ___
     R3 (no page banner / running title / arxiv stamp inside IMAGE 2): ___
     R4 (no vertical strip of an adjacent figure inside IMAGE 2): ___
     R5 (no fractional capture at any edge of IMAGE 2 — every edge-adjacent
         element ENTIRELY in or ENTIRELY out): ___
     R6 (all figure-content elements present in IMAGE 2 — panels, axes,
         legends, sub-captions, colorbars; nothing critical was dropped
         when going from IMAGE 1 → IMAGE 2): ___
     R7 (no TRANSLATION error — the bbox is positioned correctly, not
         shifted off-center. Symptom: IMAGE 2 has significant white margin
         on ONE side AND content clipped at the OPPOSITE side. If left
         margin is wide and right edge cuts content, the box has the right
         width but is shifted too far LEFT — same with top-vs-bottom): ___

   The per-rule answers MUST cite the STEP A descriptions. 'R5 OK because
   the figure looks complete' is invalid — only 'R5 OK because STEP A right
   line says the orange panel background ends ~10 px before IMAGE 2's right
   edge with clean white margin' is a valid answer. 'R6 OK because IMAGE 2
   looks like a complete figure' is invalid — only 'R6 OK because IMAGE 1's
   right-strip drop contains only adjacent-figure content per STEP A right
   line, so nothing critical was dropped' is valid. 'R7 OK because the box
   is centered' is invalid — only 'R7 OK because STEP A shows the left
   ~50 px contains the leftmost panel's edge with ≤10 px margin, and the
   right ~50 px contains the rightmost panel's edge with ≤10 px margin,
   so the box is positioned correctly, not shifted' is valid.

   STEP C — Verdict. Based on STEP B:
   1. PASS (every rule OK) or FAIL (at least one VIOLATED).
   2. If FAIL, name the rule(s) violated and quote the relevant STEP A line(s).
   3. If FAIL, suggest a corrected bbox as concrete pixel deltas relative to
      the current one. Pick the right shape of correction:
      - **Single-edge extend** when an element is missing only on one side:
        'extend x1 by ~30 px to recover the rest of the orange panel
        background per R5'.
      - **Single-edge contract** when noise is leaking in on one side:
        'reduce y1 by ~50 px to drop the "Figure 1:" caption text per R2'.
      - **Translation shift** when R7 fired — when one side has slack
        whitespace and the opposite side cuts content. Apply equal-magnitude
        opposite-direction edits to BOTH edges of that axis, NOT a single-edge
        edit. Example: 'shift box right by ~80 px: x0 += 80 AND x1 += 80
        (per R7 — left has ~80 px white margin, right cuts the orange panel
        mid-element)'. Same

…(truncated)
