microsoft/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 the live DOM via headless chromium, extracts BLOCK-level text containers (p/h1-h6/li/td) as TextBoxes with inline <strong>/<em> as mixed-style Runs, <img> as Picture with object-fit:contain respected (plus white-tile decoration under transparent-PNG logos), CSS ::before/::after generated content (e.g. 'So what →' callouts) as inherited-style runs, SVG via cairosvg rasterization, decorative <div>/<section> with bg/border/gradient/box-shadow as Rectangle/RoundedRect with matching fill (solid + linear-gradient + outer shadow). All CSS colors (including color-mix/oklab/color()) normalized via canvas. CSS hyphens:auto becomes OOXML soft hyphens via pyphen. CSS line-height absolute Pt for paragraph spacing. Native OOXML bullets with hanging indent. Optional Claude-vision fidelity auditor runs by default (toggle with `--no-vision-audit`); diffs HTML truth vs PPT render → structured 12-category issue report. Supports both direct API (`ANTHROPIC_AUTH_TOKEN`) and base-URL proxy (`ANTHROPIC_BASE_URL`) auth. Targets ~95% visual fidelity with web fonts installed. TRIGGER when user asks: 'HTML to PPT', 'poster to PowerPoint', 'editable PPT from HTML', 'pptx from html', '1:1 PPT clone', or wants an HTML render shipped as an editable .pptx for a non-developer collaborator.
npx skills add https://github.com/microsoft/ResearchStudio --skill html2pptx
Convert a rendered HTML page → native PowerPoint .pptx with editable shapes (not a PNG-in-slide).
paper2poster/demo/presentation/posters.pptx.pip install python-pptx playwright pdf2image lxml Pillow pyphen cairosvg
playwright install chromium # ~110MB headless browser
# Linux:
sudo apt install libreoffice-impress
# Mac:
brew install --cask libreoffice
> 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).
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.html → my_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:
@page { size: ... }, caps to 55" if poster > 56" (PPT limit) with proportional scaleposter.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.font_resolver.py)<name>_dom.json)<name>_html_print.png (print viewport, matches PPT canvas) and <name>_html_browser.png (1920×1080 browser viewport, fullPage)<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.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 intent | Flag to pass | Applies 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-audit | auto_correct_loop |
| "Vision Opus but fix Sonnet" (rare) | --vision-model claude-opus-4-8 --fix-model claude-sonnet-4-6 | auto_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.
Two paths, either works:
| Mode | Env vars | Use case |
|---|---|---|
| Direct API | ANTHROPIC_AUTH_TOKEN (or ANTHROPIC_API_KEY) | Personal key, direct to api.anthropic.com |
| Base-URL proxy | ANTHROPIC_AUTH_TOKEN + ANTHROPIC_BASE_URL | Corporate 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).
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.
auto_fix_loop.py) — fully agenticFor 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:
scripts/ under output/<ts>/_skill_run_copy/)alignment_off)claude -p subagent, sandboxed to the copy (--add-dir <copy>, only Read/Edit/Grep/Glob tools — no Bash, no git, can't escape)GOTCHAS.md + the copy's html_to_pptx.py, makes a minimal diffOtherwise → git reset --hard in the copy → try next issue
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 PPToutput/<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 trajectoryIf 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.
The skill resolves fonts in this priority chain:
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.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.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.# 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.
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 | Status |
|---|---|
| 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/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.py — canonical 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.py — autonomous 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→ 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.
→ See GOTCHAS.md — every non-obvious failure mode + root cause + fix, written after hitting them in production.
Quick links:
line_spacing only works with correct fontsisInlineOnly tag-name based, not CSS-display based::marker is generated content — use OOXML native bullets<img> decoration tile must render under picture (white tile under transparent logos)word_wrap=False (ICLR/ICML badges)::before/::after invisible to DOM walkers — fetch via getComputedStyle(el, '::before')| Package | Purpose |
|---|---|
| playwright | chromium for DOM extract |
| python-pptx | OOXML construction API |
| lxml | direct OOXML when python-pptx lacks (gradients, shadows, bullets) |
| Pillow | image decoding + text measurement |
| pdf2image | PDF → PNG preview |
| pyphen | CSS hyphens:auto soft-hyphen insertion |
| cairosvg | rasterize 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:
| | |
|---|---|
| libreoffice | PPTX → PDF via soffice |
| poppler-utils | pdf2image's binary backend (pdftoppm) |
ppt/fonts/*.fntdata + embeddedFontLst). Eliminates the Mac/Win "missing fonts" issue at the cost of ~3 MB per pptx.Take microsoft/html2pptx from the repository into ~/.claude/skills for personal
use, or into .claude/skills inside a project.
The agent identifies a skill by the name field in its header. Two skills with the
same name cannot sit side by side — one of them will be ignored.
The instructions reference pip, brew.
Without those the skill loads but fails at the first command.