# Paper2poster

> Render a pre-extracted paper's structured 9-section spec (`paper_spec.md`) into a single-page HTML academic poster, fit the layout to the page via an iterative measured-fill loop, and export it to print-ready PDF + PNG thumbnail. Requires the upstream `paper2assets` skill to have produced the input `<outdir>/` package (`manifest.json` at the root + an `assets/` folder holding `meta/paper_spec.md`, `meta/text.txt`, `meta/figures.json`, `meta/metadata.json`, `figures/*.png`, `logos/`, `qr/`) first. Use when the user wants an HTML poster, PDF/PNG export, or PPTX from a paper they already have extracted assets for — e.g., "render the poster", "make the poster from this spec", "export poster to PDF", "paper2poster". The three skills paper2assets → paper2poster → html2pptx run in sequence, each invokable on its own.

- Skill: `songyw2003/paper2poster` (Agent Skill, multi-file: 118 files)
- Install (CLI): `npx skillmds@latest add songyw2003/paper2poster`
- Raw SKILL.md: https://api.skillmd.com/api/skills/songyw2003/paper2poster/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: songyw2003 (https://skillmd.com/u/songyw2003)
- Updated: 2026-09-22
- Page: https://skillmd.com/skills/songyw2003/paper2poster

---


# paper2poster — paper_spec.md → HTML poster → PDF/PNG + editable PPTX

This skill is the **rendering stage** of a 3-skill pipeline. It assumes `paper2assets` has already produced the input `<outdir>/`. Given that outdir, it picks figures, renders an HTML poster, iteratively fills the layout to fit a fixed page (60×36in landscape or 33.1×46.8in A0 portrait), generates per-section narration audio, hands the HTML to the bundled **html2pptx** sub-skill (vendored at `html2pptx/`) for an editable `.pptx`, and exports PDF + PNG. The html2pptx handoff is a **standard final step**, not optional — one run yields `poster.{html,pptx,pdf,png}`, because users want the editable deck in the same pass and won't call html2pptx separately.

```
   <outdir>/                              (produced by paper2assets)
     manifest.json   assets/figures/   assets/logos/   assets/qr/   assets/meta/{paper_spec.md, text.txt, figures.json, metadata.json}
     │
     ▼  Step 1 — verify prerequisites
     ▼  Step 2 — pick figures (Method / optional Motivation / optional Secondary)
     ▼  Step 2.5 — optional per-figure visual box cut (asymmetric noise the
     │             paper2assets deterministic chain couldn't catch — most
     │             figures need no further work here)
     │
     ▼  Step 3 — compose (layout × style × header) + substitute  (references/compose_poster.py → poster.html)
     │         → <outdir>/poster.html (lean: 6 core sections, Necessary only)
     │
     ▼  Step 4 — iterative fill loop (check_poster.py slack + polish)
     │         → <outdir>/poster.html (every section FULL, every figure ≥90% on one axis)
     │
     ▼  Step 5 — generate_audio.py → <outdir>/assets/audio/<id>.mp3   (free Edge TTS, from <outdir>/assets/meta/narration.json)
     │         → the Listen buttons + Full Listen play these clips by id
     │
     ▼  Step 5.9 — fit_logos.py → pack the header institution logos to fill their zone
     │             (browser-measure + greedy shape-pack; baked into poster.html)
     │
     ▼  Step 6 — render_poster.py → <outdir>/poster.pdf + <outdir>/poster.png   (applies + bakes the expand into poster.html)
     │
     ▼  Step 7 — html2pptx skill → <outdir>/poster.pptx   (reads the baked poster.html; users want the editable pptx in one run)
     │         (render FIRST — bakes the expand into poster.html; html2pptx then reads it, isolated under assets/_pptx_build/)
     │         then check_poster.py verify-final
     │
     ▼  Step 7.5 — check_poster.py deliverables (MANDATORY final gate)
     │
     └─→  Step 8 — Report absolute paths (html + pptx + pdf + png)
```

The canvas is fixed per orientation (landscape 60×36in 5:3 / portrait A0 33.1×46.8in 0.708) in the templates; do not change it.

## Mandatory finishing gates (NEVER ship outside these bands)

Two hard requirements gate the final poster. They are NOT warnings, NOT advisory, NOT "polish for next iteration" — a poster that violates either MUST be re-iterated before you call the task done:

