html2pptx

Convert a rendered HTML page (especially A0 conference posters) into a native PowerPoint .pptx with editable text + native shapes — NOT a PNG-in-slide. Walks…

npx skills add https://github.com/microsoft/researchstudio --skill html2pptx

html2pptx

Convert a rendered HTML page → native PowerPoint .pptx with editable shapes (not a PNG-in-slide).

When to use

  • "Convert this HTML poster to PPT."
  • "I need an editable PowerPoint version of my HTML page."
  • "1:1 PPT clone of poster.html — fonts/colors/layout."
  • "Hand off the poster to a non-developer in PPT."

When NOT to use

  • Need a flat PNG slide → use paper2poster/demo/presentation/posters.pptx.
  • HTML uses heavy JS animations / video → those don't translate to PPT shapes.
  • Math-heavy KaTeX content → math gets rasterized fallback (loses equation editability).

Setup (one-time)

1. Python deps

pip install python-pptx playwright pdf2image lxml Pillow pyphen cairosvg
playwright install chromium     # ~110MB headless browser

2. soffice (for verification)

# Linux:
sudo apt install libreoffice-impress
# Mac:
brew install --cask libreoffice

3. Web fonts — CRITICAL for fidelity (Read GOTCHAS.md)

Default = Arial → you can skip this whole section. Since the paper2poster Arial default, posters render in Arial (or another PPT-safe family: Calibri, Aptos, Cambria, Times New Roman, Verdana, Georgia, Trebuchet MS). Those are pre-installed on every Mac + Windows PowerPoint, so you do not need the font installs below and the .pptx is portable without embedding. The Inter / Source Serif 4 / JetBrains Mono setup below applies only when the poster was rendered with the optional Inter webfont override.

Without Inter / Source Serif 4 / JetBrains Mono installed, soffice falls back to DejaVu Sans (Linux) or Helvetica (Mac) — character widths change, text wraps differently, all positions cascade-shift.

Linux:

# Download static-weight TTFs (variable fonts register under wrong family name)
mkdir -p /tmp/fonts-dl && cd /tmp/fonts-dl
curl -sL "https://github.com/rsms/inter/releases/download/v4.0/Inter-4.0.zip" -o Inter.zip
curl -sL "https://github.com/adobe-fonts/source-serif/releases/download/4.005R/source-serif-4.005_Desktop.zip" -o SourceSerif4.zip
curl -sL "https://github.com/JetBrains/JetBrainsMono/releases/download/v2.304/JetBrainsMono-2.304.zip" -o JetBrainsMono.zip
unzip -qo Inter.zip -d Inter/
unzip -qo SourceSerif4.zip -d SourceSerif4/
unzip -qo JetBrainsMono.zip -d JetBrainsMono/

mkdir -p ~/.local/share/fonts
cp Inter/extras/ttf/Inter-{Regular,Medium,SemiBold,Bold,Italic,SemiBoldItalic,BoldItalic}.ttf ~/.local/share/fonts/
cp SourceSerif4/source-serif-4.005_Desktop/TTF/SourceSerif4-{Regular,Semibold,Bold,It,SemiboldIt,BoldIt}.ttf ~/.local/share/fonts/
cp JetBrainsMono/fonts/ttf/JetBrainsMono-{Regular,Medium,Bold,Italic,BoldItalic}.ttf ~/.local/share/fonts/
fc-cache -f ~/.local/share/fonts/

# Verify: fc-match MUST return the font itself, not a fallback
fc-match "Inter" "Source Serif 4" "JetBrains Mono"

Mac: Same downloads, but copy to ~/Library/Fonts/ and fully quit + reopen PowerPoint (Cmd+Q) to pick up new fonts.

For a poster using the default Arial (or any PPT-safe family), the viewer needs nothing — those fonts ship with PowerPoint, so the pptx is portable as-is. Only for the optional Inter override must the viewer either have Inter installed OR the pptx embed it via the opt-in scripts/font_embedder.py step (embeds the 4 Inter weights into the pptx; ~3 MB).

