diff --git a/skills/html-to-pdf-hires.zip b/skills/html-to-pdf-hires.zip new file mode 100644 index 00000000..a26403f8 Binary files /dev/null and b/skills/html-to-pdf-hires.zip differ diff --git a/skills/html-to-pdf-hires/LICENSE b/skills/html-to-pdf-hires/LICENSE new file mode 100644 index 00000000..0e4bfac4 --- /dev/null +++ b/skills/html-to-pdf-hires/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Primarch Systems Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/html-to-pdf-hires/README.md b/skills/html-to-pdf-hires/README.md new file mode 100644 index 00000000..31193842 --- /dev/null +++ b/skills/html-to-pdf-hires/README.md @@ -0,0 +1,95 @@ +# html-to-pdf-hires + +A [Claude Code](https://docs.claude.com/en/docs/claude-code) skill that converts HTML files or URLs into pixel-perfect, high-DPI rasterized PDFs. + +Each `.page` element is screenshotted at 3x device-pixel-ratio and embedded full-bleed into a fresh A4 PDF. Output is pixel-identical to the browser at every zoom level — no font-hinting surprises, no viewer-dependent blur. + +**Trade-off:** larger file size and non-selectable text compared to vector PDFs. Use this when crispness matters more than text selection (presentations, marketing decks, designed reports). + +## Install + +```bash +git clone https://github.com/MMTTGL/html-to-pdf-hires ~/.claude/skills/html-to-pdf-hires +cd ~/.claude/skills/html-to-pdf-hires +npm install +``` + +> **Windows users:** the path resolves to `C:\Users\\.claude\skills\html-to-pdf-hires`. Use Git Bash or PowerShell with the equivalent path. + +> **Behind a corporate proxy / locked-down network?** Puppeteer downloads its own Chromium (~170 MB). To skip and use a system Chrome instead: +> ```bash +> PUPPETEER_SKIP_DOWNLOAD=true npm install +> # then point at your Chrome via PUPPETEER_EXECUTABLE_PATH +> ``` + +## Use it from Claude Code + +Just ask Claude any of: + +- "convert this HTML to a hi-res PDF" +- "the PDF you made looks blurry, redo it crisper" +- "export this deck to PDF at 4x DPR" + +Claude will pick up the skill automatically based on the description. + +## Use it from the command line + +```bash +# Local file +node scripts/convert.js input.html output.pdf + +# URL (preferred when HTML loads web fonts) +node scripts/convert.js http://localhost:8765/doc.html output.pdf + +# Custom DPR +node scripts/convert.js input.html output.pdf --dpr=4 + +# Landscape +node scripts/convert.js input.html output.pdf --landscape + +# Custom selector (default ".page") +node scripts/convert.js input.html output.pdf --selector=".slide" + +# Letter format +node scripts/convert.js input.html output.pdf --format=Letter + +# Extra wait for fonts / JS +node scripts/convert.js input.html output.pdf --wait=4000 +``` + +### Options + +| Flag | Default | Description | +|---|---|---| +| `--dpr=` | `3` | Device pixel ratio for screenshot rasterization | +| `--format=` | `A4` | Page format: A4, A3, A5, Letter, Legal | +| `--landscape` | off | Landscape orientation | +| `--selector=` | `.page` | Element selector — one screenshot per match. Falls back to full-page screenshot if no matches | +| `--wait=` | `2500` | Extra wait after load for fonts/JS | + +## HTML structure + +For multi-page output, wrap each page in a div with the chosen selector and lock its exact physical size: + +```css +.page { + width: 210mm; + height: 297mm; + overflow: hidden; +} +``` + +Use `height` (not `min-height`) with `overflow: hidden`, otherwise content can overflow and the screenshot grows taller than A4 — leading to clipped or oddly-scaled pages. + +## How it works + +1. Launches Puppeteer headless with `--font-render-hinting=none --disable-font-subpixel-positioning --force-color-profile=srgb` +2. Sets viewport matching the format at `deviceScaleFactor=` +3. Loads the URL/file, waits for `load + networkidle0`, then `document.fonts.ready`, then `--wait` ms +4. Screenshots each `.page` element as PNG (or full-page fallback) +5. Builds a temporary HTML containing `@page { size: ; margin: 0 }` and one full-bleed `` per screenshot +6. Loads the composed HTML in a fresh headless instance and exports with `preferCSSPageSize: true, printBackground: true, scale: 1, margin: 0` + +## License + +MIT — see [LICENSE](./LICENSE). diff --git a/skills/html-to-pdf-hires/SKILL.md b/skills/html-to-pdf-hires/SKILL.md new file mode 100644 index 00000000..dada1a8b --- /dev/null +++ b/skills/html-to-pdf-hires/SKILL.md @@ -0,0 +1,97 @@ +--- +name: html-to-pdf-hires +description: Convert an HTML file or URL to a pixel-perfect, high-DPI rasterized PDF by screenshotting each page element at 3x device-pixel-ratio and embedding the PNGs full-bleed into an A4 PDF. Supports slide-style decks where only one element is visible at a time via an active class (use --activate-class=). Use this skill when the user asks to "turn HTML into PDF", "make a PDF from this HTML", "convert to PDF", "generate a crisp PDF", "export to PDF at high resolution", "convert presentation/deck to PDF", or complains that a previously generated vector PDF looks blurry / low-res / fuzzy in their PDF viewer. Output is pixel-identical to the browser at every zoom level — no font-hinting surprises, no viewer-dependent blur. Trade-off: larger file size and non-selectable text compared to vector PDFs. +license: MIT +metadata: + author: Primarch Systems Inc. + version: '1.0.0' +--- + +# HTML to PDF (Hi-Res Raster) + +Replicates the exact workflow that produces pixel-perfect PDFs: render in Chrome at 3x DPR, screenshot each `.page` element, embed the PNGs full-bleed into a fresh A4 PDF. + +## Setup (one-time) + +From the skill folder: + +```bash +npm install +``` + +## Usage + +Run `scripts/convert.js` from the skill folder: + +```bash +# Local file +node scripts/convert.js input.html output.pdf + +# URL (preferred when HTML loads web fonts — ensures proper loading) +node scripts/convert.js http://localhost:8765/doc.html output.pdf + +# Custom DPR (default 3) +node scripts/convert.js input.html output.pdf --dpr=4 + +# Landscape +node scripts/convert.js input.html output.pdf --landscape + +# Custom selector (default ".page") +node scripts/convert.js input.html output.pdf --selector=".slide" + +# Custom format +node scripts/convert.js input.html output.pdf --format=Letter + +# Extra wait for fonts / JS (default 2500ms) +node scripts/convert.js input.html output.pdf --wait=4000 + +# Slide deck where only one element is visible at a time (e.g. .slide.active) +node scripts/convert.js deck.html deck.pdf \ + --selector=.slide --activate-class=active --landscape +``` + +## Options + +| Flag | Default | Description | +|---|---|---| +| `--dpr=` | `3` | Device pixel ratio for screenshot rasterization | +| `--format=` | `A4` | Page format: A4, A3, A5, Letter, Legal | +| `--landscape` | off | Landscape orientation | +| `--selector=` | `.page` | Element selector — one screenshot per match. Falls back to full-page screenshot if no matches | +| `--wait=` | `2500` | Extra wait after load for fonts/JS | +| `--activate-class=` | none | Slide-deck mode. Before each screenshot, toggle this class on the matched element and remove it from the others. Uses viewport (not element) screenshots — correct for decks using `opacity` + `position:absolute` to stack slides | +| `--activate-delay=` | `400` | Delay between activating an element and taking its screenshot | + +## Required HTML structure for multi-page output + +Each page of the PDF corresponds to one matching element. For best results, wrap each page in a div with the selector class and lock its exact physical size: + +```css +.page { + width: 210mm; + height: 297mm; + overflow: hidden; +} +``` + +If `.page` uses `min-height` instead of `height`, content may overflow and the screenshot grows taller than A4 — resulting in clipped or oddly-scaled pages. Always use `height` with `overflow: hidden`. + +## Verify after every run + +Read the generated PDF and check: +- Correct page count (one per `.page` element) +- No clipped content at page edges +- Gradients, logos, and text all appear crisp + +If a page looks clipped or stretched, the source `.page` element's physical dimensions don't match the chosen format — fix the HTML CSS, don't apply scale in the script. + +## How it works + +1. Launch Puppeteer headless with `--font-render-hinting=none --disable-font-subpixel-positioning --force-color-profile=srgb` +2. Set viewport matching the format at `deviceScaleFactor=` +3. Load the URL/file, wait for `load + networkidle0`, then `document.fonts.ready`, then `--wait` ms +4. `$$(selector)` → screenshot each matched element as PNG (default) + - **With `--activate-class=`**: disable transitions, cycle the class across matches (`classList.toggle(name, j === idx)` for each), then screenshot the viewport after `--activate-delay` ms + - **No matches**: fall back to full-page screenshot +5. Close browser, build a temporary HTML containing `@page { size: ; margin: 0 }` and one full-bleed `` per screenshot +6. Launch a second headless instance, load the composed HTML, and `page.pdf()` with `preferCSSPageSize: true, printBackground: true, scale: 1, margin: 0` diff --git a/skills/html-to-pdf-hires/package.json b/skills/html-to-pdf-hires/package.json new file mode 100644 index 00000000..91f23866 --- /dev/null +++ b/skills/html-to-pdf-hires/package.json @@ -0,0 +1,16 @@ +{ + "name": "html-to-pdf-hires", + "version": "1.0.0", + "description": "Pixel-perfect HTML to PDF via high-DPI screenshots", + "private": true, + "main": "scripts/convert.js", + "bin": { + "html-to-pdf-hires": "./scripts/convert.js" + }, + "scripts": { + "convert": "node scripts/convert.js" + }, + "dependencies": { + "puppeteer": "^22.0.0" + } +} diff --git a/skills/html-to-pdf-hires/scripts/convert.js b/skills/html-to-pdf-hires/scripts/convert.js new file mode 100644 index 00000000..451a2a13 --- /dev/null +++ b/skills/html-to-pdf-hires/scripts/convert.js @@ -0,0 +1,213 @@ +#!/usr/bin/env node +/** + * HTML to PDF (Hi-Res Raster) + * + * Screenshots each page element at high DPR and embeds PNGs full-bleed + * into a fresh PDF. Output is pixel-identical to what Chrome renders, + * independent of the viewer's font-rendering. + */ + +const fs = require('fs'); +const path = require('path'); +const os = require('os'); + +const puppeteer = require('puppeteer'); + +// Page formats in mm (width x height, portrait) +const FORMATS = { + A3: [297, 420], + A4: [210, 297], + A5: [148, 210], + Letter: [215.9, 279.4], + Legal: [215.9, 355.6], +}; + +// Parse CLI args +function parseArgs(argv) { + const args = { + input: null, + output: null, + dpr: 3, + format: 'A4', + landscape: false, + selector: '.page', + wait: 2500, + activateClass: null, + activateDelay: 400, + }; + const positional = []; + for (const a of argv.slice(2)) { + if (a.startsWith('--dpr=')) args.dpr = parseFloat(a.slice(6)); + else if (a.startsWith('--format=')) args.format = a.slice(9); + else if (a === '--landscape') args.landscape = true; + else if (a.startsWith('--selector=')) args.selector = a.slice(11); + else if (a.startsWith('--wait=')) args.wait = parseInt(a.slice(7), 10); + else if (a.startsWith('--activate-class=')) args.activateClass = a.slice(17); + else if (a.startsWith('--activate-delay=')) args.activateDelay = parseInt(a.slice(17), 10); + else if (a.startsWith('--')) { + console.error(`Unknown flag: ${a}`); + process.exit(2); + } else positional.push(a); + } + args.input = positional[0]; + args.output = positional[1]; + return args; +} + +function usage() { + console.error( + 'Usage: convert.js ' + + '[--dpr=3] [--format=A4] [--landscape] [--selector=".page"] [--wait=2500] ' + + '[--activate-class=] [--activate-delay=400]', + ); + process.exit(1); +} + +// Resolve input to a URL that puppeteer can load +function resolveInput(input) { + if (/^https?:\/\//i.test(input) || /^file:\/\//i.test(input)) return input; + const abs = path.resolve(input); + if (!fs.existsSync(abs)) { + console.error(`Input not found: ${abs}`); + process.exit(1); + } + // file:// URL — on Windows, path.resolve returns C:\foo\bar; convert to /C:/foo/bar + const normalized = abs.replace(/\\/g, '/'); + return 'file:///' + normalized.replace(/^\/+/, ''); +} + +// Convert mm to CSS pixels at 96dpi +const mmToPx = (mm) => Math.round((mm * 96) / 25.4); + +async function run() { + const args = parseArgs(process.argv); + if (!args.input || !args.output) usage(); + + const fmt = FORMATS[args.format]; + if (!fmt) { + console.error(`Unknown format: ${args.format}. Supported: ${Object.keys(FORMATS).join(', ')}`); + process.exit(2); + } + const [wMm, hMm] = args.landscape ? [fmt[1], fmt[0]] : fmt; + const viewportW = mmToPx(wMm); + const viewportH = mmToPx(hMm); + + const inputUrl = resolveInput(args.input); + const outputPath = path.resolve(args.output); + + // --- Phase 1: render source HTML, screenshot each page element --- + const browser = await puppeteer.launch({ + headless: 'new', + args: [ + '--no-sandbox', + '--disable-setuid-sandbox', + '--font-render-hinting=none', + '--disable-font-subpixel-positioning', + '--force-color-profile=srgb', + ], + }); + const page = await browser.newPage(); + await page.setViewport({ width: viewportW, height: viewportH, deviceScaleFactor: args.dpr }); + await page.goto(inputUrl, { waitUntil: ['load', 'networkidle0'] }); + await page.evaluateHandle('document.fonts.ready'); + await new Promise((r) => setTimeout(r, args.wait)); + + let handles = await page.$$(args.selector); + let shots; + if (handles.length === 0) { + console.error( + `Selector "${args.selector}" matched 0 elements — falling back to full-page screenshot.`, + ); + const buf = await page.screenshot({ type: 'png', fullPage: true }); + shots = [buf.toString('base64')]; + } else if (args.activateClass) { + // Slide-style decks: only the element carrying `activateClass` is visible + // (typically via opacity + position:absolute). Cycle the class across + // matches and screenshot the viewport each time. + await page.addStyleTag({ + content: `*,*::before,*::after{transition:none!important;animation:none!important;}`, + }); + shots = []; + for (let i = 0; i < handles.length; i++) { + await page.evaluate( + (sel, cls, idx) => { + document.querySelectorAll(sel).forEach((el, j) => { + el.classList.toggle(cls, j === idx); + }); + }, + args.selector, + args.activateClass, + i, + ); + await new Promise((r) => setTimeout(r, args.activateDelay)); + const buf = await page.screenshot({ type: 'png', fullPage: false }); + shots.push(buf.toString('base64')); + } + } else { + shots = []; + for (const h of handles) { + const buf = await h.screenshot({ type: 'png', omitBackground: false }); + shots.push(buf.toString('base64')); + } + } + await browser.close(); + + // --- Phase 2: compose HTML with full-bleed PNGs and print to final PDF --- + const composed = `${shots + .map((b64) => `
`) + .join('')}`; + + const tmpHtml = path.join(os.tmpdir(), `html-to-pdf-hires-${process.pid}.html`); + fs.writeFileSync(tmpHtml, composed); + + try { + const b2 = await puppeteer.launch({ + headless: 'new', + args: ['--no-sandbox', '--disable-setuid-sandbox'], + }); + const p2 = await b2.newPage(); + const tmpUrl = 'file:///' + tmpHtml.replace(/\\/g, '/').replace(/^\/+/, ''); + await p2.goto(tmpUrl, { waitUntil: ['load', 'networkidle0'] }); + await p2.pdf({ + path: outputPath, + width: `${wMm}mm`, + height: `${hMm}mm`, + printBackground: true, + preferCSSPageSize: true, + margin: { top: '0', right: '0', bottom: '0', left: '0' }, + scale: 1, + }); + await b2.close(); + } finally { + try { + fs.unlinkSync(tmpHtml); + } catch {} + } + + const bytes = fs.statSync(outputPath).size; + console.log( + `Wrote ${outputPath} — ${shots.length} page(s), ${(bytes / 1024).toFixed(1)} KB ` + + `@ dpr=${args.dpr}, ${wMm}×${hMm}mm`, + ); +} + +run().catch((err) => { + console.error(err.stack || err.message || err); + process.exit(1); +});