Canvas image generator

Make PNG images without an image model: Claude draws them in code on an HTML canvas, then a script screenshots the page in a headless browser.

Download canvas-image-generator.zip Version 1.0, updated 2026-10-25. MIT licence, copyright 2026 Level-headed AI.

Read it before you install it. A skill is a set of instructions, and sometimes scripts, that Claude will follow and run. Anthropic's advice is to use skills only from sources you trust and to read them first (Agent Skills overview). This one is short, and the full text of every file in it is further down this page.

What it does

Claude writes a small web page that draws the picture on an HTML canvas. The bundled script opens that page in a headless browser and saves a PNG. Claude then looks at the image, lists anything that looks wrong, and fixes only that. Because the picture is code, it is exact and repeatable, and you can change one thing without redoing the rest. It suits banners, icons, patterns, charts and social cards. It is not for photographs or realistic art.

What you need

Works with: Claude Code, running on your own computer.

Why the script is safe to run

Anything in the page's folder can be read by the page you render, so keep a separate folder for each image and render only pages you trust.

How to install it

Claude Code

  1. Unzip the download. You get a folder called canvas-image-generator.
  2. Put that folder in ~/.claude/skills/ to use it in every project, or in .claude/skills/ inside one project.
  3. Ask for an image, for example: "Make a 1920 by 1080 banner with a dark blue gradient sky, seeded stars and a glowing ring, using the canvas-image-generator skill." You can also type /canvas-image-generator.

More about how skills work: Skills in Claude Code.

claude.ai and the API

Skills can be uploaded as a zip in claude.ai (Settings, then Features), but this one needs Node and a browser on the computer that runs it. I have not tried it there, and the API's sandbox has no internet and no browser, so I would not expect it to work. Claude Code on your own computer is the intended place.

What has and hasn't been tested

Tested: Rendering was tested on Windows 11 with Edge and Chrome (Node 24). The argument checks, error messages and file-safety checks were also run on Linux.

Not tested: Rendering on macOS and Linux (the code that finds the browser there is written but has not been run), and use in claude.ai or through the Claude API.

The files

5 files, 32.8 KB before zipping.

LICENSE (1.0 KB)
MIT License

Copyright (c) 2026 Level-headed AI

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.
SKILL.md (9.0 KB)
---
name: canvas-image-generator
description: Make PNG images without an image model by drawing them with code on an HTML canvas and screenshotting the page in a headless browser. Use it when the user wants a generated graphic such as a banner, header, cover, icon, logo, background, texture, pattern, diagram, chart or social card, especially at an exact size, with a transparent background, or in a reproducible, editable form. Covers writing the drawing page, rendering it with the bundled script, looking at the result, and fixing only what is wrong. Not for photographs or realistic art.
license: MIT
compatibility: Needs Node 22+ and a Chromium-based browser (Microsoft Edge, Google Chrome or Chromium). No npm packages and no network access.
---

# Canvas image generator

You cannot paint pixels, but you can write code that does. This skill turns a request for an image into a small HTML page that draws the picture on a `<canvas>`, then uses a bundled script to open that page in a headless browser and save a screenshot as a PNG. Because the picture is code, it is exact, repeatable and easy to change.

Good fits: gradients, geometric and abstract art, starfields and other seeded "random" scenes, patterns, icons, badges, logos made of shapes and text, charts and diagrams, social cards, banners, backgrounds, textures, and anything with precise sizes or a transparent background.

Poor fits: photographs, realistic people or animals, and anything that needs the look of hand-painted art. Say so plainly and offer the closest thing the canvas can do well.

## What is in the folder

- `scripts/render-still.js`: opens a page in a headless browser and writes a PNG.
- `templates/blank.html`: the smallest correct starting page. Copy it and draw in `draw()`.
- `examples/starfield.html`: a worked example (seeded random stars, soft nebula blobs, a glowing ring, a vignette).

## The contract a page must follow

