PuttyPNG

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

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. What it promises depends on the cover. Noise uses it exactly and raises PTY-E04 if the data will not fit. CD treats it as a starting size and grows past it when the data will not fit, so set maxSize to the same value to pin it. Custom uses it exactly, except that it is still raised to 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. The noise and CD covers let an explicit size go below this floor. A custom cover is still raised to it.
maxSize4096Ceiling for the finished image (px). It caps auto-sizing, and it is also what stops the CD cover growing past an explicit size. Past it the engine raises PTY-E04 rather than making a larger picture.
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: set the disc in a light grey jewel case, lying on white, instead of leaving the corners transparent. Every pixel opaque → the whole square carries data, and it survives being flattened onto a background. The page turns this on by default.
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

On its own the CD has transparent corners, so it drops cleanly onto any background. Turn on solidBackground and the disc is set in a light grey jewel case instead, lying on white paper that runs to the corners. Every pixel of the square becomes opaque, so the whole square carries data (a 256px cover holds 65,425 bytes rather than 46,635) and the picture survives being flattened. The page turns it on by default, and the Solid switch under the Make button turns it off: the word reads Solid while it is on and See-thru while it is not. 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. These are the 13 codes in PuttyPNG.errors. A gate check compares this table against the engine, so the two cannot drift apart.

CodeMeaning
PTY-E00Not a PuttyPNG (magic not found)
PTY-E01Protocol version is newer than this engine can read
PTY-E02Data is corrupted (checksum or structure mismatch)
PTY-E03This browser cannot decompress (CompressionStream missing)
PTY-E04Data is too large for the cover image or the size cap
PTY-E05Wrong password, or the data could not be decrypted
PTY-E06Encryption is not available (Web Crypto missing)
PTY-E07A password is required but none was provided
PTY-E08Invalid input or options
PTY-E09The image could not be loaded or read
PTY-E10The cover image has too few opaque pixels to use
PTY-E11The developer tag is too long (max 65535 bytes)
PTY-E99The engine failed in a way it did not classify

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
DRP-E00That is not a file.
DRP-E01That is a folder. Drop a single file.
DRP-E02One file at a time.
DRP-E03That file is empty.
DRP-E04That file is too large to read here.
DRP-E05That PNG could not be opened as an image.
DRP-E06Wait for the current drop to finish.
DRP-E99That drop failed for an unknown reason.

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.1.0
Protocol 1
The solid background became a jewel case. solidBackground now sets the disc in a light grey case lying on white paper, instead of filling the corners with a soft gradient. Every pixel of the square is opaque, the spindle hole included, so a 256px cover carries 65,425 bytes rather than about 64,348. The disc itself is drawn a little smaller so the case shows around it, which costs no room because the case carries data too. No function signature changed and the byte layout did not move, so every existing PuttyPNG still decodes.
Engine 2.0.0
Protocol 1
First release. PPNG header with a version byte and CRC32; standard and subtle depths; opaque-only embedding with transparency hardening; automatic gzip; AES-256-GCM with a PBKDF2-SHA-256 derived key, private by default; a plaintext developer tag readable with peek(); CD, noise, or custom covers; error codes PTY-E00 to PTY-E11, plus PTY-E99 for a failure the engine did not classify. Page-level drop failures use the separate DRP- family.