1. **Every card figure fills 90–100% of its section on at least one axis (width OR height).** `check_poster.py polish` reports this as `FIG/NARROW`. A figure painting under 90% on *both* axes is a small stamp marooned in its card — a visible defect. Stated as one number: `fillRatio = max(w_fig / w_section, h_fig / h_section)` MUST land in `[0.90, 1.00]`. The fix order (cap tune → tighten the figure-section's prose → shorten the figcaption → rebalance the column) is in `references/staged_fill.md`. Do not exit the staged-fill loop with any figure below 90%.

2. **Every section reads `FULL` (fullRatio 0.90–1.00).** `check_poster.py slack` reports this. `OVERFLOW` (>1.10), `SPILLAGE` (1.00–1.10), `SPARSE` (0.70–0.90), and `EMPTY` (<0.70) are all unacceptable finishing states.

Both gates compose: an edit that fixes one but breaks the other must be rolled back. Loop until `slack` shows every section `FULL` AND `polish` reports zero `FIG/NARROW` warnings — then render PDF/PNG and run `verify-final` + `deliverables`.

## When to consult which reference

| Step | When you hit it, read |
|---|---|
| Step 3 — compose the template | `references/compose_poster.py` (assemble layout × style × header → self-contained `poster.html`; landscape only) |
| Step 3 — substitute placeholders | `references/template_substitution.md` (placeholder map, theme color randomization, per-section accents, vertical-sizing rule, lean-render policy) |
| Step 3 — content patterns | `references/content_patterns.md` (catalog of 16 reusable CSS widgets — callouts, key-stat, vs-compare, numbered-steps, timeline ×4 variants, chips, definition, highlight-table, pullquote, bento, equation, banner — to break up wall-of-text monotony in section bodies) |
| Step 3 — polish | `references/visual_polish.md` (typography, color/contrast, inline emphasis rules, stat grid, figure cap, callouts, arch banners, print hygiene) |
| Step 4 — staged fill | `references/staged_fill.md` (slack command, the iterative measure→select→apply→review loop, the modification-method catalog, shave-back rules) |
| Step 5.9 — pack header logos | `references/fit_logos.py` (browser-measure each logo zone + greedy shape-pack so the marks fill it; bakes rows into poster.html — run after the fill loop, before render) |
| Step 5 — audio (generate) | `scripts/generate_audio.py` synthesizes `<outdir>/assets/audio/<id>.mp3` from the `<outdir>/assets/meta/narration.json` script (produced upstream by paper2assets; free Edge TTS) for the Listen buttons. See `references/audio_narration.md`. |

## Workflow

### Parallel-safety (READ FIRST — never violate)

This skill is frequently run on MANY papers at once — a batch driver
launches one `claude -p` session per paper, each with its own separate
`<outdir>` (e.g. `papers/foo_portrait/`, `papers/bar_portrait/`, …
running concurrently). Therefore:

- **NEVER run `ps`, `pkill`, `kill`, `killall`, or otherwise inspect or
  terminate other processes.** Any other `claude` / `python` /
  `chromium` / `soffice` processes you see belong to SIBLING poster jobs
  on DIFFERENT papers. They are NOT duplicates of you and NOT competing
  for your files. Killing them corrupts other papers' runs.
- **Only ever read/write files inside YOUR given `<outdir>`.** Never
  touch another paper's directory or the shared template/config dirs.
- **If `<outdir>/poster.html` appears to have "changed unexpectedly"
  between a Read and an Edit, it changed because of YOUR OWN prior tool
  call** (a Write, a substitution script you ran, a prior Edit) — NOT a
  competing process. Re-Read the file and continue. Do not investigate
  "who else is modifying it" — nobody else is.
- The shared template at `<config>/skills/paper2poster/assets/*.html`
  is READ-ONLY input — copy it into your `<outdir>`, never edit it
  in place.

Violating any of the above is the single most common cause of a
fast-fail (~400s) where the agent rabbit-holes on phantom "process
conflicts" instead of building the poster.

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

Rendering a poster runs Stage 2's iterative staged-fill loop (~15-30
min of Claude tokens) and overwrites `<outdir>/poster.html` —
destroying any inline edits, manual figure swaps, or layout tweaks the
user may have made. Before starting, **check whether the deliverables
already exist**:

```bash
if [[ -f "$outdir/poster.html" \
      && -f "$outdir/poster.pdf" \
      && -f "$outdir/poster.png" ]]; then
  echo "[paper2poster] CACHED in $outdir — poster from prior run, reusing."
  echo "  poster.html  $(stat -c%s "$outdir/poster.html") bytes"
  echo "  poster.pdf   $(stat -c%s "$outdir/poster.pdf") bytes"
  echo "  poster.png   $(stat -c%s "$outdir/poster.png") bytes"
  # Report any extras
  [[ -f "$outdir/poster.pptx" ]] && echo "  poster.pptx  (html2pptx output present)"
  [[ -d "$outdir/assets/audio" ]] && echo "  assets/audio/       (narration present)"
  exit 0
fi
```

If all three core deliverables are present, REPORT and STOP.

Re-render ONLY when:
- one of `poster.{html,pdf,png}` is missing → resume from the
  appropriate step (`poster.html` missing → start from Step 2 fill loop;
  only `poster.pdf` / `poster.png` missing → just run Step 6 render)
- the user explicitly requests it ("rebuild the poster", "regenerate",
  "fresh render", "from scratch", "redo the layout"). In that case,
  delete `<outdir>/poster.html` first so the cache check doesn't fire.

### Step 1 — Verify paper2assets prerequisites

Required argument: an `<outdir>/` path produced by the `paper2assets` skill, or a path to a source `*.pdf`. Verify the required files exist before doing any work:

```bash
ls <outdir>/assets/meta/paper_spec.md <outdir>/assets/meta/text.txt <outdir>/assets/meta/figures.json <outdir>/assets/meta/metadata.json
ls <outdir>/assets/figures/*.png
```

