# 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 an output bundle with `manifest.json` at the root and an `assets/` folder holding the paper metadata and media. Use when the user wants an HTML poster, PDF/PNG export, or PPTX from extracted paper assets, for example "render the poster", "make the poster from this spec", "export poster to PDF", or "paper2poster". The three skills paper2assets → paper2poster → html2pptx run in sequence, each invokable on its own.

- Skill: `microsoft/paper2poster` (Agent Skill, multi-file: 113 files)
- Install (CLI): `npx skillmds@latest add microsoft/paper2poster`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/paper2poster/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Design & Media
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/microsoft/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: select figures from upstream semantics
     │         → assets/meta/figure_selection.json
     │           (Method / high-confidence optional Motivation / Result)
     ▼  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 and portrait) |
| 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` (12 semantic widget families — three callouts, key-stat, stat-strip, vs-compare, numbered-steps, chips, highlight-table, equation, timeline, and banner — to break up wall-of-text monotony in eligible 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
  if python ~/.claude/skills/paper2poster/scripts/select_figures.py "$outdir" \
      && python ~/.claude/skills/paper2poster/scripts/check_poster.py \
          preflight "$outdir/poster.html"; then
    echo "[paper2poster] CACHED in $outdir: semantic preflight passed, 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"
    [[ -f "$outdir/poster.pptx" ]] && echo "  poster.pptx  (html2pptx output present)"
    [[ -d "$outdir/assets/audio" ]] && echo "  assets/audio/       (narration present)"
    exit 0
  fi
  echo "[paper2poster] cached deliverables are semantically stale; upgrading " \
       "Paper2Assets and rebuilding instead of reusing them."
fi
```

If all three core deliverables are present, REPORT and STOP only when the
selector and semantic preflight both pass. A legacy, partial, mixed-generation,
or wrongly attributed cached poster must continue through Step 1, upgrade its
Paper2Assets package, and be rendered again. Cached PDF/PNG existence alone is
never proof that the Motivation image is correct.

Re-render ONLY when:
- the user changes the header template, including switching to/from an explicit
  no-institution header → recompose and re-export instead of reusing the cache
- 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

