Type or paste anything, then press it into a PNG you can hand to anyone.
The whole engine is one dependency-free file. These are its four functions, the protocol it speaks, and the ways to wire it into your own project.
Everything hangs off the global PuttyPNG. Both encode and decode return promises.
Hides input (a string, plain object, Uint8Array, ArrayBuffer, or File/Blob) inside a PNG.
| Option | Default | Meaning |
|---|---|---|
| password | none | If set, encrypts with AES-256-GCM (key from PBKDF2-SHA-256). Filename & type are hidden too. |
| depth | "standard" | "standard" = 1 byte/pixel (roomy). "subtle" = 3 bits/pixel (invisible). |
| compress | true | Auto gzip, kept only when it makes the data smaller. |
| tag | "" | Plaintext developer tag, readable later via peek() without decrypting. |
| cover | none | A PNG (File/Blob/URL/Image) to hide the data in. Default is generated noise. Resampled with high-quality smoothing; a transparent cover is auto-hardened so no data pixel borders a soft edge. |
| coverFit | "crop" | How a cover fills the frame: crop, keepRatio (non-square, original shape), scale, center, stretch. |
| size | auto | Fixed square side in pixels. Omit to auto-size to the data. A value set here is used exactly, even below minSize. |
| sizeMode | "auto" | "pow2" rounds the auto size up to a power of two. |
| minSize | 256 | Floor for auto-sizing (px), so a small payload still makes a shareable image. An explicit size ignores this floor. |
| name | "" | Filename recorded in the (protected) metadata. |
| coverStyle | "noise" | Built-in generated cover when no cover is given: "noise" or "cd" (a reflective disc). CD defaults depth to subtle. |
| label | "" | CD only: curved text across the top of the disc (shrinks / wraps). |
| solidBackground | false | CD only: fill the corners with a soft gradient instead of leaving them transparent. Fully opaque → smaller disc and survives being flattened onto a background. |
| imprint | none | CD only: an image (File/Blob/URL/Image) etched onto the disc as a grayscale stipple, beneath the label. |
| fontFamily | system | CD only: the label font. |
| fontSize | "medium" | CD only: label size: "small", "medium", "large", or "xlarge" (each scales with the disc size; still shrinks/wraps to fit). |
| rimText | auto | CD only: the mirrored microtext around the inner rim. Defaults to "Paste into PuttyPNG.com to decode!". The text shrinks to fit if it is long. |
| rimSize | 13 | CD only: rim microtext size in points measured on a 256px disc, then scaled to the real disc, so the text keeps its proportion at every size. A name ("small", "medium", "large", "xlarge") picks a fraction of the disc instead. |
| rimSpacing | 0 | CD only: extra space after each rim letter, in px on a 256px disc, scaled the same way. Negative values tighten the text. |
| rimTwoSided | false | CD only: always print the rim text at the top and the bottom, shrinking it to fit each half. Left off, text that outgrows one half wraps once around the rim instead. |
| splat | branding | CD only: the default "putty splat" imprint (our reusable Gak-like silhouette, stippled into the surface across the full lobe extent; dots stay 5% clear of the disc edge and the hub). { points, curve, waviness, amplitude, seed, size, dotColor, dotMin, dotMax, separation, textBuffer, textClear }. textBuffer is how far the dot-clearing reaches past the text, in px on a 256px disc and scaled from there (default 4); textClear is how much of that space is emptied, from 0 to 1 (default 0.25). dotColor is a palette (default "rainbowSoft"; also rainbowStrong, black, white, dkgray, ltgray, blue, red, orange, yellow, green). A custom imprint image overrides it. |
| hub | round | CD only: the round clamping centre. { size, holeSize, outerThickness, innerThickness } (fractions of the disc). The spindle hole is transparent. |
Result: { dataUrl, blob, width, height, depth, compressed, encrypted, bytesHidden, capacityBytes, usedPercent }
const png = await PuttyPNG.encode("hello", { password: "s3cret", depth: "subtle" });
document.querySelector("img").src = png.dataUrl; // show it
// png.blob is a PNG Blob you can upload, download, or copy to the clipboard
source may be a File/Blob, an <img>, a <canvas>, an ImageData, or a data-URL / URL string.
| Option | Default | Meaning |
|---|---|---|
| password | none | Needed for encrypted PuttyPNGs. If omitted, passwordPrompt is called. |
| autoDownload | false | If the payload is a binary file, also save it to disk. |
Result: { type, name, mime, bytes, text?, json?, tag, encrypted, compressed, depth, width, height }. type is "text", "json", or "binary".
const result = await PuttyPNG.decode(file); if (result.type === "binary") PuttyPNG.download(result); else console.log(result.text, result.json);
Reads the header and the plaintext developer tag without decrypting. Returns { isPuttyPNG: false } for anything that is not a PuttyPNG.
const info = await PuttyPNG.peek(file);
if (info.isPuttyPNG && info.tag === "myapp v2") { /* it's ours */ }
download() saves a decoded result (or a raw Blob) to disk. passwordPrompt is the override point for password entry. It defaults to the browser's native prompt(). Replace it with your own UI (see Custom prompt).
| Also on PuttyPNG | What it is |
|---|---|
| PuttyPNG.version | Engine version string (e.g. "1.0.0"). |
| PuttyPNG.protocolVersion | Protocol version number (1). |
| PuttyPNG.errors | The { "PTY-E00": "...", ... } code table (see below). |
| PuttyPNG.selfTest() | Runs the built-in test battery; returns { passed, failed, results }. |
Data is written into the low bits of the R, G, B channels of fully opaque pixels (alpha = 255), in raster order. Multi-byte integers are big-endian.
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Magic PPNG |
| 4 | 1 | Protocol version (decoders reject a higher number → PTY-E01) |
| 5 | 1 | Flags: bit0 compressed, bit1 encrypted, bit2 subtle depth |
| 6 | 2 | Developer-tag length |
| 8 | 2 | Outer-metadata length |
| 10 | 4 | Payload length |
| 14 | 4 | CRC32 of the payload (as embedded) |
[dev tag][outer metadata][payload]
The outer metadata is empty unless encrypted, in which case it holds the (plaintext) crypto parameters: { enc, kdf, iterations, salt, iv }. The payload is the processed inner container.
[uint16 metadata length][metadata JSON: {name, type, mime}][data bytes]
Because the inner container lives inside the compress/encrypt envelope, an encrypted PuttyPNG reveals no filename or type.
Standard packs 3 bits into R, 2 into G, 3 into B, one byte per pixel. Subtle uses one bit per channel, three bits per pixel, invisible even on flat art. Only alpha = 255 pixels carry data; alpha is never modified.
Transparency is hardened. Semi-transparent, anti-aliased fringe pixels (alpha 1 to 254) are the ones platforms silently rewrite, so a custom cover is snapped to binary alpha: every fringe pixel becomes fully opaque, while genuinely transparent (alpha 0) regions are kept. No data-bearing pixel ever borders fractional alpha, so a lossless round-trip, or a flatten onto any background, leaves every hidden byte intact.
Encode: build inner container → gzip (if it helps) → encrypt (if a password is given) → CRC32 → embed. Decode reverses it, and the CRC is checked before any password is requested.
The version byte bumps only on a breaking byte-layout change. An old engine refuses a newer PuttyPNG (PTY-E01) instead of emitting garbage. Backwards-compatible growth happens by adding optional keys to the metadata JSON, which old decoders ignore.
When you do not supply your own cover image, PuttyPNG draws one for you. Pick with coverStyle.
A fully opaque square of random pixels, auto-sized to the smallest that fits the data (min 32×32). Invisible embedding, robust everywhere.
A reflective disc drawn in canvas, the flagship example of a JS-generated cover. By default it carries the PuttyPNG "putty splat" branding (a reusable Gak-like silhouette stippled into the surface) and an informational rim (PuttyPNG | size | contents). Add a curved label, replace the splat with your own imprint image, or tune the hub. Auto-sizes from 32×32 up (a little larger than noise, since the transparent corners and spindle hole hold no data). Defaults to subtle depth for a smooth sheen.
By default the CD's corners are transparent, so it drops cleanly onto any background. Turn on solidBackground to fill them with a soft gradient. That makes the disc fully opaque, which both shrinks it (more room for data) and makes it survive being flattened. This matters: if a platform composites a transparent PuttyPNG onto an opaque background, the once-transparent pixels become opaque and shift the data's pixel indexing, so it will not decode. Use a transparent cover only where the image is kept with its alpha intact (lossless PNG); use a solid background (or the noise style) for anywhere that might flatten it.
Every failure is one short, documented code, logged to the console and thrown as a PuttyPNGError whose .code you can branch on. There are two families, and one event always has exactly one code.
Thrown by the engine. This table is generated live from PuttyPNG.errors, so it always matches the engine on the page.
| Code | Meaning |
|---|---|
| Loading... | |
Raised by the page, not the engine, when a drop cannot mean anything. These never reach your code if you call the engine yourself. They stop before a file is handed over, which is why a bad drop cannot disturb a result already on screen.
| Code | Meaning |
|---|---|
| Loading... | |
Engine 2.0.0 renamed every code. The protocol did not change, so every PuttyPNG made with 1.0.0 still decodes. Only code that compared err.code to an old value needs an edit. The number shifted as well as gaining the prefix, so read this table by meaning, not by number: old E06 is not new PTY-E06.
| 1.0.0 | 2.0.0 | Meaning |
|---|---|---|
| Loading... | ||
Skip the drop-in importer/exporter and drive the engine directly however your app needs.
No widgets. Encode and decode in your own flow.
// Export whatever your app holds:
const save = { level: 7, score: 9001, items: ["sword"] };
const png = await PuttyPNG.encode(save, { name: "save.json" });
uploadOrShare(png.blob);
// Import from anywhere you already have the image:
const result = await PuttyPNG.decode(pastedImageElement);
applySave(result.json);
Hand PuttyPNG a picture to hide the data in. By default it is resampled (high quality) to the smallest size that still fits the data. The source resolution is ignored, so a huge photo will not make a huge PuttyPNG. Raise minSize or set a fixed size to keep the art larger; use keepRatio to preserve the original (non-square) shape.
// Smallest square that fits, cropped, fringe auto-hardened:
await PuttyPNG.encode(data, { cover: "logo.png" });
// Keep the picture's original shape, and don't shrink below 256px:
await PuttyPNG.encode(data, { cover: "banner.png", coverFit: "keepRatio", minSize: 256 });
// Lock a fixed size regardless of data:
await PuttyPNG.encode(data, { cover: "logo.png", size: 512 });
The CD style is a canvas drawn in code. You can do the same: draw an ImageData (or a canvas / data-URL) and pass it as the cover. PuttyPNG hardens any transparency and hides the data in the opaque pixels. Use the built-in "cd" style for a ready-made disc.
// Built-in reflective CD with a label + a burned-in imprint:
await PuttyPNG.encode(data, { coverStyle: "cd", label: "My Album", imprint: "logo.png" });
// ...or draw your own cover on a canvas and hand it over:
const c = document.createElement("canvas"); c.width = c.height = 256;
const x = c.getContext("2d");
const g = x.createLinearGradient(0, 0, 256, 256);
g.addColorStop(0, "#1b2a4a"); g.addColorStop(1, "#c96f52");
x.fillStyle = g; x.fillRect(0, 0, 256, 256);
await PuttyPNG.encode(data, { cover: c.toDataURL("image/png") });
Stamp a plaintext tag on export, then peek() on import to route it. No password is needed to know it is yours. Great for versioning an app's own format (a chess game, a doc editor, ...).
await PuttyPNG.encode(move, { tag: "chess v3", password: userPw });
// Later, on any dropped image:
const info = await PuttyPNG.peek(image);
if (!info.isPuttyPNG || !/^chess /.test(info.tag)) return ignore();
const move = (await PuttyPNG.decode(image)).json; // prompts for the password
| Tier | How | Protects against |
|---|---|---|
| Good | No password (default) | Nothing. The data is open |
| Better | A key baked into your page's JS | Casual snooping (keeps honest people honest) |
| Best | A password the user sets/enters | Real protection. Only the password holder can open it |
// Better: a fixed page key (anyone reading your source can find it)
const PAGE_KEY = "our-club-2026";
await PuttyPNG.encode(data, { password: PAGE_KEY });
// Best: a real per-user secret
await PuttyPNG.encode(data, { password: await askUserForPassword() });
The engine never prompts on its own. It calls PuttyPNG.passwordPrompt(context), which by default is the plain browser prompt(). Replace it with a styled modal by assigning your own async function that resolves to the password (or null to cancel). Here is a complete, chic one you can paste in.
PuttyPNG.passwordPrompt = function (context) {
return new Promise(function (resolve) {
const wrap = document.createElement("div");
wrap.style.cssText =
"position:fixed;inset:0;background:rgba(0,0,0,.45);display:flex;" +
"align-items:center;justify-content:center;z-index:9999";
wrap.innerHTML =
"<form style='background:#fff;border-radius:14px;padding:22px;width:300px;" +
"font-family:system-ui;box-shadow:0 20px 60px rgba(0,0,0,.3)'>" +
"<h3 style='margin:0 0 10px'>Password</h3>" +
"<p style='margin:0 0 12px;color:#666;font-size:14px'>" + (context.message || "") + "</p>" +
"<input type='password' autofocus style='width:100%;padding:10px;border:1px solid #ccc;" +
"border-radius:8px;font-size:15px'>" +
"<div style='display:flex;gap:8px;justify-content:flex-end;margin-top:14px'>" +
"<button type='button' data-x style='padding:8px 14px'>Cancel</button>" +
"<button style='padding:8px 14px;background:#111;color:#fff;border:none;" +
"border-radius:999px'>Open</button></div></form>";
const input = wrap.querySelector("input");
function close(value) { wrap.remove(); resolve(value); }
wrap.querySelector("[data-x]").onclick = function () { close(null); };
wrap.querySelector("form").onsubmit = function (e) { e.preventDefault(); close(input.value); };
document.body.appendChild(wrap);
input.focus();
});
};
| Version | Notes |
|---|---|
| Engine 2.0.0 Protocol 1 | Breaking API change. Every engine error code gained the PTY- prefix and renumbered from 00: old E01 is now PTY-E00, through to E12, now PTY-E11. Added PTY-E99 for a failure the engine did not classify. Page-level drop failures use the separate DRP- family. The protocol and the byte layout did not change, so every existing PuttyPNG still decodes. Code that compared err.code to an old value must be updated: see the migration table below. |
| Engine 1.0.0 Protocol 1 | First release. PPNG header with version byte + CRC32; standard & subtle depths; opaque-only embedding with transparency hardening; automatic gzip; AES-256-GCM + PBKDF2-SHA-256 encryption (private by default); plaintext developer tag with peek(); noise or custom covers; E01 to E12 error codes (renamed in 2.0.0, see below). |
From "what is this?" to "it is in my project" in a few minutes.
A PuttyPNG is an ordinary PNG image with your data pressed invisibly into its pixels. Because PNGs are lossless, you can copy, paste, download, and message the image around, and the data rides along untouched. Hand someone the picture; they read the data back. No server, no link, no account.
Type a message and press it into a PNG right here, then read it straight back to see the round-trip.
A drop target can be the entire page or a single bounded area. The box below is a bounded one. Drop a PuttyPNG on it, or click it to browse, and the result opens on the PuttyPNG tab.
A PNG stores its pixels losslessly - every color value survives a copy, a paste, even a trip through many chat apps. PuttyPNG hides your data in the lowest, least-visible bits of the red, green, and blue channels of the image's fully opaque pixels. Nudging those bits shifts each color by an amount your eye cannot see, but the bytes are perfectly recoverable.
A tiny header (PPNG + version + a CRC32 checksum) marks the image and guarantees the data came out exactly as it went in. Descriptive details ride in a small JSON block; the version byte lets the format grow without ever misreading an old PuttyPNG.
Data is gzip-compressed automatically whenever that helps. Add a password and it is sealed with AES-256-GCM (a key stretched from your password with PBKDF2) - the filename and type are hidden too, so an encrypted PuttyPNG reveals nothing until it is opened.
Loading engine source...
When you are ready for more: call the engine directly, add a styled password prompt, use your own cover art, or route your app's own PuttyPNGs with the developer tag. It is all on the Docs tab.
Four small files, no build step, no dependencies.
PuttyPNG is four files that sit next to each other: index.html for the page, styles.css for how it looks, scripts.js for what it does, and puttypng.js for the engine. A fonts folder beside them carries the one display face the page uses, served from the folder rather than over a network. Drop the folder on any static host and it runs. Nothing is compiled, nothing is installed, and nothing ever phones home.
Download puttypng.jsThe engine is the only file you truly need. It carries the whole protocol on its own, with no dependencies. See it in full on the OldPuttyPNG tab.
As a kid you could press a wad of putty onto a comic strip and lift the ink right off, a perfect little copy you could carry in your pocket and hand to a friend. PuttyPNG is that trick for data. Press whatever you have (a note, a form, a game save, a whole file) into an ordinary picture, and hand the picture to anyone. Because so many places let you paste a lossless PNG without mangling it, the picture becomes a friendly, universal envelope: no server, no link that expires, no account to sign into. An image that carries exactly what you put in it.
PuttyPNG is made by Aaron Michael Harris. It is a fun, open project. Use it, remix it, decorate your PuttyPNGs however you like, and build your own things on top of the protocol.
PuttyPNG is released under the MIT License.