| name | web-to-pdf |
|---|---|
| description | Use when the user wants to scrape a website, tutorial, or documentation and convert it into a clean, bookmarked A4 PDF. Triggers on requests like "save this site as PDF", "download tutorial as PDF", "convert web pages to PDF", "export docs to PDF". Handles multi-page tutorials with navigation sidebar, SVG diagrams with CJK fonts, ad removal, and bookmark generation. |
Convert multi-page web tutorials/docs into a single, clean, bookmarked A4 PDF.
Scrapes all pages from a web tutorial site, strips ads/navigation, fixes SVG font issues, combines into one styled HTML, then renders to a bookmarked A4 PDF using headless Chromium.
- User provides a URL and wants it saved as PDF
- Multi-page tutorial/documentation sites (e.g., runoob.com, w3schools, MDN)
- User wants ads removed, only article content kept
- User needs bookmarks/outline in the PDF
- SVG diagrams contain CJK text that may lose fonts in conversion
1. Discover pages → Fetch index page, extract sidebar/nav links
2. Fetch & clean → For each page: download HTML, extract article, remove ads
3. Fix SVGs → Download SVGs, replace fonts, inline into HTML
4. Combine HTML → Cover + TOC + all chapters, A4 CSS, page breaks
5. Render PDF → Playwright (headless Chromium) → page.pdf()
6. Add bookmarks → Calculate chapter positions → pypdf adds outline
Use the bundled web_to_pdf.py script in this skill directory.
requests beautifulsoup4 playwright pypdf
System requirement: brew install pango (for fallback; Playwright/Chromium is primary)
Chromium install: playwright install chromium
# The script is at:
~/.claude/skills/web-to-pdf/web_to_pdf.py
# Basic usage:
python3 web_to_pdf.py <start_url> [--output path.pdf]
# Example:
python3 web_to_pdf.py https://www.runoob.com/claude-code/claude-code-tutorial.html \
--output ~/Claude_Code_Tutorial.pdf| Decision | Choice | Why |
|---|---|---|
| PDF renderer | Playwright (Chromium) | Only engine that renders SVG foreignObject + CJK correctly |
| NOT WeasyPrint | WeasyPrint loses SVG foreignObject text | Mermaid diagrams use foreignObject for labels |
| SVG handling | Download → replace fonts → inline | <img src=".svg"> can't access system fonts; inlining fixes this |
| CJK font fallback | PingFang SC, Heiti SC, Hiragino Sans GB | macOS built-in; covers zh-CN/zh-TW |
| Bookmark generation | Playwright renders → pypdf adds outlines | Chromium PDF export has no bookmark API; post-process with pypdf |
| Page estimation | element.top / (totalHeight / totalPages) |
Proportional mapping; accurate within 1 page |
- Find main content:
#content>.article-body>article> div with "content"/"article" class - Remove by selector:
script, iframe, .adsbygoogle, #sidebar, footer, nav, .share-bar, #comments - Remove by pattern:
class/id matching ad[s_-], sponsor, promo, google - Remove hidden:
style="display:none" - Remove HTML comments
SVGs from sites like runoob.com use Mermaid.js which specifies:
font-family: "Comic Neue", "Comic Sans MS", "Noto Sans SC", cursive, sans-serif;These fonts are usually missing on developer machines. Fix:
svg_text = re.sub(
r'"Comic Neue".*?sans-serif',
'"PingFang SC", "Heiti SC", "Hiragino Sans GB", sans-serif',
svg_text
)Then replace <img src="x.svg"> with inline <svg>...</svg>.
Chromium's page.pdf() doesn't support outlines. Workaround:
- Before PDF generation, run JS to get each chapter heading's Y offset
- After PDF generation, calculate:
page_number = int(y_offset / (total_height / total_pages)) - Use
pypdf.PdfWriter.add_outline_item(title, page_number)to inject bookmarks
| Mistake | Fix |
|---|---|
| Using WeasyPrint for SVG-heavy pages | Switch to Playwright; WeasyPrint can't render foreignObject |
| Not waiting for images to load | Add wait_until="networkidle" + extra timeout |
| SVG text disappearing in PDF | Inline SVGs and replace font-family with system fonts |
| Bookmarks all pointing to page 1 | Don't trust Chromium named destinations; use proportional calculation |
| Rate limiting / 403 errors | Add User-Agent header, 0.5s delay between requests |
| Encoding issues | Force resp.encoding = 'utf-8' before parsing |
The script's content extraction is generic but may need tuning:
- Different content selectors: Modify
CONTENT_SELECTORSlist in the script - Different nav structure: Override
discover_pages()for sites without sidebar nav - Different ad patterns: Extend
AD_SELECTORSlist - Non-CJK sites: Font replacement can be skipped (set
--no-font-fix)