**No institution names/logos requested?** Select the explicit unbranded header
first: Landscape `v6` (centered) / `v7` (left), Portrait `pv6` (centered) / `pv7`
(left). Read `references/branding_free_headers.md`. For these templates, skip the
logo recovery below, preserve any shared logo assets, and still recover QR assets
if needed. Their structure deliberately has no affiliation/logo slots; do not
add them during substitution or fitting. This exception also applies to all
later logo-fetching and affiliation instructions. The regular cache shortcut
does not apply when the requested header differs from the cached poster.

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
```

If any of the four required files is missing, automatically invoke the
`paper2assets` skill on the source PDF to produce/populate the `<outdir>/`, then
continue. `figures.json` may legitimately be an empty list; the selector and
preflight validate every non-empty record and its on-disk asset.

**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 ~/.claude/skills/paper2assets/scripts/fetch_logos.py \
    --from-spec <outdir>/assets/meta/paper_spec.md --outdir <outdir>

# C. Same for qr/ if missing
python ~/.claude/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

Run the deterministic semantic selector before composing or substituting the
poster:

```bash
python scripts/select_figures.py <outdir>
```

It writes `<outdir>/assets/meta/figure_selection.json`. Treat that manifest as
the single source of truth for the primary figure placeholders:

- `selections.method.file` -> `{{METHOD_FIGURE}}`
- `selections.motivation.file` -> `{{TEASER_FIGURE}}` when non-null
- `selections.result.file` -> `{{SECONDARY_FIGURE}}` or the primary figure in
  the Key Result section

Each non-null entry also carries the stable `figure_id`, caption, semantic
confidence, and evidence. Do not replace a selected
file with a different image based on visual guesswork. `check_poster.py
preflight` independently recomputes the deterministic selection and hard-fails
a stale manifest, a role mismatch, or Method/Motivation reuse.

The selector consumes the additive Paper2Assets semantic fields
`semantic_roles[]` and `section_relevance[]`. Selection is deterministic and
disjoint:

- **Method figure** requires `method >= 0.55` and rejects empirically dominant
  or near-tied Result/Ablation/Qualitative galleries. This prevents captions
  that happen to say "our method" from displacing the actual system overview.
- **Motivation figure is optional and high-confidence only.** It requires
  `motivation >= 0.62`, rejects every candidate carrying `method`, `result`, or
  `ablation >= 0.55`, and is always disjoint from Method. If it is null, remove
  the entire Motivation `<figure>` block. Never fill that space with a Method
  or Result image.
- **Result figure** requires `result` or `qualitative >= 0.55`, rejects clearly
  Method-dominant diagrams, and is disjoint from Method and Motivation.
  **Target >=2 figures per poster** only when an
  eligible empirical figure exists. Do not pad a poster with a semantically
  unrelated image to reach the target.
- **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.

**Legacy bundles must be upgraded.** When `figures.json` has no complete
`figure_semantics.v2` producer contract, including partial/prototype semantic
fields, the selector exits non-zero and preflight fails. Reuse the existing
assets and run Paper2Assets `build_package.py --skip-extract --paper-spec ...`,
then rerun the selector. Manual/raw-model figure selection is not an allowed
fallback because it recreates the Motivation mismatch this gate prevents.

Cite each selected figure with its file path and verify it exists 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 ~/.claude/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

**Choose the orientation-aware composition axes, then COMPOSE.**

Both orientations share four independent axes under `assets/`: **layout × style × header × theme**. Landscape adds a fifth, **Scan-to-Read**, while Portrait deliberately has no standalone scan axis:

- **Structure:** landscape `assets/layouts/{full,half,3col}.html`; portrait `assets/layouts_portrait/{full,half}.html`.
- **Visual style:** `assets/styles/*.css`; Landscape enables all 11 treatments, while Portrait excludes `underline` and `double-rule` and enables the remaining 9.
- **Titlebar:** landscape `assets/headers/{v1,v2,v3,v4,v5,v6,v7}.html`; portrait `assets/headers_portrait/{pv1,pv2,pv3,pv4,pv5,pv6,pv7}.html`. The `*6` / `*7` variants are opt-in, without institution names/logos.
- **Color/theme:** 9 bundles from `references/apply_theme.py`, shared by both orientations.
- **Scan-to-Read:** landscape only, from `assets/scan/*.html`.

`references/compose_poster.py` injects the chosen style at `{{STYLE_CSS}}`, the chosen header at `{{HEADER}}`, the landscape-only scan block at `{{SCAN_SECTION}}`, and the resolved theme into the chosen layout. It writes ONE self-contained `poster.html`, so `check_poster.py` / `render_poster.py` / the staged-fill loop work unchanged without an N×M×K×J explosion of monolithic templates.

**Portrait is now composed** (`--orientation portrait`, `assets/layouts_portrait/{full,half}.html`) with the STYLE + COLOR + **HEADER** axes: 9 eligible styles, 9 themes including `mono`, and 5 A0 title formats `pv1`–`pv5` on the light-default header. `underline` and `double-rule` are Landscape-only because their horizontal rules create misleading section divisions in narrow Portrait columns. Portrait has no standalone Scan-to-Read section or `--scan` axis, but it does have a composed header and its header-owned QR slots. Portrait sections must use the **content-pattern widgets** (`references/content_patterns.md`) with the same ≥5-distinct-semantic-families discipline as landscape. The narrow A0 columns make it *easier* to collapse to plain bullets/tables, so Portrait 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. The `half` layout uses a 2-column body; the `full` layout uses four stacked content bands. 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 four stacked content bands: Problem | Motivation; the full-width `.method-hero`; Key Results | Ablation | Headline Numbers at `1.5fr 1fr 1fr`; and a full-width Takeaway.

   **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:
- **Subsection headings** in both `methoddriven` and `methoddriven4` use outlined square **A, B, C, D…** badges (`.msub-h .n`). Keep numeric steps inside cards separate; do not turn heading badges back into filled numbered circles. When adding/reordering cards, continue the letters in reading order while preserving `data-section="method-1"…"method-N"` for measurement and Reel interactions.
- **Colour** = `multi-accent` by default: same hue as the theme `--accent`, different **depth per card** (`.msub.d1…d4`, theme-derived — tracks the 9-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:** `--style` defaults to **`random`**, so `compose_poster.py` picks deterministically from the orientation's eligible catalog. Landscape randomizes across all 11 styles. Portrait randomizes across 9 and rejects explicit `underline` or `double-rule` requests. Override an eligible style via `POSTER_STYLE=<name>` or `--style <name>`. Do NOT ask the model to "pick a random style"; use the composer's random mode. The block-only styles ride the solid header theme. Portrait also composes 9 themes and **5 A0 title formats** (`pv1`–`pv5`, `assets/headers_portrait/`), all on the light-default header. The Portrait formats now differ structurally: **pv1** balanced `Venue+QR | centered title | institutions`; **pv2** full-width centered masthead over a `Venue+QR | institutions` navigation strip; **pv3** `left-top-aligned title | institutions | Venue+QR`, keeping institutional identity beside the paper information; **pv4** is pv1's centered mirror, `institutions | centered title | Venue+QR`; **pv5** mirrors pv3 as `Venue+QR | institutions | right-top-aligned title`. In pv2/pv3/pv5, one vertical rule separates the utility menu from institution marks while the two regions remain independently measurable by `fit_logos.py`.

**Axis 5 — color/theme (`POSTER_THEME`, default `random`):** 9 academic accent bundles (`blue` · `teal` · `green` · `burgundy` · `purple` · `rust` · `slate` · `plum` · `mono`) — 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 **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` samples one theme deterministically from the seed, 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):**

**Explicit no-institution templates:** `v6` / `v7` (Landscape), `pv6` / `pv7`
(Portrait) omit both institution names and all header logos by construction.
Choose centered (`*6`) or left-aligned (`*7`); both retain author names, venue
text and the orientation's normal QR behavior. Fill `{{AUTHORS_PLAIN}}` rather
than the affiliation-marked `{{AUTHORS}}`. They are excluded from random pools.
See `references/branding_free_headers.md` for the complete contract.

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 from the configured seed 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 defaults to **Arial**, and `--font-latin` may select any of the 8 Mac/Windows PowerPoint-safe family names: `Calibri | Aptos | Cambria | Arial | "Times New Roman" | Verdana | Georgia | "Trebuchet MS"`. These proprietary OS fonts are not reliably installed on the Linux renderer or every HTML client. Before browser geometry is measured, `check_poster.py` / `render_poster.py` freeze browser rendering to a compact, self-contained open-licensed subset under `assets/fonts/`, while html2pptx continues emitting the requested native family into `.pptx`. Arial uses bundled Arimo Regular/Bold because Arimo is metrically compatible with Arial; the other selections preserve an open DejaVu or Noto face from the renderer's actual fallback category. A fresh Arial poster, an old Arial/DejaVu fidelity block, or a local-first Arimo block is migrated to packaged-first Arimo through a directional media gate: media identity must stay fixed, every research figure must satisfy the absolute 90% minimum after migration, and no axis may newly exceed 101%. After that one repair, the Arimo layout becomes the strict source baseline for durable HTML, PDF, PNG, and Reel checks. The optional `POSTER_FONT` env var carries the same choice, but the default lives in the templates. To use **Inter**, 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 in every template for exactly this override.

**Composition catalog (landscape and portrait):**

| Axis | Choices | Source files | Pick via |
|------|---------|--------------|----------|
| layout | landscape `full` · `half` · `3col` / portrait `full` · `half` | `assets/layouts/<layout>.html` · `assets/layouts_portrait/<layout>.html` | Method-figure AR / figure count (Axis 1) → `--layout` |
| style | Landscape: all 11. Portrait: `solid` · `framed` · `simple` · `left-bar` · `elevated` · `neo-brutal` · `tag` · `tinted` · `legend-frame` (9) | `assets/styles/<style>.css` | `POSTER_STYLE` (default random within the orientation catalog) → `--style` |
| header | landscape `v1`–`v5` / portrait `pv1`–`pv5`; opt-in no institutions/logos: `v6`·`v7` / `pv6`·`pv7` | `assets/headers/<v>.html` · `assets/headers_portrait/<pv>.html` | `POSTER_HEADER` (default random, branded pool only) → `--header` |
| scan | landscape only: `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`; portrait has no scan axis |
| color/theme | `blue` · `teal` · `green` · `burgundy` · `purple` · `rust` · `slate` · `plum` · `mono` (9 accents) | `references/apply_theme.py` (`THEMES`) | `POSTER_THEME` (default `random`, deterministic per seed) → `--theme`. Applies to landscape and portrait via composition. |

`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 random --header random \
     --scan <single|dual> --theme random \
     --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 random --header random --theme random \
     --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. Portrait ignores this axis because it has no standalone scan section, while its `pv1`–`pv5` header still composes normally. **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 9 academic themes DETERMINISTICALLY from the seed — 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).

   **Deterministic selection and 30+ paper batches.** For an ordinary run, the random axes use `--seed` when supplied, then `POSTER_SEED`, otherwise the **resolved absolute `--out` path**. Resolving the path is important: separate paper outdirs no longer collapse to the same seed merely because each command says `--out poster.html`. For a large batch, pass consecutive zero-based `--variant-index` values and one shared `--variant-seed`; the sampler deterministically orders the joint random-axis combination space without replacement while keeping each axis marginally balanced. Landscape covers all 11 styles in 11 posters; Portrait covers its 9 eligible styles in 9 posters; 5 posters cover all 5 portrait headers; and 9 cover all 9 themes. Keep layout driven by the Method figure, or pass `--layout random` only when layout itself should join the balanced sampling:

   ```bash
   python references/compose_poster.py --orientation portrait \
     --layout full --style random --header random --theme random \
     --variant-index 17 --variant-seed portrait-wave-20260810 \
     --selection-out <outdir>/selection.json \
     --out <outdir>/poster.html
   ```

   Every composed HTML also embeds the resolved composition as JSON in `<script id="paper2poster-composition" type="application/json">` and stamps the main axes as `<body data-poster-...>` attributes. `--selection-out` writes the same auditable selection to a separate JSON file for gallery and sampling tests.
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-9, applied by compose / `apply_theme.py` — do NOT hand-edit `:root`), per-section accent palette, vertical-sizing convention (in Portrait Half, exactly one `grow` on the bottom-most section of each column), 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.** Landscape `v6`/`v7` follow the same body-scan rules as `v1`–`v4`. Portrait `pv1`–`pv5` expose `{{LOGO_1}}`…`{{LOGO_4}}` plus header QR placeholders; `pv6`/`pv7` expose o

…(truncated)