If any of the five required files is missing, automatically invoke the `paper2assets` skill on the source PDF to produce/populate the `<outdir>/`, then continue.

**Optional files `<outdir>/assets/logos/` and `<outdir>/assets/qr/` may be absent** — but **DO NOT silently delete the logo/QR HTML blocks** just because the directory is missing. Two common reasons the directory is missing are recoverable:

1. paper2assets's Step 6 was skipped, or
2. paper2assets's Step 6 was called with the wrong CLI flags (the `--spec` / `--outdir <outdir>/assets/logos` traps documented in paper2assets SKILL.md Step 6 caused most papers to land logos at `<outdir>/assets/logos/logos/<slug>.png` instead of `<outdir>/assets/logos/<slug>.png`).

**Recovery procedure when `<outdir>/assets/logos/` is missing or empty:**

```bash
# A. Auto-fix the nested-dir bug if present
if [ -d <outdir>/assets/logos/logos ]; then
  mv <outdir>/assets/logos/logos/* <outdir>/assets/logos/ 2>/dev/null
  rmdir <outdir>/assets/logos/logos
fi

# B. Retry fetch_logos.py from this skill (it's a paper2assets script
#    but safe to call from paper2poster as a recovery step).
python ~/.agents/skills/paper2assets/scripts/fetch_logos.py \
    --from-spec <outdir>/assets/meta/paper_spec.md --outdir <outdir>

# C. Same for qr/ if missing
python ~/.agents/skills/paper2assets/scripts/make_qr.py \
    --from-metadata <outdir>/assets/meta/metadata.json --outdir <outdir>
```