1. Read the size from the address: `?w=<width>&h=<height>`. The renderer adds these. Set `canvas.width` and `canvas.height` to them and scale your drawing from the size (for example `const S = Math.min(W, H) / 1000`) so it looks right at any resolution.
2. Set `window.__ready = true` when drawing has finished, and not before. The renderer waits for it and fails with a clear message if it never arrives.
3. If the page uses a web font or an image, wait for it first (`await document.fonts.load('48px "My Font"')`, or an image's `decode()`), then draw, then set `__ready`.
4. Keep everything in the page's own folder. The renderer serves only that folder on `127.0.0.1` and refuses anything outside it. Use local files only, not web addresses: the browser is not allowed to look up other hosts.
5. For a transparent picture, do not paint a background (no full-size `fillRect`), and render with `--transparent`.
6. Use a seeded random number generator, never `Math.random()`, so the same page always gives the same picture. The example has a short one (`mulberry32`).

## The workflow

Follow these steps in order, every time.

1. **Pin down the brief.** Ask only for what you cannot sensibly decide: the size (default 1920 by 1080), where it will be used, any words that must appear, and colours. If the user gives none, choose a sensible palette and say what you chose.
2. **Work in a scratch folder** that holds only this image's files, because the renderer serves the whole folder the page is in. Copy `templates/blank.html` there under a new name.
3. **Draw in layers, back to front**: background, large soft shapes, mid-sized detail, small detail and text, then finishing touches such as a vignette. Build up in this order, and keep the drawing code in small named functions so a change is easy.
4. **Render:**
   ```
   node scripts/render-still.js my-picture.html my-picture.png 1920 1080
   ```
   Add `--transparent` for a see-through background, `--timeout=60000` for a heavy page, and `--browser="<path>"` (or set `CANVAS_BROWSER`) if no browser is found automatically. The sizes are in pixels and at most 8192 each.
5. **Look at the PNG. Always.** Open the image itself, not just the command output. Do not say it is finished until you have looked.
6. **List what looks wrong**, specifically (see the checklist below). If nothing is wrong, say what you checked.
7. **Fix only what is wrong**, re-render, and look again. Do not redesign things the user did not complain about.
8. **Report honestly**: where the files are, the exact size, and anything you could not verify (for example "I could not check how this looks on a phone screen" or "colours may look different on another display").

## Review checklist (go through it after every render)

Look at the whole image first, then zoom in on details. For small things, render a second copy at a larger size or crop the PNG, because thin lines and small text hide problems when an image is shown small.

- **Size and format:** is the PNG exactly the size asked for? Is the background transparent if it should be (check a corner against a coloured backdrop)?
- **Clipping:** is anything cut off at an edge that should not be? Is there enough margin around text and the main shape?
- **Text:** is it spelt correctly, readable at the size it will be shown, and high enough in contrast with what is behind it? Does it overlap anything? Is the font the one you meant (a missing font silently falls back to a default)?
- **Overlaps and collisions:** do shapes or labels sit on top of each other by accident?
- **Balance:** is the focal point where you intended, and is the rest quiet enough to let it stand out? Is it too busy, too empty, or lopsided?
- **Colour:** banding in gradients, muddy mixes, too many competing colours, glow that has washed out detail, or dark detail lost in the dark.
- **Edges:** jagged or blurry edges, hairline gaps between shapes that should touch, stray pixels.
- **Details that repeat:** do patterns tile without a visible seam? Are "random" elements clumped or in a visible grid?
- **Console errors:** the renderer prints page errors to the terminal. Any message there is a bug to explain or fix, even if the picture seems fine.

## Lessons and common mistakes

- **Waiting properly matters.** A screenshot taken before fonts or images have loaded shows the wrong font or missing pictures. Set `__ready` only at the very end.
- **Set the scale on purpose.** The renderer sets the device scale factor to 1, so one CSS pixel is one output pixel. If you want a sharper picture, ask for a larger size instead.
- **Scale everything from the size.** A picture hard-coded for 1280 wide looks tiny at 4096.
- **Seed your random numbers.** Otherwise every render differs and "fix only what is wrong" becomes impossible.
- **Do not paint a background when you need transparency**, and remember an opaque render shows the browser's white behind an unpainted page.
- **Glows and shadows are cheap to overdo.** Add them last, in small amounts, then check the dark areas.
- **Use `globalCompositeOperation = 'lighter'`** for glows, nebulae and light beams (overlaps add up); switch back to `'source-over'` afterwards.
- **Draw text last**, with an outline or soft shadow when it sits on a busy picture, and measure it (`ctx.measureText`) to centre and fit it.
- **Do not trust the code instead of the image.** Code that looks right can still draw something wrong. The picture is the truth.
- **If the render fails**, read the message: it names the cause (no browser, page never ready, bad size, a path outside the folder). A page that never sets `__ready` usually has an error earlier in its script; the page errors are printed above the message.
- **Windows:** the script works with Edge or Chrome. If a temporary profile cannot be deleted it says so; the folder is in the system's temporary folder and can be removed by hand.
- **Keep scratch folders small.** Anything in the page's folder is reachable by the page, so do not put private files there.

## Example prompt that works well

> Make a 1920 by 1080 PNG banner for a space game. A dark blue to black gradient sky, about 700 small seeded stars with a few larger glowing ones, soft purple and teal nebula blobs, and a large glowing orange ring in the lower right. Draw it on an HTML canvas and render it with the canvas-image-generator skill. Then look at the image and list anything that looks wrong (clipping, banding, muddy colours, stars clumping) before you fix it. Change only what is wrong, and tell me what you could not check.

Other prompts of the same shape: "a transparent 512 by 512 badge with a hexagon, a lightning bolt and the word VOLT", "a seamless 1024 by 1024 tile of overlapping circles in four muted colours", "a 1200 by 630 social card with this title and a gradient background, text large enough to read as a thumbnail".

## Quick reference

```
node scripts/render-still.js <page.html> <out.png> [width] [height] [--transparent] [--browser=<path>] [--timeout=<ms>]
```

- Defaults: 1920 by 1080, 30 second wait. Maximum 8192 by 8192.
- Exit code 0 on success, 1 on a render failure, 2 for bad arguments.
- It always closes the browser, stops the server and deletes its temporary profile, even after an error.
examples/starfield.html (4.2 KB)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Starfield example</title>
<style>
	html, body { margin: 0; padding: 0; background: #000; overflow: hidden; }
	canvas { display: block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// A starfield with a glowing ring: gradient sky, soft nebula blobs, stars, a ring and a vignette. Everything is drawn from a seeded random generator,
// so the same address always gives the same picture. Size comes from the address (?w=1920&h=1080); ?seed=7 gives a different sky.
(function () {
	var q = new URLSearchParams(location.search);
	var W = parseInt(q.get('w'), 10) || 1280, H = parseInt(q.get('h'), 10) || 720, seed = parseInt(q.get('seed'), 10) || 1;
	var canvas = document.getElementById('c'); canvas.width = W; canvas.height = H;
	var ctx = canvas.getContext('2d');

	// mulberry32: a small seeded random number generator (returns 0 to 1)
	function rng(a) { return function () { a |= 0; a = a + 0x6D2B79F5 | 0; var t = Math.imul(a ^ a >>> 15, 1 | a); t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t; return ((t ^ t >>> 14) >>> 0) / 4294967296; }; }
	var rand = rng(seed), S = Math.min(W, H) / 1000;   // S scales sizes so the picture looks the same at any resolution

	// 1. the sky: a vertical gradient from deep blue to near black
	var sky = ctx.createLinearGradient(0, 0, 0, H);
	sky.addColorStop(0, '#0b1230'); sky.addColorStop(0.6, '#060916'); sky.addColorStop(1, '#020309');
	ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);

	// 2. nebula: big soft radial blobs added on top of each other ('lighter' makes overlaps glow)
	ctx.globalCompositeOperation = 'lighter';
	var tints = ['120,60,200', '30,150,170', '200,80,120'];
	for (var i = 0; i < 9; i++) {
		var x = rand() * W, y = rand() * H, r = (250 + rand() * 450) * S, tint = tints[i % tints.length];
		var g = ctx.createRadialGradient(x, y, 0, x, y, r);
		g.addColorStop(0, 'rgba(' + tint + ',0.16)'); g.addColorStop(1, 'rgba(' + tint + ',0)');
		ctx.fillStyle = g; ctx.fillRect(x - r, y - r, r * 2, r * 2);
	}

	// 3. stars: many tiny ones, a few bigger ones with a soft glow
	for (var s = 0; s < 700 * (W * H) / (1280 * 720); s++) {
		var sx = rand() * W, sy = rand() * H, size = (0.4 + Math.pow(rand(), 4) * 1.8) * Math.max(1, S), a = 0.25 + rand() * 0.75;
		ctx.fillStyle = 'rgba(255,255,255,' + a.toFixed(3) + ')';
		ctx.beginPath(); ctx.arc(sx, sy, size, 0, Math.PI * 2); ctx.fill();
	}
	for (var b = 0; b < 14; b++) {
		var bx = rand() * W, by = rand() * H, br = (6 + rand() * 10) * S, warm = rand() > 0.5 ? '255,230,190' : '190,215,255';
		var glow = ctx.createRadialGradient(bx, by, 0, bx, by, br * 2.5);
		glow.addColorStop(0, 'rgba(' + warm + ',1)'); glow.addColorStop(0.12, 'rgba(' + warm + ',0.55)'); glow.addColorStop(0.4, 'rgba(' + warm + ',0.12)'); glow.addColorStop(1, 'rgba(' + warm + ',0)');
		ctx.fillStyle = glow; ctx.fillRect(bx - br * 2.5, by - br * 2.5, br * 5, br * 5);
	}

	// 4. a glowing ring in the lower right: a dark disc, then a few strokes of different width and brightness
	var cx = W * 0.68, cy = H * 0.62, R = 150 * S;
	ctx.globalCompositeOperation = 'source-over';
	var halo = ctx.createRadialGradient(cx, cy, R * 0.8, cx, cy, R * 2.6);
	halo.addColorStop(0, 'rgba(255,150,40,0.35)'); halo.addColorStop(1, 'rgba(255,150,40,0)');
	ctx.fillStyle = halo; ctx.fillRect(cx - R * 3, cy - R * 3, R * 6, R * 6);
	ctx.fillStyle = '#000'; ctx.beginPath(); ctx.arc(cx, cy, R, 0, Math.PI * 2); ctx.fill();
	ctx.globalCompositeOperation = 'lighter';
	[[1.0, 26, 'rgba(255,120,20,0.35)'], [1.0, 12, 'rgba(255,180,70,0.7)'], [1.0, 4, 'rgba(255,240,200,0.95)']].forEach(function (p) {
		ctx.lineWidth = p[1] * S; ctx.strokeStyle = p[2]; ctx.shadowColor = 'rgba(255,140,30,0.9)'; ctx.shadowBlur = 30 * S;
		ctx.beginPath(); ctx.arc(cx, cy, R * p[0], 0, Math.PI * 2); ctx.stroke();
	});
	ctx.shadowBlur = 0;

	// 5. vignette: darken the corners so the eye stays in the middle
	ctx.globalCompositeOperation = 'source-over';
	var vg = ctx.createRadialGradient(W / 2, H / 2, Math.min(W, H) * 0.35, W / 2, H / 2, Math.hypot(W, H) * 0.6);
	vg.addColorStop(0, 'rgba(0,0,0,0)'); vg.addColorStop(1, 'rgba(0,0,0,0.65)');
	ctx.fillStyle = vg; ctx.fillRect(0, 0, W, H);

	// the renderer waits for this
	window.__ready = true;
})();
</script>
</body>
</html>
scripts/render-still.js (17.3 KB)
#!/usr/bin/env node
'use strict';
// Renders one still image from an HTML page by opening it in a headless Chromium-based browser (Edge, Chrome or Chromium) and taking a screenshot.
//   node scripts/render-still.js <page.html> <out.png> [width] [height] [--transparent] [--browser=<path>] [--timeout=<ms>]
// Needs Node 22+ (built-in WebSocket and fetch) and one browser. No npm packages. Nothing leaves this machine: the page is served from its own folder on 127.0.0.1,
// and the browser is told not to resolve any other host name.
// The page must set `window.__ready = true` when it has finished drawing. The width and height are passed to it as ?w=...&h=... in the address.
const http = require('http'), fs = require('fs'), os = require('os'), path = require('path'), cp = require('child_process');

const MAX_SIZE = 8192, DEFAULT_W = 1920, DEFAULT_H = 1080, DEFAULT_TIMEOUT = 30000;
const TYPES = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json',
	'.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml', '.ttf': 'font/ttf', '.otf': 'font/otf', '.woff': 'font/woff', '.woff2': 'font/woff2', '.txt': 'text/plain; charset=utf-8' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const warn = (m) => process.stderr.write(m + '\n');
class UserError extends Error {}

function usage() {
	return 'Usage: node scripts/render-still.js <page.html> <out.png> [width] [height] [--transparent] [--browser=<path>] [--timeout=<ms>]\n' +
		'  width, height   output size in pixels (default ' + DEFAULT_W + 'x' + DEFAULT_H + ', at most ' + MAX_SIZE + ')\n' +
		'  --transparent   leave the background see-through (the page must not paint one)\n' +
		'  --browser=PATH  the browser to use (or set CANVAS_BROWSER); otherwise Edge, Chrome or Chromium is looked for\n' +
		'  --timeout=MS    how long to wait for the page to set window.__ready (default ' + DEFAULT_TIMEOUT + ')';
}

function parseArgs(argv) {
	const pos = [], o = { transparent: false, browser: process.env.CANVAS_BROWSER || '', timeout: DEFAULT_TIMEOUT, help: false };
	for (const a of argv) {
		if (a === '--transparent') o.transparent = true;
		else if (a === '-h' || a === '--help') o.help = true;
		else if (a.startsWith('--browser=')) o.browser = a.slice('--browser='.length);
		else if (a.startsWith('--timeout=')) o.timeout = Number(a.slice('--timeout='.length));
		else if (a.startsWith('--')) throw new UserError('Unknown option ' + a + '\n' + usage());
		else pos.push(a);
	}
	if (o.help) return o;
	if (pos.length < 2 || pos.length > 4) throw new UserError('Expected a page and an output file.\n' + usage());
	o.page = pos[0]; o.out = pos[1];
	const dim = (v, name, d) => {
		if (v === undefined) return d;
		const n = Number(v);
		if (!Number.isInteger(n) || n < 1) throw new UserError(name + ' must be a whole number of pixels, got "' + v + '"');
		if (n > MAX_SIZE) throw new UserError(name + ' is ' + n + ' but the most allowed is ' + MAX_SIZE);
		return n;
	};
	o.width = dim(pos[2], 'width', DEFAULT_W); o.height = dim(pos[3], 'height', DEFAULT_H);
	if (!Number.isFinite(o.timeout) || o.timeout < 1000) throw new UserError('--timeout must be at least 1000 (milliseconds)');
	return o;
}

// ---- finding a browser
function onPath(names) {
	const dirs = (process.env.PATH || '').split(path.delimiter).filter(Boolean), exts = process.platform === 'win32' ? ['.exe'] : [''];
	for (const n of names) for (const d of dirs) for (const e of exts) {
		const f = path.join(d, n + e);
		try { fs.accessSync(f, fs.constants.X_OK); if (fs.statSync(f).isFile()) return f; } catch (e2) { /* not here */ }
	}
	return null;
}
function findBrowser(explicit) {
	if (explicit) {
		if (!fs.existsSync(explicit)) throw new UserError('The browser given with --browser or CANVAS_BROWSER was not found: ' + explicit);
		return explicit;
	}
	const c = [];
	if (process.platform === 'win32') {
		for (const base of [process.env['ProgramFiles(x86)'], process.env.ProgramFiles, process.env.LOCALAPPDATA].filter(Boolean)) {
			c.push(path.join(base, 'Microsoft', 'Edge', 'Application', 'msedge.exe'), path.join(base, 'Google', 'Chrome', 'Application', 'chrome.exe'), path.join(base, 'Chromium', 'Application', 'chrome.exe'));
		}
	} else if (process.platform === 'darwin') {
		c.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', '/Applications/Chromium.app/Contents/MacOS/Chromium');
	}
	for (const f of c) if (fs.existsSync(f)) return f;
	const p = onPath(['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-browser', 'microsoft-edge', 'microsoft-edge-stable', 'chrome', 'msedge']);
	if (p) return p;
	if (fs.existsSync('/snap/bin/chromium')) return '/snap/bin/chromium';
	throw new UserError('No browser found. Install Microsoft Edge, Google Chrome or Chromium, or give the path with --browser=<path> (or the CANVAS_BROWSER environment variable).');
}

// ---- a tiny web server for ONE folder, on 127.0.0.1 and a free port. Anything that resolves outside the folder (.., symbolic links, odd encodings) is refused.
function startServer(rootDir) {
	const root = fs.realpathSync(rootDir), norm = (p) => process.platform === 'win32' ? p.toLowerCase() : p;
	const inside = (p) => norm(p) === norm(root) || norm(p).startsWith(norm(root + path.sep));
	let port = 0;
	const send = (res, code, text) => { res.writeHead(code, { 'Content-Type': 'text/plain; charset=utf-8', 'Cache-Control': 'no-store' }); res.end(text); };
	const server = http.createServer((req, res) => {
		try {
			if (req.method !== 'GET' && req.method !== 'HEAD') return send(res, 405, 'Method not allowed');
			const host = String(req.headers.host || '');
			if (host !== '127.0.0.1:' + port && host !== 'localhost:' + port) return send(res, 403, 'Forbidden host');
			let rel;
			try { rel = decodeURIComponent(String(req.url).split('?')[0].split('#')[0]); } catch (e) { return send(res, 400, 'Bad request'); }
			if (rel.includes('\0') || !rel.startsWith('/')) return send(res, 400, 'Bad request');
			if (rel === '/favicon.ico' && !fs.existsSync(path.join(root, 'favicon.ico'))) { res.writeHead(204); return res.end(); }   // browsers always ask for it: answer quietly so it is not reported as a page error
			const file = path.resolve(root, '.' + rel);
			if (!inside(file)) { warn('[server] refused a path outside the page folder: ' + rel); return send(res, 403, 'Forbidden: outside the page folder'); }
			fs.realpath(file, (err, real) => {
				if (err) return send(res, 404, 'Not found');
				if (!inside(real)) { warn('[server] refused a link that leads outside the page folder: ' + rel); return send(res, 403, 'Forbidden: outside the page folder'); }
				fs.stat(real, (e2, st) => {
					if (e2 || !st.isFile()) return send(res, 404, 'Not found');
					res.writeHead(200, { 'Content-Type': TYPES[path.extname(real).toLowerCase()] || 'application/octet-stream', 'Content-Length': st.size, 'Cache-Control': 'no-store' });
					if (req.method === 'HEAD') return res.end();
					fs.createReadStream(real).on('error', () => res.destroy()).pipe(res);
				});
			});
		} catch (e) { send(res, 500, 'Server error'); }
	});
	return new Promise((resolve, reject) => {
		server.on('error', reject);
		server.listen(0, '127.0.0.1', () => { port = server.address().port; resolve({ server, port, root }); });
	});
}

// ---- the browser
function killTree(child) {
	if (!child || !child.pid) return;
	try {
		if (process.platform === 'win32') cp.spawnSync('taskkill', ['/PID', String(child.pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true });
		else { try { process.kill(-child.pid, 'SIGKILL'); } catch (e) { process.kill(child.pid, 'SIGKILL'); } }
	} catch (e) { /* already gone */ }
}

class Cdp {
	constructor(ws) {
		this.ws = ws; this.id = 1; this.pending = new Map(); this.handlers = new Map();
		ws.addEventListener('message', (m) => {
			const d = JSON.parse(m.data);
			if (d.id && this.pending.has(d.id)) { const p = this.pending.get(d.id); this.pending.delete(d.id); d.error ? p.reject(new Error(d.error.message || JSON.stringify(d.error))) : p.resolve(d.result); }
			else if (d.method && this.handlers.has(d.method)) for (const h of this.handlers.get(d.method)) h(d.params);
		});
		ws.addEventListener('close', () => { for (const p of this.pending.values()) p.reject(new Error('The browser connection closed')); this.pending.clear(); });
	}
	static open(url) {
		return new Promise((resolve, reject) => {
			const ws = new WebSocket(url);
			ws.addEventListener('open', () => resolve(new Cdp(ws)));
			ws.addEventListener('error', () => reject(new Error('Could not connect to the browser')));
		});
	}
	send(method, params) { return new Promise((resolve, reject) => { const id = this.id++; this.pending.set(id, { resolve, reject }); this.ws.send(JSON.stringify({ id, method, params: params || {} })); }); }
	on(method, fn) { if (!this.handlers.has(method)) this.handlers.set(method, []); this.handlers.get(method).push(fn); }
	close() { try { this.ws.close(); } catch (e) { /* ignore */ } }
}

const argText = (a) => a.value !== undefined ? String(a.value) : (a.description || a.type || '');
function pngSize(buf) { return buf.length > 24 && buf.toString('latin1', 1, 4) === 'PNG' ? { w: buf.readUInt32BE(16), h: buf.readUInt32BE(20) } : null; }

async function render(o) {
	const pagePath = path.resolve(o.page);
	if (!fs.existsSync(pagePath) || !fs.statSync(pagePath).isFile()) throw new UserError('The page was not found: ' + pagePath);
	const browser = findBrowser(o.browser);
	const outPath = path.resolve(o.out);
	let srv = null, child = null, cdp = null, profile = null, cleaned = false, pageErrors = 0, exited = null;
	const cleanup = async () => {
		if (cleaned) return; cleaned = true;
		if (cdp) cdp.close();
		killTree(child);
		if (child && exited === null) await Promise.race([new Promise((r) => child.once('exit', r)), sleep(3000)]);
		if (srv) { try { srv.server.closeAllConnections && srv.server.closeAllConnections(); srv.server.close(); } catch (e) { /* ignore */ } }
		if (profile) {
			let removed = false;
			for (let i = 0; i < 20 && !removed; i++) {
				try { fs.rmSync(profile, { recursive: true, force: true }); removed = true; } catch (e) {   // (Windows keeps files locked for a moment after a browser exits)
					if (i === 3 && process.platform === 'win32') {   // some helper processes (for example Chrome's crash handler) can outlive the tree kill: stop any that still use this profile
						const leaf = path.basename(profile);
						cp.spawnSync('powershell', ['-NoProfile', '-NonInteractive', '-Command', "Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -like '*" + leaf + "*' -and $_.ProcessId -ne $PID } | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue }"], { stdio: 'ignore', windowsHide: true });
					}
					await sleep(300);
				}
			}
			if (!removed) warn('Warning: could not delete the temporary browser profile ' + profile + ' (delete it by hand).');
		}
	};
	const onSignal = () => { cleanup().finally(() => process.exit(130)); };
	process.once('SIGINT', onSignal); process.once('SIGTERM', onSignal);
	try {
		srv = await startServer(path.dirname(pagePath));
		profile = fs.mkdtempSync(path.join(os.tmpdir(), 'canvas-still-'));
		const args = ['--headless=new', '--remote-debugging-port=0', '--user-data-dir=' + profile, '--window-size=' + o.width + ',' + o.height, '--hide-scrollbars', '--disable-extensions',
			'--force-device-scale-factor=1', '--no-first-run', '--no-default-browser-check', '--disable-background-networking', '--disable-sync', '--no-pings', '--mute-audio',
			'--host-resolver-rules=MAP * ~NOTFOUND, EXCLUDE 127.0.0.1'];
		if (process.platform === 'linux' && typeof process.getuid === 'function' && process.getuid() === 0) { args.push('--no-sandbox'); warn('Note: running as root on Linux, so the browser is started with --no-sandbox. Only render pages you trust.'); }
		args.push('about:blank');
		child = cp.spawn(browser, args, { stdio: 'ignore', windowsHide: true, detached: process.platform !== 'win32' });
		child.on('exit', (code) => { exited = code === null ? -1 : code; });
		child.on('error', (e) => { exited = -1; warn('Could not start the browser: ' + e.message); });

		// the browser writes its debugging port (it was given 0, so it picks a free one) to a file in its profile
		const portFile = path.join(profile, 'DevToolsActivePort');
		let debugPort = 0;
		for (let i = 0; i < 100 && !debugPort; i++) {
			if (exited !== null) throw new UserError('The browser exited before it was ready (exit code ' + exited + '): ' + browser);
			try { const t = fs.readFileSync(portFile, 'utf8').split('\n'); if (t[0] && /^\d+$/.test(t[0].trim()) && t[1]) debugPort = Number(t[0]); } catch (e) { /* not written yet */ }
			if (!debugPort) await sleep(200);
		}
		if (!debugPort) throw new UserError('The browser did not report a debugging port within 20 seconds: ' + browser);
		let target = null;
		for (let i = 0; i < 50 && !target; i++) {
			try { const list = await (await fetch('http://127.0.0.1:' + debugPort + '/json/list')).json(); target = list.find((t) => t.type === 'page'); } catch (e) { /* retry */ }
			if (!target) await sleep(200);
		}
		if (!target) throw new UserError('The browser started but opened no page');
		cdp = await Cdp.open(target.webSocketDebuggerUrl);

		cdp.on('Runtime.consoleAPICalled', (p) => { if (['error', 'warning', 'assert'].includes(p.type)) { if (p.type !== 'warning') pageErrors++; warn('[page ' + p.type + '] ' + p.args.map(argText).join(' ')); } });
		cdp.on('Runtime.exceptionThrown', (p) => { pageErrors++; const d = p.exceptionDetails || {}; warn('[page exception] ' + ((d.exception && (d.exception.description || d.exception.value)) || d.text) + (d.url ? ' (' + d.url + ':' + d.lineNumber + ')' : '')); });
		cdp.on('Log.entryAdded', (p) => { const e = p.entry || {}; if (e.level === 'error' || e.level === 'warning') { if (e.level === 'error') pageErrors++; warn('[page ' + e.level + '] ' + e.text + (e.url ? ' (' + e.url + ')' : '')); } });
		await cdp.send('Page.enable'); await cdp.send('Runtime.enable'); await cdp.send('Log.enable');
		await cdp.send('Emulation.setDeviceMetricsOverride', { width: o.width, height: o.height, deviceScaleFactor: 1, mobile: false });   // the scale factor is set on purpose: 1 CSS pixel = 1 output pixel
		if (o.transparent) await cdp.send('Emulation.setDefaultBackgroundColorOverride', { color: { r: 0, g: 0, b: 0, a: 0 } });
		const url = 'http://127.0.0.1:' + srv.port + '/' + encodeURIComponent(path.basename(pagePath)) + '?w=' + o.width + '&h=' + o.height;
		const nav = await cdp.send('Page.navigate', { url });
		if (nav.errorText) throw new UserError('The page could not be opened (' + nav.errorText + '): ' + url);

		// wait for the fonts, then for the page to say it has finished drawing
		const deadline = Date.now() + o.timeout, left = () => Math.max(1, deadline - Date.now());
		const evaluate = (expression, await_) => cdp.send('Runtime.evaluate', { expression, awaitPromise: !!await_, returnByValue: true }).then((r) => r.exceptionDetails ? undefined : r.result.value);
		const timedOut = Symbol('timeout');
		const fonts = await Promise.race([evaluate('document.fonts ? document.fonts.ready.then(function () { return true; }) : true', true).catch(() => undefined), sleep(left()).then(() => timedOut)]);
		if (fonts === timedOut) throw new UserError('The fonts did not finish loading within ' + o.timeout + ' ms (document.fonts.ready never resolved).');
		let ready = false;
		while (!ready && Date.now() < deadline) {
			if (exited !== null) throw new UserError('The browser exited while the page was loading (exit code ' + exited + ')');
			try { ready = (await evaluate('window.__ready === true')) === true; } catch (e) { /* the page is still loading */ }
			if (!ready) await sleep(100);
		}
		if (!ready) throw new UserError('The page never set window.__ready = true within ' + o.timeout + ' ms. Set it at the end of the drawing code, after fonts and images have loaded' + (pageErrors ? ' (' + pageErrors + ' page error(s) were printed above; they are the likely cause).' : '.'));

		const shot = await cdp.send('Page.captureScreenshot', { format: 'png', fromSurface: true, captureBeyondViewport: false, clip: { x: 0, y: 0, width: o.width, height: o.height, scale: 1 } });
		const png = Buffer.from(shot.data, 'base64'), size = pngSize(png);
		fs.mkdirSync(path.dirname(outPath), { recursive: true });
		fs.writeFileSync(outPath, png);
		if (size && (size.w !== o.width || size.h !== o.height)) warn('Warning: asked for ' + o.width + 'x' + o.height + ' but the PNG is ' + size.w + 'x' + size.h);
		console.log('wrote ' + outPath + ' (' + (size ? size.w + 'x' + size.h : o.width + 'x' + o.height) + ', ' + (png.length / 1024).toFixed(0) + ' KB' + (o.transparent ? ', transparent background' : '') + ')');
		if (pageErrors) warn(pageErrors + ' page error(s) were reported above; check the picture carefully.');
	} finally {
		await cleanup();
		process.removeListener('SIGINT', onSignal); process.removeListener('SIGTERM', onSignal);
	}
}

async function main() {
	let o;
	try { o = parseArgs(process.argv.slice(2)); } catch (e) { warn('ERROR: ' + e.message); return 2; }
	if (o.help) { console.log(usage()); return 0; }
	try { await render(o); return 0; } catch (e) { warn('ERROR: ' + (e instanceof UserError ? e.message : (e && e.stack) || e)); return 1; }
}

module.exports = { startServer, findBrowser, parseArgs };
if (require.main === module) main().then((code) => process.exit(code));
templates/blank.html (1.3 KB)
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Blank canvas</title>
<style>
	html, body { margin: 0; padding: 0; background: transparent; overflow: hidden; }
	canvas { display: block; }
</style>
</head>
<body>
<canvas id="c"></canvas>
<script>
// The smallest correct starting page. The renderer opens it as blank.html?w=<width>&h=<height>, waits until fonts have loaded and window.__ready is true, then takes a screenshot.
// Draw in draw(). Leave the background unpainted if the picture should be see-through (render with --transparent).
var q = new URLSearchParams(location.search);
var W = parseInt(q.get('w'), 10) || 1280, H = parseInt(q.get('h'), 10) || 720;
var canvas = document.getElementById('c'); canvas.width = W; canvas.height = H;
var ctx = canvas.getContext('2d');

function draw(ctx, W, H) {
	// example: an orange disc with a white outline, centred
	ctx.fillStyle = '#ff8a1f';
	ctx.beginPath(); ctx.arc(W / 2, H / 2, Math.min(W, H) * 0.3, 0, Math.PI * 2); ctx.fill();
	ctx.lineWidth = Math.min(W, H) * 0.01; ctx.strokeStyle = '#ffffff'; ctx.stroke();
}

// if you use text or images, wait for them here (for a web font: document.fonts.load('48px "My Font"')) before drawing
draw(ctx, W, H);
window.__ready = true;
</script>
</body>
</html>