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);
+});