Only AFTER the retry, if logos/ is still empty (institute names didn't resolve to any Wikipedia infobox — happens for "Anonymous Institution" submissions and obscure labs), then remove the unused `<img class="logo">` elements from the poster HTML as a final fallback. Same for missing QR codes.

### Step 2 — Pick figures

Read `figures.json` + `captions.json` + `paper_spec.md`. Pick:

- **Method figure** — the figure that visualizes the paper's proposed approach. Usually labeled "Figure 1" or "Figure 2" and described in the Method section's caption ("our pipeline", "overview", "architecture"). Record its `width`, `height`, and `layout` from `figures.json`.
- **Motivation figure (optional)** — a figure that motivates the problem (a "failure mode" plot, a side-by-side comparison with prior art, a teaser). Pick only when one of the early figures clearly carries motivational signal. **Disjoint from Method.** Always rendered as `{column=half}` — full-width motivation figures are not supported.
- **Secondary figure (optional but encouraged)** — a Key Result plot, ablation chart, or qualitative samples figure. **Disjoint from Method + Motivation.** **Target ≥2 figures per poster** — Method alone leaves the empirical side as a prose-and-numbers wall.
- **Figure-rich mode → 3-column layout.** If the paper carries **more than 3 high-signal figures** (multiple result plots, a qualitative-samples gallery, architecture + component diagrams), select them all (soft cap ~6) instead of stopping at 2 — and render with the **3-column layout** (`--layout 3col`, Step 3) whose wider columns hold bigger figures, the way most author-GT posters do. Distribute the figures across all three columns; the Method figure stays the anchor. Pick only genuinely informative figures — never pad to hit a count.

Cite each picked figure with its file path: `assets/figures/<page>_figure<n>.png`. Verify the file exists on disk before relying on it.

### Step 2.5 — Per-figure visual box cut (optional, for asymmetric noise the deterministic chain couldn't catch)

`paper2assets` already ran the deterministic cleanup pipeline (`top-check` → `decaption` → `autotrim`) on every figure. Most picked figures need no further work.

If a picked figure has **asymmetric noise** the deterministic chain couldn't catch — multi-figure-page bleed on a SIDE (a vertical strip of an adjacent panel), an obvious orphan caption line a tight `decaption` threshold missed, or a region of figure content you want excluded — do one visual `box` pass:

1. **Read** the figure with the Read tool.
2. **Decide** a tight pixel bbox `(X0, Y0, X1, Y1)`.
3. **Apply:**
   ```bash
   python ~/.agents/skills/paper2assets/scripts/crop_figure.py box <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1
   ```
   This writes a one-time `<file>.png.bak` (or preserves the existing one from paper2assets' earlier work) and updates `figures.json`.
4. **Re-Read** the result. Cap at 3 attempts per figure; restore from `.bak` with `cp` if you over-cut.

For most papers this step is a no-op — skip when the picked figures look clean after paper2assets' deterministic pipeline.

### Step 3 — Render the HTML poster

**Pick THREE independent axes — layout × style × header — then COMPOSE.**

Landscape posters are now assembled from three orthogonal source axes under `assets/` (instead of one monolithic file per combination):

- `assets/layouts/{full,half,3col}.html` — **STRUCTURE** (column grid + `.section` cards + base CSS),
- `assets/styles/{solid,framed,simple}.css` — **VISUAL** style,
- `assets/headers/{v1,v2,v3,v4}.html` — the **TITLEBAR**.

`references/compose_poster.py` injects the chosen style at `{{STYLE_CSS}}` and the chosen header at `{{HEADER}}` into the chosen layout and writes ONE self-contained `poster.html` — structurally identical to the old monolithic templates, so `check_poster.py` / `render_poster.py` / the staged-fill loop all work UNCHANGED. Pick each axis independently — **four orthogonal axes** (layout × style × header × scan) composed from ~30 small source files, not an N×M×K×J explosion of monolithic templates.

**Portrait is now composed** (`--orientation portrait`, `assets/layouts_portrait/{full,half}.html`) — it takes the STYLE + COLOR + **HEADER** axes (all 11 styles + 8 themes + 5 A0 title formats `pv1`–`pv5`, light-default header). It has no Scan-to-Read section, so it composes those three axes only. Portrait sections must use the **content-pattern widgets** (`references/content_patterns.md`) with the same ≥5-distinct-types discipline as landscape — the narrow A0 columns make it *easier* to collapse to plain bullets/tables, so it needs the widget palette more.

**Axis 0 — orientation:**

1. **`landscape`** (default) — ICML / NeurIPS / CVPR standard 60×36 in, 5:3 aspect. 4-column outer grid. **Always use landscape unless the user has explicitly opted into portrait.**
2. **`portrait`** — ACL / NAACL / AAAI 2025 standard A0 portrait, 33.1×46.8 in, 0.708 aspect. 2-column outer grid. Use **ONLY** when the pipeline sets `POSTER_ORIENTATION=portrait`. **Do NOT auto-detect orientation from the Method figure aspect ratio** — figure shape drives the layout axis (full vs half) WITHIN an orientation, not the orientation itself. A tall Method figure in landscape goes in a `half` template's single column; it does not flip the whole poster to portrait.

If `POSTER_ORIENTATION` is unset (or set to `landscape`), use landscape templates. Period.

**Axis 1 — layout (driven by Method figure shape):**

1. **`full`** (landscape `--layout full`; portrait `layouts_portrait/full.html`) — use when the Method figure is **horizontally wide**: in landscape `AR ≥ 2.5`, in portrait `AR ≥ 1.2`. Also use when `{column=full}` in `figures.json`. In landscape, this picks a 4-col outer grid with the middle two columns merged into a `.mid-wide` block. In portrait, this picks a layout with the Method section in a full-width hero band (`.method-hero` — bullets-left + wide-figure-right side-by-side) above the 2-col body.

   **Portrait pseudo-section for bullets fill:** the hero band's LEFT bullets cell is wrapped in `<div class="section method-text" data-section="method-text">…</div>` — a visually-transparent pseudo-section that participates in the staged-fill loop. slack.py measures its fill ratio as `bullets_content_h / row_h`. When the figure stretches the row taller than the bullets need, the verdict surfaces `method-text SPARSE` and the LLM expands bullets until the cell fills. This is why the AR threshold can stay loose at 1.2 instead of the safer 1.8 — the pseudo-section absorbs medium-aspect-figure whitespace automatically.
2. **`half`** (landscape `--layout half`; portrait `layouts_portrait/half.html`) — default within the orientation. Use when Method figure AR is moderate or tall AND `{column=half}` in figures.json, OR when Method figure is `**Figure:** none`. In landscape, Method is a half-width card in 1 of 4 cols. In portrait, Method is a card in 1 of 2 cols.

Read the Method figure's `width` and `height` from `figures.json`, compute `AR = width / height`, then:
- Landscape: `full` if AR ≥ 2.5 OR `{column=full}`, else `half`.
- Portrait: `full` if AR ≥ 1.2 OR `{column=full}`, else `half`. (The hero band's bullets cell is a pseudo-section that auto-fills via the staged-fill loop; medium-aspect figures are safe.)

**Figure-rich override (landscape — takes precedence over full/half).** If Step 2 selected **more than 3 figures**, use the **3-column layout** (`--layout 3col`) regardless of the Method figure's AR — its 3 wide equal columns (`1fr 1fr 1fr`) hold bigger, more numerous figures, matching the dominant author-GT layout. The Method figure card stays prominent in the middle column; the other figures distribute across columns. Via composition, `3col` now combines with **any** style and **any** header (the old "solid-only" limit is gone). The full/half choice above applies only when the poster carries ≤ 3 figures.

**Method-driven override (OPT-IN — only when the user explicitly asks for a "method-driven" poster).** Use `--layout methoddriven`. The **Method owns the wide middle block** (`.mid-wide`, the merged centre columns): a **large priority Method figure** on top, then the method split into **solid rounded subsection cards** (`.section.msub`, `data-section="method-1"…"method-N"`) that organize its sub-parts; the benchmark-style filler cards are dropped (side columns carry Problem/Motivation left, Key-Results/Headline/Takeaway right). This is opt-in — `--layout random` never selects it, and every other layout/style is unchanged. Rules:
- **Colour** = `multi-accent` by default: same hue as the theme `--accent`, different **depth per card** (`.msub.d1…d4`, theme-derived — tracks the 8-theme axis). Opt into distinct hues by adding class `mhue` to `<body>`.
- **The Method figure is the priority** — it fills the column WIDTH (so a wide banner clears the 90% figure-fill gate on width; NO exemption needed) and is bounded only by a generous, fill-loop-tunable height guard `--method-fig-max` (default `42cqh`). Raise/lower `--method-fig-max` to trade figure size against card room. The `<figcaption>` flows below.
- **The subsection cards adapt to the figure — the fill loop chooses the arrangement per-paper** (no fixed 2×2). `.msubs` is a span-composable grid; tag individual cards + order them to compose the layout:
  - `.msub.wide` → full-width long **row** (first card = top row, last card = bottom row)
  - `.msub.tall` → **tall** card spanning 2 rows (the two normal cards flow the other column)
  - add `cols-3` to `.msubs` → a **3-up** equal-column row
  - Examples: **tall+2** (one `.tall` + two normal); **row-top/bottom+2** (a leading/trailing `.wide` + two normal); **2×2** (four normal); **3-up** (`.cols-3` + three normal). Add/remove cards freely.
- **Fit the cards into the room the figure left.** Run the `pack` pre-check; measure every `.msub` with `slack` (each must read FULL 0.90–1.00). If cards are SPARSE/OVERFLOW or unbalanced, **re-pick the arrangement** (add/remove a `.wide`/`.tall`, change card count, switch to `.cols-3`) or nudge `--method-fig-max`, rather than only padding prose. In any multi-card row, **balance the cards' content** or the shorter one trails blank (polish Gate C / CARD-TRAILING).

**Axis 2 — style (`POSTER_STYLE`, default randomize):**

The first three are **full themes** (they retheme the header + page background); styles 4–11 are **block-only** card treatments that ride the solid theme (accent header, flat white logo chips) and just change the section-card look.

1. **`solid`** — the *classic* look: solid-filled accent titlebar (accent bg, white text), section `h2` = colored accent text with a thin underline. Sections are quiet warm cards with a top accent stripe.
2. **`framed`** — the *editorial* look: outlined rounded titlebar frame (white bg, accent border + accent title), section `h2` = solid accent-filled banner edge-to-edge; white section cards with a neutral thin frame.
3. **`simple`** — the *minimal white* look: white header + a single thin rule beneath it, near-black plain headings, frameless section cards separated only by a hairline top rule.
4. **`left-bar`** — thick accent rail down the card's left edge; plain accent heading.
5. **`elevated`** — floating white card with a soft deep shadow, rounded corners.
6. **`neo-brutal`** — hard black border + offset accent drop-shadow; uppercase headings.
7. **`tag`** — heading rendered as an inline accent pill.
8. **`underline`** — bold accent rule hugging the heading.
9. **`tinted`** — faint accent-tinted card background.
10. **`double-rule`** — centered heading between two thin accent lines.
11. **`legend-frame`** — heavy accent border with the centered heading sitting on the top edge, breaking the border (fieldset/legend look).

**Default style policy:** for landscape, **randomize across all 11**. Pass **`--style random`** so `compose_poster.py` picks one DETERMINISTICALLY from a hash of the output path — a reproducible spread across a wave. Do NOT ask the model to "pick a random style" (it defaults to solid in headless); use the `random` keyword. Override a specific one via `POSTER_STYLE=<name>`. The block-only styles (4–11) ride the solid header theme. **Portrait now composes too** (`--orientation portrait`, reads `assets/layouts_portrait/`): it takes the STYLE + COLOR + HEADER axes, so all 11 styles + 8 themes + **5 A0 title formats** (`pv1`–`pv5`, `assets/headers_portrait/`) ride portrait, all on the light-default header. The portrait title formats: **pv1** centered-classic, **pv2** title-left, **pv3** banner masthead (centered title + rule, venue/logos row below), **pv4** logo-forward (marks left, title right), **pv5** centered stack.

**Axis 5 — color/theme (`POSTER_THEME`, default `random`):** 8 academic accent bundles (`blue` · `teal` · `green` · `burgundy` · `purple` · `rust` · `slate` · `plum`) — the palette the paper's gallery recolor used. Each swaps `{--accent, --accent-soft}` (and the audio `--play-highlight-blue`); the result-register `--callout` (crimson) stays fixed across all themes. With the landscape **light-default header** (`--tb-bg: var(--accent-soft)`, dark title) each theme paints a pale-tint header + colored `<h2>` text/underlines on white cards — the light look, not a dark filled band. `--theme random` hash-samples one theme from the output path — **truly random and reproducible**, replacing the old "model hand-edits `:root`" step. Defined once in `references/apply_theme.py` (`THEMES`); shared by landscape and portrait (both via `compose_poster.py`).

**Axis 3 — header (`POSTER_HEADER`, default randomize):**

1. **`v1`** — venue (left) · title (center) · institution logos (right). Symmetric 3-zone band.
2. **`v2`** — mirror of v1: institution logos (left) · title (center) · venue (right).
3. **`v3`** — title centered full-width with a single equal-height logo strip below (conference mark + institutions).
4. **`v4`** — title (left) · venue + institution logos stacked (right). A common GT layout.
5. **`v5`** — *classic*: venue **text** badge (left) · title (center) · institution logos **+ Paper/Code QR tiles** (right). The one header that carries the QR in the titlebar. **Opt-in only** (`POSTER_HEADER=v5`) — when chosen, suppress the standalone `scan-to-read` section (set both `{{QR_*}}` empty) so the QR is not duplicated.

v1–v4 each render the conference **logo** when `assets/logos/_venue.png` exists (Step 6 `fetch_conf_logo.py`), else a text venue/year fallback in the same chip; v5 uses a text venue badge by design. All work for **2–6 institutions** (empty `LOGO_n` slots auto-hide). Logos are sized to **fill** their zone (single venue logo + a 2-row institution grid), and the logo chips **theme to the chosen style** via `--tb-chip-bg` / `--tb-chip-shadow`: solid → flat white chip (no shadow) on the accent band; framed → flat white chip on the white card; **simple → transparent chip (no frame), logos sit directly on the white header**. Default: pass **`--header random`** — `compose_poster.py` picks one DETERMINISTICALLY (output-path hash) from **all five (v1–v5)**; v5 fills its own titlebar QR via `{{HDR_QR_*}}` (see the QR contract). Override via `POSTER_HEADER={v1|v2|v3|v4|v5}`.

**Default font policy:** the poster body font defaults to **Arial** — a cross-platform-safe family pre-installed on Mac + Windows PowerPoint, so the exported `.pptx` needs **no font embedding** and round-trips cleanly. To override, edit the chosen template's `--font-latin` CSS variable (in the `:root` block) to any of the 8 PPT-safe families: `Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS"`. The optional `POSTER_FONT` env var, when set, carries the same choice — but the default lives in the templates, not in any external script. To use **Inter** (the bundled webfont — more editorial, but not pre-installed), flip `--font-latin` back to `Inter, …` *and* run the html2pptx Inter embed step so the `.pptx` ships the font; the 4 Inter `@font-face` blocks stay defined (inert) in every template for exactly this one-line override.

**Composition catalog (landscape):**

| Axis | Choices | Source files | Pick via |
|------|---------|--------------|----------|
| layout | `full` · `half` · `3col` | `assets/layouts/<layout>.html` | Method-figure AR / figure count (Axis 1) → `--layout` |
| style | `solid` · `framed` · `simple` · `left-bar` · `elevated` · `neo-brutal` · `tag` · `underline` · `tinted` · `double-rule` · `legend-frame` (11) | `assets/styles/<style>.css` | `POSTER_STYLE` (default randomize all 11) → `--style` |
| header | landscape `v1`·`v2`·`v3`·`v4`·`v5`(opt-in) / portrait `pv1`·`pv2`·`pv3`·`pv4`·`pv5` | `assets/headers/<v>.html` · `assets/headers_portrait/<pv>.html` | `POSTER_HEADER` (default random) → `--header` |
| scan | `single` · `dual` (group keywords — recommended) · `aside`(default) · `hero` · `contact` · `directory` · `banner` · `twin` · `chips` | `assets/scan/<variant>.html` | code QR resolves → `--scan dual`, else `--scan single` (Axis 4) → `--scan` |
| color/theme | `blue` · `teal` · `green` · `burgundy` · `purple` · `rust` · `slate` · `plum` (8 accents) | `references/apply_theme.py` (`THEMES`) | `POSTER_THEME` (default `random`, deterministic per output-path) → `--theme`. Applies to landscape (via compose) **and portrait** (explicit `apply_theme.py` call). |

`compose_poster.py` validates each choice and, on a bad name, aborts listing the available options. The old monolithic `poster_*_{solid,framed}.html` / `poster_portrait_*.html` templates have been **retired** — `compose_poster.py` (layout × style × header × scan × color, `--orientation portrait` for A0) is the only path; the source now lives entirely in `assets/{layouts,layouts_portrait,styles,headers,headers_portrait,scan}/`.

**Generate `poster.html` WITHOUT ever emitting its full contents through your output channel — hard requirement.** The template is ~100 KB (≈ 30–40k tokens); writing it inline with the `Write` tool overflows the per-turn output-token cap (`CLAUDE_CODE_MAX_OUTPUT_TOKENS`, default **32000**) and **kills the run** — and the measured-fill loop would re-pay that cost every round. Generate it *indirectly* so the bulk template never passes through your output:

1. **Compose the template** (disk-to-disk, zero output tokens — do NOT `Write` it):
   ```bash
   # landscape — assemble layout × style × header into ONE self-contained poster.html
   python references/compose_poster.py \
     --layout <full|half|3col> --style <solid|framed|simple|left-bar|elevated|neo-brutal|tag|underline|tinted|double-rule|legend-frame> --header <v1|v2|v3|v4|v5> \
     --scan <single|dual> --theme <random|blue|teal|green|burgundy|purple|rust|slate|plum> \
     --out <outdir>/poster.html
   # portrait — composed too (STYLE + COLOR + HEADER axes; 5 A0 title formats pv1-pv5,
   # no scan section): reads assets/layouts_portrait/ + assets/headers_portrait/
   python references/compose_poster.py --orientation portrait \
     --layout <full|half> --style <solid|framed|simple|left-bar|elevated|neo-brutal|tag|underline|tinted|double-rule|legend-frame> \
     --header <random|pv1|pv2|pv3|pv4|pv5> \
     --theme <random|blue|teal|green|burgundy|purple|rust|slate|plum> \
     --out <outdir>/poster.html
   ```
   `compose_poster.py` resolves the STRUCTURAL hooks (`{{STYLE_CSS}}`, `{{HEADER}}`, and landscape-only `{{SCAN_SECTION}}`) AND the COLOR axis (rewrites the `:root` accent vars to the resolved theme); every CONTENT `{{...}}` placeholder survives for the next step. **`--scan`:** pass the GROUP keyword `dual` only when `make_qr.py` emitted TWO QR slots (two genuinely distinct URLs survived de-duplication), else `single` — compose deterministically picks a fitting variant within the group (so a 2-QR layout never lands on a 1-link paper, whose paper/project/code URLs collapse to one QR); `--scan random` or an explicit variant name also work. **QR-count guard (belt-and-suspenders):** if you pass a single-QR context (`single`, or an explicit `hero`/`contact`/`banner`) but `metadata.json` actually carries **two** QR files on disk, `compose_poster.py` auto-upgrades to the `dual` group so the second (project/code) QR is never silently dropped — still pass `dual` explicitly when a code QR resolved. **`--theme`:** default `random` picks one of 8 academic themes DETERMINISTICALLY from the output-path hash (reproducible spread across a wave) — do NOT hand-edit `:root` colors; override a specific one with `--theme <name>` or `POSTER_THEME`. **`--header`:** default `random` (landscape v1-v5 / portrait pv1-pv5). **`--math`:** the math-typesetting engine, default **`katex`** (thinner glyphs, posterskill-like) — the ONE place to switch is `MATH_ENGINE_DEFAULT` in `compose_poster.py`, or per-run `--math mathjax` / `POSTER_MATH=mathjax`. Both engines are bundled offline (`assets/katex/`, `assets/mathjax/`) and intercepted by the renderer + html2pptx (whose math pass is engine-agnostic), so flipping it needs no template/pptx change. Injected at the `{{MATH_HEAD}}` hook in every layout (landscape + portrait).
2. **Substitute placeholders with the `Edit` tool**, one `{{...}}` token (or one section block) at a time — each `Edit` emits only the small placeholder plus your paper-specific content, never the surrounding template. **Never reconstruct and `Write` the whole file.**

Equivalent Opus-style alternative when you have many substitutions: **copy the ready skeleton at `references/build_poster.py`** (it carries the real placeholder names, a depth-aware optional-section drop for the lean render, and a leftover-`{{...}}` check), fill its `SUBS` dict with this paper's content, and run it on the composed `poster.html` — the template is read from disk at runtime and never enters your output. Either path is fine; the invariant is identical: **the full HTML must never appear in a tool call's output** (this is the single most common cause of a smaller model aborting on a large poster).

**Read `references/template_substitution.md` now** — it carries the full placeholder map, the code-driven theme color (1-of-5, applied by compose / `apply_theme.py` — do NOT hand-edit `:root`), per-section accent palette, vertical-sizing convention (`grow` on the bottom-most section only), and the **lean initial render policy**: only `Necessary` of the six core sections; all three optional sections (Contribution, Dataset / Benchmark, Ablation Study) and every `Additional` paragraph are deliberately withheld at this stage.

**Decoupled-header + QR placeholder contract (NEW — edge cases).** The composed headers (v1–v4) replace the old titlebar, so the placeholder set changed:
- **Venue:** the header uses `{{VENUE_NAME}}` + `{{VENUE_YEAR}}` (text) and an optional `{{VENUE_LOGO}}`. Set `VENUE_LOGO` to `assets/logos/_venue.png` **only if that file exists**, else `""` — when empty, the header paints the VENUE/YEAR text in the conference chip (and preflight won't flag a dead image). The old `{{VENUE}}` / `{{VENUE_LINK}}` / `{{VENUE_TAG}}` header fields are **gone**; don't emit them.
- **Institution logos:** `{{LOGO_1}}`…`{{LOGO_6}}` (up to six). Fill each present institution's path; set every **unused** slot to `""` (empty/unfilled chips auto-hide). Works for 2–6 institutions.
- **QR placement depends ONLY on the header — the QR appears in exactly ONE place, and NEVER in the Title Section except for `v5`:**
  - **Headers `v1` / `v2` / `v3` / `v4` →** the Title Section carries NO QR (these headers have no QR slot at all). ALWAYS fill the standalone **Scan to Read** `.section` (`data-section="scan-to-read"`, right after Takeaway) via `{{QR_PAPER}}` / `{{QR_CODE}}`, and set the header's `{{HDR_QR_PAPER}}` / `{{HDR_QR_CODE}}` to `""`. Institution count is **irrelevant** — the old "≤ 2 institutions → header QR" rule is **RETIRED**; the QR never joins the logo row. The section's **internal layout is the `--scan` axis** (Step 3): pass `--scan dual` when a code QR resolves, else `--scan single`, so a two-QR layout never lands on a one-QR paper. Beyond `{{QR_PAPER}}`/`{{QR_CODE}}`, the picked variant may also expose the display-URL placeholders `{{URL_PAPER}}` / `{{URL_CODE}}` / `{{URL_PROJECT}}` (short URL text from `metadata.json`, e.g. `arxiv.org/abs/2106.09711`) and `{{CONTACT}}` — fill whatever exists and leave the rest `""`; every one auto-hides when empty. **Exception — `--layout 3col`:** the standalone Scan-to-Read section is **suppressed** in the 3col layout (its 1/3-width column is too wide for the section's small content and reads as empty), so a 3col poster intentionally carries **NO QR**. When you compose with `--layout 3col`, leave `{{QR_PAPER}}` / `{{QR_CODE}}` (and the other scan content placeholders) empty — they would render into a hidden section anyway.
  - **Header `v5`** (classic) is the ONLY header with a titlebar QR — with `v5`, ALWAYS fill `{{HDR_QR_PAPER}}` / `{{HDR_QR_CODE}}` and leave the section `{{QR_*}}` empty, so the QR shows once (in the v5 header) and the `scan-to-read` section auto-hides.
  - **Render-time guarantee (CSS — belt-and-suspenders, you do NOT rely on the build filling the right one):** every layout hides `.section[data-section="scan-to-read"]` whenever the titlebar carries a FILLED QR (`body:has(.titlebar img.qr-img[filled]) , body:has(.titlebar .qr-tile .chip.qr img[filled]) -> [data-section="scan-to-read"]{display:none}`). So even if BOTH the header QR and the section QR get filled, the standalone Scan-to-Read section is suppressed at render time and the QR can never appear twice.
  - Every QR placeholder auto-hides when empty or when its `assets/qr/*.png` is absent — set a path only if the file exists.
- **Logo autotrim:** `fetch_logos.py` / `fetch_conf_logo.py` now rasterize (SVG→PNG) and crop the transparent/near-white border so chips hug the mark — automatic, best-effort, no action needed here.

After substitution, apply the visual polish layer — **read `references/visual_polish.md`** for typography, color, the inline-emphasis vocabulary (`<strong>` / `.hi` / `.num`), stat grid, figure cap, callout and arch components, and print hygiene (the canvas locks per orientation: landscape 60×36in / `cqw` / `aspect-ratio: 5 / 3`; portrait 33.1×46.8in / `cqw` / `aspect-ratio: 33.1 / 46.8` — **never edit**).

**Read `references/content_patterns.md` now and BREAK UP THE WALL-OF-TEXT.** This is not optional reference material — it is a hard requirement for visual quality. The 16-widget catalog (callouts, key-stat, vs-compare, numbered-steps, timeline ×4, chips, definition, highlight-table, pullquote, bento, equation, banner) exists specifically because plain `<p>` + `<ul>` across every section makes the poster read as undifferentiated text. **Rules:**

- **Every section body MUST contain at least ONE pattern widget.** Plain `<p>` + `<ul>` alone is a failure mode (verified empirically — without this rule, posters ship with only 1 widget across 9 sections).
- **Across the full poster, use at least 5 DISTINCT pattern types.** A poster that uses `.p-callout-soft` 9 times still reads as monotonous. Vary the widget across sections so adjacent sections look visually different.
- **Cap at 2 widgets per section** (so a section doesn't stack 3 callouts + a key-stat + a chips strip = different kind of clutter).
- **Match widget to content shape:** pick from the catalog's "Shape of content it suits" column — `key-stat` for sections dominated by one number, `vs-compare` for Theirs/Ours sections, `numbered-steps` for pipelines, `chips` for taxonomy/dataset/baseline lists, etc.

The figure/logo/QR assets live under `<outdir>/assets/{figures,logos,qr}/` (placed there by paper2assets), and `poster.html` references them with `src="assets/figures/…"`, `src="assets/logos/…"`, `src="assets/qr/…"`. The `path`/`file` values in `figures.json`, `fetch_logos.py`, and `make_qr.py` manifests already carry the `assets/` prefix, so dropping them verbatim into `src` makes the relative paths resolve from the poster's own location without further action.

### Step 4 — Iterative fill to exactly fit the page

**First, the column-pack pre-check (one calculation, before any fill round).** Run `python3 scripts/check_poster.py pack <outdir>/poster.html`. It flags any column whose figure floors + minimum text already exceed the column height — a **negative-slack column is INFEASIBLE**, and the fill loop would oscillate there for ~20 rounds (the single biggest time-sink measured: one opus run burned ~15 min on one such column). Re-pack a flagged column *before* filling — move a text section or the figure to a looser/wider column, or (if TOTAL slack is negative) drop/shrink a figure or cut text — and enter the loop only when every column's slack ≥ 0. Details: `references/staged_fill.md` → "Column-pack pre-check".

The lean initial render usually leaves some sections under-filled. Grow content with an **iterative loop** until every section reads `FULL` (`fullRatio` 90–100% of the card height, padding included). Each pass: **first run `check_poster.py autofit <outdir>/poster.html`** — it deterministically closes the continuous-lever gaps a machine can size exactly (every `.grow`-card row-gap gap AND the scan-to-read QR height, using the `needPx` the report already computes, bounded by the column budget) and prints the residual sections that still need YOUR content/figure edits — then measure with `check_poster.py slack --with-polish`, read the per-section verdicts, pick the **one or two modification methods** best matched to the current defects, apply them, then re-measure to review and keep-or-rollback. There is no fixe

…(truncated)
