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:
Every card figure fills 90–100% of its section on at least one axis (width OR height).
check_poster.py polishreports this asFIG/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 inreferences/staged_fill.md. Do not exit the staged-fill loop with any figure below 90%.Every section reads
FULL(fullRatio 0.90–1.00).check_poster.py slackreports this.OVERFLOW(>1.10),SPILLAGE(1.00–1.10),SPARSE(0.70–0.90), andEMPTY(<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 otherclaude/python/chromium/sofficeprocesses 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.htmlappears 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/*.htmlis 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:
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.htmlmissing → start from Step 2 fill loop; onlyposter.pdf/poster.pngmissing → 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.htmlfirst 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:
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:
- paper2assets's Step 6 was skipped, or
- paper2assets's Step 6 was called with the wrong CLI flags (the
--spec/--outdir <outdir>/assets/logostraps documented in paper2assets SKILL.md Step 6 caused most papers to land logos at<outdir>/assets/logos/logos/<slug>.pnginstead of<outdir>/assets/logos/<slug>.png).
Recovery procedure when <outdir>/assets/logos/ is missing or empty:
# 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, andlayoutfromfigures.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:
- Read the figure with the Read tool.
- Decide a tight pixel bbox
(X0, Y0, X1, Y1). - Apply:
This writes a one-timepython ~/.agents/skills/paper2assets/scripts/crop_figure.py box <outdir>/assets/figures/<file>.png --box X0 Y0 X1 Y1<file>.png.bak(or preserves the existing one from paper2assets' earlier work) and updatesfigures.json. - Re-Read the result. Cap at 3 attempts per figure; restore from
.bakwithcpif 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 +.sectioncards + 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:
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.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 setsPOSTER_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 ahalftemplate'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):
full(landscape--layout full; portraitlayouts_portrait/full.html) — use when the Method figure is horizontally wide: in landscapeAR ≥ 2.5, in portraitAR ≥ 1.2. Also use when{column=full}infigures.json. In landscape, this picks a 4-col outer grid with the middle two columns merged into a.mid-wideblock. 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 asbullets_content_h / row_h. When the figure stretches the row taller than the bullets need, the verdict surfacesmethod-text SPARSEand 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.half(landscape--layout half; portraitlayouts_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:
fullif AR ≥ 2.5 OR{column=full}, elsehalf. - Portrait:
fullif AR ≥ 1.2 OR{column=full}, elsehalf. (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-accentby 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 classmhueto<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(default42cqh). Raise/lower--method-fig-maxto 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).
.msubsis 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-3to.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
packpre-check; measure every.msubwithslack(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.
solid— the classic look: solid-filled accent titlebar (accent bg, white text), sectionh2= colored accent text with a thin underline. Sections are quiet warm cards with a top accent stripe.framed— the editorial look: outlined rounded titlebar frame (white bg, accent border + accent title), sectionh2= solid accent-filled banner edge-to-edge; white section cards with a neutral thin frame.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.left-bar— thick accent rail down the card's left edge; plain accent heading.elevated— floating white card with a soft deep shadow, rounded corners.neo-brutal— hard black border + offset accent drop-shadow; uppercase headings.tag— heading rendered as an inline accent pill.underline— bold accent rule hugging the heading.tinted— faint accent-tinted card background.double-rule— centered heading between two thin accent lines.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):
v1— venue (left) · title (center) · institution logos (right). Symmetric 3-zone band.v2— mirror of v1: institution logos (left) · title (center) · venue (right).v3— title centered full-width with a single equal-height logo strip below (conference mark + institutions).v4— title (left) · venue + institution logos stacked (right). A common GT layout.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 standalonescan-to-readsection (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:
- Compose the template (disk-to-disk, zero output tokens — do NOT
Writeit):# 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.htmlcompose_poster.pyresolves the STRUCTURAL hooks ({{STYLE_CSS}},{{HEADER}}, and landscape-only{{SCAN_SECTION}}) AND the COLOR axis (rewrites the:rootaccent vars to the resolved theme); every CONTENT{{...}}placeholder survives for the next step.--scan: pass the GROUP keyworddualonly whenmake_qr.pyemitted TWO QR slots (two genuinely distinct URLs survived de-duplication), elsesingle— 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 randomor an explicit variant name also work. QR-count guard (belt-and-suspenders): if you pass a single-QR context (single, or an explicithero/contact/banner) butmetadata.jsonactually carries two QR files on disk,compose_poster.pyauto-upgrades to thedualgroup so the second (project/code) QR is never silently dropped — still passdualexplicitly when a code QR resolved.--theme: defaultrandompicks one of 8 academic themes DETERMINISTICALLY from the output-path hash (reproducible spread across a wave) — do NOT hand-edit:rootcolors; override a specific one with--theme <name>orPOSTER_THEME.--header: defaultrandom(landscape v1-v5 / portrait pv1-pv5).--math: the math-typesetting engine, defaultkatex(thinner glyphs, posterskill-like) — the ONE place to switch isMATH_ENGINE_DEFAULTincompose_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). - Substitute placeholders with the
Edittool, one{{...}}token (or one section block) at a time — eachEditemits only the small placeholder plus your paper-specific content, never the surrounding template. Never reconstruct andWritethe 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}}. SetVENUE_LOGOtoassets/logos/_venue.pngonly 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--scanaxis (Step 3): pass--scan dualwhen 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 frommetadata.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 — withv5, ALWAYS fill{{HDR_QR_PAPER}}/{{HDR_QR_CODE}}and leave the section{{QR_*}}empty, so the QR shows once (in the v5 header) and thescan-to-readsection 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/*.pngis absent — set a path only if the file exists.
- Headers
- Logo autotrim:
fetch_logos.py/fetch_conf_logo.pynow 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-soft9 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-statfor sections dominated by one number,vs-comparefor Theirs/Ours sections,numbered-stepsfor pipelines,chipsfor 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)