PuttyPNG

Press your data into a PNG. Hand it to anyone.

Make

Load

Drop an attachment here!
0 bytesof 45.6 KB
256 disc
Drop a PuttyPNG here
Or right-click and paste from your clipboard!
Let go
Decoding...

              
Drop another PuttyPNG
Your PuttyPNG. Drag it into Load, or press the bin to throw it away.

What you'd like to encode

Type or paste anything, then press it into a PNG you can hand to anyone.

0 bytes
Cover style

Docs

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.

API reference

Everything hangs off the global PuttyPNG. Both encode and decode return promises.

PuttyPNG.encode(input, options?)

await PuttyPNG.encode(data, { password, depth, compress, tag, cover, ... }) → result

Hides input (a string, plain object, Uint8Array, ArrayBuffer, or File/Blob) inside a PNG.

OptionDefaultMeaning
passwordnoneIf 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).
compresstrueAuto gzip, kept only when it makes the data smaller.
tag""Plaintext developer tag, readable later via peek() without decrypting.
covernoneA 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.
sizeautoFixed 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.
minSize256Floor 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).
solidBackgroundfalseCD only: fill the corners with a soft gradient instead of leaving them transparent. Fully opaque → smaller disc and survives being flattened onto a background.
imprintnoneCD only: an image (File/Blob/URL/Image) etched onto the disc as a grayscale stipple, beneath the label.
fontFamilysystemCD 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).
rimTextautoCD 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.
rimSize13CD 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.
rimSpacing0CD only: extra space after each rim letter, in px on a 256px disc, scaled the same way. Negative values tighten the text.
rimTwoSidedfalseCD 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.
splatbrandingCD 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.
hubroundCD 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

PuttyPNG.decode(source, options?)

await PuttyPNG.decode(pngSource, { password, autoDownload }) → result

source may be a File/Blob, an <img>, a <canvas>, an ImageData, or a data-URL / URL string.

OptionDefaultMeaning
passwordnoneNeeded for encrypted PuttyPNGs. If omitted, passwordPrompt is called.
autoDownloadfalseIf 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);

PuttyPNG.peek(source)

await PuttyPNG.peek(pngSource) → { isPuttyPNG, version, encrypted, compressed, depth, tag, payloadBytes }

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 */ }

PuttyPNG.download(result, filename?) · PuttyPNG.passwordPrompt

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 PuttyPNGWhat it is
PuttyPNG.versionEngine version string (e.g. "1.0.0").
PuttyPNG.protocolVersionProtocol version number (1).
PuttyPNG.errorsThe { "PTY-E00": "...", ... } code table (see below).
PuttyPNG.selfTest()Runs the built-in test battery; returns { passed, failed, results }.

Protocol v1

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.

Fixed header: 18 bytes, always at 1-1-1 depth (first 48 opaque pixels)

OffsetSizeField
04Magic PPNG
41Protocol version (decoders reject a higher number → PTY-E01)
51Flags: bit0 compressed, bit1 encrypted, bit2 subtle depth
62Developer-tag length
82Outer-metadata length
104Payload length
144CRC32 of the payload (as embedded)

Body: at the flag-declared depth

[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.

Inner container (protected content)

[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.

Depths, opacity & transparency

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.

Order of operations

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.

Versioning

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.

Cover styles

When you do not supply your own cover image, PuttyPNG draws one for you. Pick with coverStyle.

Noise

A fully opaque square of random pixels, auto-sized to the smallest that fits the data (min 32×32). Invisible embedding, robust everywhere.

CD

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.

Transparent vs solid, and a flattening caveat

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.

Error codes

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.

Engine errors: PTY-

Thrown by the engine. This table is generated live from PuttyPNG.errors, so it always matches the engine on the page.

CodeMeaning
Loading...

Page errors: DRP-

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.

CodeMeaning
Loading...

Moving from 1.0.0

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.02.0.0Meaning
Loading...

Recipes

Skip the drop-in importer/exporter and drive the engine directly however your app needs.

Call the engine directly

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

Your own cover art

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

Generate your own cover in JS

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

Recognize your own PuttyPNGs with the dev tag

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

Encryption tiers: good, better, best

TierHowProtects against
GoodNo password (default)Nothing. The data is open
BetterA key baked into your page's JSCasual snooping (keeps honest people honest)
BestA password the user sets/entersReal 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() });

A custom password prompt

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

Changelog

VersionNotes
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).

How it works

From "what is this?" to "it is in my project" in a few minutes.

1. What a PuttyPNG is

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.

2. Make your first one

Type a message and press it into a PNG right here, then read it straight back to see the round-trip.

3. A whole page, or one zone

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.

Drop a PNG here
or paste with Ctrl/Cmd+V · or click to browse [...]
Under the hood No server and no magic. Clever use of an ordinary lossless PNG.

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.

The protocol

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.

Compression & encryption

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.

4. Drop it into your own project Three copy-paste steps. No build tools, no dependencies, works offline.
  1. The Engine: save puttypng.js and include it.
  2. The Importer: a drop/paste/browse zone that hands decoded data to your callback.
  3. The Exporter: a box and a button that make a PuttyPNG.
1 The Engine
Loading engine source...
Save as puttypng.js and include it, or paste the whole thing into a <script> tag. This is the entire protocol - the only file you truly need. Download puttypng.js
2 The Importer

            
A drop / paste / browse zone. Everything decoded is handed to your onData callback - your form, game, or app decides what to do with it.
3 The Exporter

            
A box and a button that turns whatever the user types into a downloadable PuttyPNG. See the Docs tab for encryption, custom covers, and calling the engine directly.

Going further

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.

Download

Four small files, no build step, no dependencies.

Take the whole thing

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.js

The 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.

About

The idea

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.

The author

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.

License

PuttyPNG is released under the MIT License.

PuttyPNG · Protocol v1 · MIT © 2026 Aaron Michael Harris