Quick start — one command

cd skills/paper2poster/html2pptx
python -m scripts.auto_correct_loop --html /path/to/poster.html

Outputs default to the input HTML's parent directory (sibling-co-located convention, 2026-06-12 spec) — e.g. my_paper/poster.htmlmy_paper/poster.pptx. Successive runs in the same dir overwrite the previous .pptx. Override with --outdir /custom/path/.

Bundle contract (paper2 pipeline).* When invoked as the poster pipeline's final handoff, the caller passes --outdir <bundle>/assets/_pptx_build/ so every html2pptx artifact (DOM json, sanity PNGs, the soffice render) stays under assets/, then promotes the deck to the bundle root — cp <bundle>/assets/_pptx_build/poster.pptx <bundle>/poster.pptx — because poster.pptx is a top-level deliverable (the only html2pptx output that leaves assets/). The input poster.html references its figures / logos / fonts with root-relative assets/… src paths; the converter loads the HTML via file://, so those resolve from the HTML's own on-disk location automatically — no path handling is needed in the converter.

This is the canonical entry point. It:

  1. Auto-detects canvas from CSS @page { size: ... }, caps to 55" if poster > 56" (PPT limit) with proportional scale
  2. Auto-detects sibling poster.pdf (if present): runs pdffonts on it, builds CSS-name → PDF-font alias map. Writes a fontconfig <match> runtime override so the browser uses the SAME fonts as the reference PDF — wraps match 1:1. Cleaned up via atexit.
  3. Auto-downloads any other HTML-referenced fonts from Google Fonts (font_resolver.py)
  4. Extracts DOM (one playwright run, cached as <name>_dom.json)
  5. Builds pptx → renders via soffice → PNG
  6. Saves comparison PNGs: <name>_html_print.png (print viewport, matches PPT canvas) and <name>_html_browser.png (1920×1080 browser viewport, fullPage)
  7. Vision-audit (ON by default): calls Claude vision to diff <name>_html_print.png vs <name>.png, writes <name>_audit.json with structured fidelity issues (severity, category, block_idx, where, description). When a prior audit JSON exists, prints a delta (e.g. +/- 3 issues vs previous). Default model: Opus 4.8 (~$0.10/poster; override with --vision-model claude-sonnet-4-6 for ~$0.02/poster if budget matters more than catch-rate); ~70s extra runtime.

Model selection (for orchestrators invoking this skill)

The skill exposes two independent model knobs. When a user says "use Opus" / "use Sonnet" / "use the cheaper one" / etc., the orchestrating Claude must translate that to the right flag — the skill scripts have no natural-language parsing.

User intentFlag to passApplies to
"Use Opus" / "highest quality" / no model specified (default)(omit flag — default is Opus 4.8)both
"Use Sonnet" / "cheaper"--vision-model claude-sonnet-4-6 (and --fix-model claude-sonnet-4-6 if running auto_fix_loop)both
"Don't audit" / "skip vision" / batch automation--no-vision-auditauto_correct_loop
"Vision Opus but fix Sonnet" (rare)--vision-model claude-opus-4-8 --fix-model claude-sonnet-4-6auto_fix_loop only

Two separate knobs, two separate scripts:

  • scripts/auto_correct_loop.py (build + audit) → --vision-model <MODEL>
  • scripts/auto_fix_loop.py (build + audit + autonomous code-fix subagent) → --vision-model <MODEL> AND --fix-model <MODEL> (both default Opus 4.8; the --fix-model is explicitly passed to claude -p --model so the subagent doesn't silently inherit the orchestrator's CLI config)

Reproducibility: both scripts log the model choice at start. The vision-audit logs [vision-audit] calling Claude vision (claude-opus-4-8, auth=...); auto_fix_loop logs [fix-loop] models: vision-audit=..., fixer=.... If a user reports unexpected behavior, the model used is in the run log.

