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:
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 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 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
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.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
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:
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:
- 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 ~/.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:
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-nullselections.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.55and 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 carryingmethod,result, orablation >= 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
resultorqualitative >= 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:
- Read the figure with the Read tool.
- Decide a tight pixel bbox
(X0, Y0, X1, Y1). - Apply:
This writes a one-timepython ~/.claude/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
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; portraitassets/layouts_portrait/{full,half}.html. - Visual style:
assets/styles/*.css; Landscape enables all 11 treatments, while Portrait excludesunderlineanddouble-ruleand enables the remaining 9. - Titlebar: landscape
assets/headers/{v1,v2,v3,v4,v5,v6,v7}.html; portraitassets/headers_portrait/{pv1,pv2,pv3,pv4,pv5,pv6,pv7}.html. The*6/*7variants 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:
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. Thehalflayout uses a 2-column body; thefulllayout uses four stacked content bands. 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 four stacked content bands: Problem | Motivation; the full-width.method-hero; Key Results | Ablation | Headline Numbers at1.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 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:
- Subsection headings in both
methoddrivenandmethoddriven4use 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 preservingdata-section="method-1"…"method-N"for measurement and Reel interactions. - Colour =
multi-accentby 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 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: --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.
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 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:
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 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.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. Portrait ignores this axis because it has no standalone scan section, while itspv1–pv5header still composes normally. 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 9 academic themes DETERMINISTICALLY from the seed — 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).Deterministic selection and 30+ paper batches. For an ordinary run, the random axes use
--seedwhen supplied, thenPOSTER_SEED, otherwise the resolved absolute--outpath. 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-indexvalues 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 randomonly when layout itself should join the balanced sampling: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.htmlEvery 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-outwrites the same auditable selection to a separate JSON file for gallery and sampling tests.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-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)