Vision-audit auth

Two paths, either works:

ModeEnv varsUse case
Direct APIANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY)Personal key, direct to api.anthropic.com
Base-URL proxyANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URLCorporate proxy / Vertex / Bedrock / company gateway. This is the setup on the local dev machine.

If neither auth is configured, audit silently skips with a one-line hint (so --no-vision-audit isn't required for offline/CI runs). Pass --no-vision-audit to explicitly disable for batch automation (100 posters = ~$2 + ~2hr extra).

3-round revision workflow (dev cycle)

Vision audit replaces the old PIL-based L2 loop with a much more useful signal: structural fidelity diff, not cosmetic font shrinking. Each rerun re-runs the audit and shows +/- N issues vs previous — the dev iteration loop is:

# round 1: baseline (defaults to ./output/<timestamp>/)
python -m scripts.auto_correct_loop --html poster.html
# → look at output/<ts>/poster_audit.json, identify top systemic bug

# round 2: after fixing the bug in html_to_pptx.py
python -m scripts.auto_correct_loop --html poster.html --outdir output/<ts>/
# → output shows e.g. `[vision-audit] 2 issues (-9 vs previous)`

# round 3: fix next bug, target near-zero high-severity
python -m scripts.auto_correct_loop --html poster.html --outdir output/<ts>/

Empirically: 2-3 rounds clears most issues on a new HTML template. Each round costs ~1 minute + ~$0.02.

Cross-poster aggregation: python -m scripts.vision_aggregate <root_dir> walks every <poster>/poster_audit.json and prints category frequencies + per-poster issue counts → reveals SYSTEMIC bugs (same category appearing across N posters) worth fixing in code vs one-off issues.

Autonomous fix loop (auto_fix_loop.py) — fully agentic

For per-poster fidelity fixes you don't want polluting the shipped skill, run:

python -m scripts.auto_fix_loop --html /path/poster.html --max-rounds 3

Each round:

  1. Builds + audits (via an ISOLATED COPY of scripts/ under output/<ts>/_skill_run_copy/)
  2. Picks top actionable issue from the audit (category whitelist filters out vision-prone false positives like alignment_off)
  3. Spawns claude -p subagent, sandboxed to the copy (--add-dir <copy>, only Read/Edit/Grep/Glob tools — no Bash, no git, can't escape)
  4. Subagent reads GOTCHAS.md + the copy's html_to_pptx.py, makes a minimal diff
  5. Rebuild + re-audit
  6. If issue count dropped → keep change in the copy's internal git Otherwise → git reset --hard in the copy → try next issue
  7. Loop terminates: convergence (no actionable issues) OR max-rounds OR consecutive-failures cap

Per-run isolation is the critical design point: your shipped skills/paper2poster/html2pptx/ is NEVER touched. Each run produces:

  • output/<ts>/<name>.pptx — final patched PPT
  • output/<ts>/<name>_audit.json — final audit (target: 0 issues)
  • output/<ts>/<name>_run_patches.diff — cumulative subagent diff vs baseline (review me!)
  • output/<ts>/_skill_run_copy/ — patched skill copy (rerun-able on the same HTML)
  • output/<ts>/<name>_fix_loop_summary.json — per-round trajectory

If reviewing the .diff shows a fix is genuinely general (not poster-specific), cherry-pick it into the shipped skill via a normal PR. Otherwise the patch stays scoped to that one output dir — other posters / other people's installs unaffected.

Cost: ~$1–3 per converged run with default models (Opus 4.8 for both vision-audit and fix subagent). Override either via --vision-model / --fix-model (e.g. claude-sonnet-4-6) to cut cost by ~5× at the price of lower catch-rate / weaker root-causing. Both model choices are logged at start so the run is fully reproducible.

Auth: same as --vision-audit (ANTHROPIC_AUTH_TOKEN direct or with ANTHROPIC_BASE_URL for corporate proxy).

Removed 2026-06-05: the previous "L2 closed-loop" font-shrinking rounds — PIL-predicted overflow was decoupled from soffice's actual render (audit showed 17→17→17→17 overflows across 4 rounds on paper_4_d), and the shrinker introduced occasional false-positive shrinks on healthy body paragraphs. Replaced with --vision-audit (default-on) which catches REAL fidelity issues (missing logo, dropped span, wrong wrap) — issues PIL could never see. Wrap fidelity itself is now solved at DOM-extract time (browser extractWrapLines with CSS line-height threshold), not after- the-fact. The --rounds / --shrink-step flags still parse (silently no-op) so existing call sites don't break.

Output convention: outputs land in dirname(input_html)/ by default (sibling of the source HTML); pass --outdir <path> to override.

Default slide 47×33.1" (A0 landscape). Override via --width-inch / --height-inch.

Font workflow

The skill resolves fonts in this priority chain:

  1. Sibling poster.pdf exists (or --reference-pdf flag): the skill extracts the PDF's actual fonts via pdffonts, builds an alias map (e.g. CSS Inter → PDF Arimo), installs the PDF fonts locally, and writes a fontconfig <match> runtime override forcing the browser to render with the PDF fonts (not the CSS-named ones). PPT XML font names = PDF font names. Best fidelity when matching a coworker's existing PDF rendering whose machine fell back to different fonts than the CSS designer intent.

  2. No sibling PDF: the skill walks all font-family declarations in the HTML and auto-downloads from Google Fonts any that aren't installed (font_resolver.py). Designer-intent fonts (Inter, Source Serif 4, JetBrains Mono, Roboto, Playfair Display, etc.) all available on Google Fonts get installed automatically.

  3. CSS / Mac / Win system fonts without a Google Fonts equivalent (Helvetica Neue, SF Pro, Menlo, etc.) are mapped via FONT_ALIASES in html_to_pptx.py to installed equivalents (Inter, JetBrains Mono, Source Serif 4) for both browser-side fontconfig alias and PPT-side run.font.name.

Verifying output

# Open the last round's PNG; compare with the HTML render PNG:
ls /tmp/uframe_round_*.png
# soffice → PDF → PNG at 96 DPI to match playwright (DPI mismatch = false font-size-look-smaller bug)

Critical: dpi=96 everywhere. Mismatched DPI in comparison PNGs creates visual size discrepancy that looks like a font-size bug but isn't.

soffice render ≠ PowerPoint render. Always sanity-check the final .pptx in actual PowerPoint (Mac/Win). soffice often diverges on shadow blur, font-shaper kerning, and gradient stops.

Architecture

HTML
  ↓ [playwright chromium] render + DOM walk
  ↓ getComputedStyle on every visible element
  ↓ canvas.fillStyle = anyColor → normalized rgba()
DOM extract (cached as <name>_dom.json)
  ├─ elements   → boxes (bg/border/gradient/shadow) + images (with object-fit + decoration tile)
  └─ text_blocks → block-level (p/li/h1-h6/figcaption/td/th) with inline runs
                   (incl. ::before/::after pseudo-element content as inherited-style runs)
  ↓ [python-pptx + lxml] generate native shapes (Z-order by DOM depth)
PPTX
  - Picture (object-fit:contain math → centered, letterboxed inside content area = bbox - padding)
    + optional decoration tile UNDER picture (CSS background-color/border-radius/box-shadow)
    + SVG → PNG via cairosvg before embed
  - Rectangle / RoundedRect (CSS border-radius → exact roundRect adj)
    + solid fill / linear gradient (gradFill XML)
    + border (line.color/width)
    + box-shadow (outerShdw effect)
  - TextBox per block element
    + multiple inline Runs (one per <strong>/<em>/text node/pseudo-element)
    + CSS padding → tf.margin_*
    + CSS line-height → absolute Pt line_spacing
    + CSS text-transform applied per-run (so ::before uppercase doesn't inherit paragraph 'none')
    + CSS letter-spacing → OOXML rPr spc
    + CSS hyphens:auto → pyphen-inserted U+00AD soft hyphens
    + <li> → native OOXML <a:buChar> with hanging indent + section color marker
    + overflow:hidden + line-clamp → PIL-measured truncation + "…"
    + single-word short text (≤6 chars, no spaces) → word_wrap=False (honors browser-proven fit)
PPTX (final)
  ↓ [optional: --vision-audit] Claude vision diff (html_print.png vs ppt.png)
  ↓ structured fidelity report (<name>_audit.json): 12 categories × 3 severities
  ↓ delta vs previous audit if one exists → dev-cycle smoke test

Capability table

CapabilityStatus
Position / sizing pixel-perfect
Computed colors (oklab / color-mix / color() / hsl / named)✓ via canvas
Alpha compositing (rgba with α<1 → composited over white)
Inline <strong>/<em> as Runs in same paragraph
Section colored banner (h3 bg + text + numbered marker)
Linear gradients✓ (gradFill)
Box-shadow✓ (outerShdw)
Border-radius (exact CSS px → roundRect adj)
Per-side borders (border-top/right/bottom/left, asymmetric)✓ thin Rect per present side
Inline text background → run highlight (Text Highlight Color)✓ schema-compliant rPr order
Superscript / Subscript (<sup>/<sub> or CSS vertical-align)✓ baseline offset + readability floor
Image object-fit:contain (letterboxed inside bbox)
CSS padding → textbox margins
CSS line-height absolute (eliminates trailing whitespace)(leveraged when needed)
CSS hyphens:auto (libhyphen → pyphen soft hyphens)
Native OOXML bullets with hanging indent + section color
overflow:hidden + line-clamp truncation
Auto-detect canvas size from @page { size: ... }
Cap slide to PPT 56" with proportional scale
Auto-download fonts from Google Fonts✓ (via font_resolver)
Auto-detect sibling poster.pdf → extract its fonts, alias-map✓ when present
CSS ::before/::after generated content as inherited-style runs✓ (catches "So what →" callouts, content-arrow icons)
Per-run text-transform override (pseudo-content uppercase != paragraph 'none')
<img> decoration tile (white rounded-rect under transparent-PNG logos)✓ (CSS background-color + border-radius + padding)
SVG embed (rasterized via cairosvg → PNG)
Single-word short text → word_wrap=False (badges like ICLR/ICML)✓ (≤6 chars, no spaces)
Vision-audit fidelity report (Claude vision diff, 12 categories)ON by default (~$0.02/poster; silently skipped when no auth)
Autonomous code-fixer loop (claude-p subagent in isolated skill copy)✓ via scripts/auto_fix_loop.py (~$0.50-1.50/run; shipped skill never modified)
Cross-poster systemic-bug aggregation✓ via scripts/vision_aggregate.py
Closed-loop font shrinking for overflow (L2)✗ removed 2026-06-05 (PIL-prediction-vs-soffice decoupling made it a no-op + false-positive risk; replaced by --vision-audit)
Match-wrap (force PPT wrap at browser positions)✗ default off; opt-in via flag
Font embedding in pptx (cross-machine portability)✗ (Phase 3 TBD)
KaTeX math equations✗ (Phase 4 PNG fallback)
CSS transforms / filters✗ (Phase 4 PNG fallback)

Scripts

  • scripts/html_to_pptx.py — DOM extract + pptx build (accepts --corrections legacy, --match-wrap to force browser wrap positions). Supports ::before/::after pseudo-element content extraction (CSS callouts like "So what →"), per-run text-transform, <img> decoration tiles (white rounded rect under transparent-PNG logos), and SVG rasterization via cairosvg.
  • scripts/auto_correct_loop.pycanonical entry: single-shot DOM-extract → build → render → compare-PNG generator. Handles sibling-PDF font alias + Google Fonts auto-download. Default outdir = dirname(input_html)/ (sibling-co-located). With --vision-audit (default on), runs vision diff after render.
  • scripts/auto_fix_loop.pyautonomous fix loop: copies skill scripts to an isolated output/<ts>/_skill_run_copy/, spawns claude -p per round to fix the top audit issue in the COPY (sandboxed via --add-dir), rebuilds, re-audits, keeps or rolls back via the copy's internal git. Shipped skill stays untouched; per-run diff exported for human cherry-pick.
  • scripts/vision_audit.py — Claude vision fidelity auditor. Takes (html_truth.png, ppt.png, dom.json) → structured issue list classified into 12 categories (missing_element, wrap_mismatch, text_clipped, color_drift, ...) with severity and block_idx. Replaces the abandoned PIL-based L2 loop.
  • scripts/vision_aggregate.py — cross-poster category frequency rollup. Walks <root>/*/poster_audit.json, prints which bugs recur and how often — surfaces SYSTEMIC issues vs one-off.
  • scripts/font_resolver.py — Google Fonts auto-download + sibling-PDF font detection + fontconfig runtime alias

Why this beats pixel-based PDF→PPTX converters

→ See difference.md — concrete advantages over iLovePDF / Smallpdf-style raster converters: inline text highlight as run property (not floating shape), native list bullets (not orphan glyphs), bold preserves family (not lookalike substitute), plus other native-vs-pixel semantic preservation.

Gotchas

→ See GOTCHAS.md — every non-obvious failure mode + root cause + fix, written after hitting them in production.

Quick links:

  • G1: Font name vs family (Inter Variable ≠ Inter)
  • G2: soffice ≠ PowerPoint (always sanity-check in PowerPoint)
  • G3: DPI consistency for PNG comparison
  • G4: line_spacing only works with correct fonts
  • G5: CSS color-mix returns oklab — normalize via canvas
  • G6: isInlineOnly tag-name based, not CSS-display based
  • G7: h3 numbered markers (skip decoration, recolor text)
  • G8: object-fit:contain — manual letterbox math
  • G9: hyphens:auto via pyphen + U+00AD
  • G10: ::marker is generated content — use OOXML native bullets
  • G11: Mac PowerPoint needs fonts installed too
  • G12: Variable fonts confuse fontconfig — use static weights
  • G15: <img> decoration tile must render under picture (white tile under transparent logos)
  • G16: Single-word short text → set word_wrap=False (ICLR/ICML badges)
  • G17: ::before/::after invisible to DOM walkers — fetch via getComputedStyle(el, '::before')
  • G18: Vision audit false-positive: LEFT-aligned in wide bbox looks centered

Dependencies

PackagePurpose
playwrightchromium for DOM extract
python-pptxOOXML construction API
lxmldirect OOXML when python-pptx lacks (gradients, shadows, bullets)
Pillowimage decoding + text measurement
pdf2imagePDF → PNG preview
pyphenCSS hyphens:auto soft-hyphen insertion
cairosvgrasterize SVG <img> to PNG before embed (PIL can't decode SVG)

Optional (for --vision-audit): | ANTHROPIC_AUTH_TOKEN | Claude API auth for vision diff |

For verification:

libreofficePPTX → PDF via soffice
poppler-utilspdf2image's binary backend (pdftoppm)

Roadmap

  • Phase 3 (TBD): web-font embedding in pptx (ppt/fonts/*.fntdata + embeddedFontLst). Eliminates the Mac/Win "missing fonts" issue at the cost of ~3 MB per pptx.
  • Phase 4 (TBD): PNG fallback for unsupported subtrees (KaTeX math, complex SVG, CSS transforms).