AssetHoard AssetHoard
RoadmapReleasesBlogDocsDownloadBuy
← All Posts
June 11, 2026

Reading Paint.NET Files Without Paint.NET

The Request

A user pinged us on Discord:

the free program Paint.NET is like super-charged MS paint with layers, been using it for graphic design for years. its .pdn files also have thumbnails in Windows, on account of a Shell extension

In AssetHoard, .pdn files were landing in the library as generic blank tiles. No thumbnail, no dimensions, no layer count. For a user with a folder full of Paint.NET work, that means the grid view is useless: every asset looks identical. The fix is obvious in principle (support the format) and not so obvious in practice (Paint.NET's file format is a serialised .NET object graph, and we are not a .NET app).

This is the story of how we shipped real thumbnails for .pdn files in a few hundred lines of Rust, by piggybacking on a trick Paint.NET itself uses for the Windows Explorer preview.

What's Actually In A .pdn File

Most game-dev file formats are documented. GLTF has a spec. PSD has a spec. Aseprite publishes their format. Paint.NET is closed-source from v4 onwards, and even when v3.x was open-source, the "format" was really just .NET's BinaryFormatter serialiser dumping the in-memory Document object straight to disk. There's no neutral binary layout. To read the bitmap data you basically need to reconstruct the .NET runtime's idea of what a Document looks like.

But there's a wrinkle. The Paint.NET team needed Windows Explorer to render thumbnails. Spinning up the .NET runtime to deserialise a Document just so Explorer can draw a 96-pixel icon is absurd, so they did something cleverer: they prepend a small XML header to every .pdn file with all the cheap stuff. Dimensions, layer count, and crucially a base64-encoded PNG preview of the image.

The shell extension reads the XML, decodes the PNG, hands it to Explorer. The actual document data sits untouched after the header. We can do the exact same thing.

The Bytes

Here are the first eight bytes of a real .pdn file saved from Paint.NET v5.109, dumped as hex:

50 44 4e 33 10 04 00 3c
P  D  N  3  └──┬──┘ <
            xml length

Decoded:

Bytes 0-3:   "PDN3"                          magic
Bytes 4-6:   0x10 0x04 0x00 = 1040 (LE u24)  XML preamble length
Byte 7+:     <pdnImage ...                   XML preamble starts here

The 24-bit little-endian length is unusual. Most formats use 16 or 32 bits. Paint.NET went with 24, which is enough for a roughly 16MB XML header. You will never hit that limit. Our test fixtures have headers in the 1KB to 17KB range.

After reading those 1040 bytes of XML, you get this:

<pdnImage
  width="980"
  height="980"
  layers="1"
  savedWithVersion="5.109.9343.2610">
  <custom>
    <thumb png="iVBORw0KGgoAAAANSUhEUgAAA..." />
  </custom>
</pdnImage>

Everything we want is right there. Width, height, layer count, the Paint.NET version that saved the file, and a base64-encoded PNG thumbnail. The iVBORw0KGgo prefix is what 0x89 P N G looks like in base64, so we know the embedded payload is a real PNG.

After the XML, the rest of the file is the .NET BinaryFormatter blob, usually gzip-compressed (you can spot it by the 0x1F 0x8B marker right after the XML ends). That blob holds the full-resolution layer pixel data, blend modes, layer names. We don't touch it. Parsing NRBF (the .NET Remoting Binary Format protocol) from Rust would mean either porting hundreds of lines of deserialisation code from pypdn, or shelling out to a .NET runtime. Both are huge effort for a feature most users will never notice.

The Implementation

The whole handler is about 200 lines. The interesting part is the preamble reader:

fn read_xml_preamble(path: &Path) -> Result<String, HandlerError> {
    let mut file = File::open(path)?;
    let file_size = file.metadata()?.len();

    if file_size < 7 {
        return Err(HandlerError::ParseError(
            "PDN file shorter than 7-byte header".to_string(),
        ));
    }

    let mut header = [0u8; 7];
    file.read_exact(&mut header)?;

    if &header[0..4] != b"PDN3" {
        return Err(HandlerError::ParseError(
            "Not a PDN3 file - missing magic bytes".to_string(),
        ));
    }

    let xml_len = u32::from(header[4])
        | (u32::from(header[5]) << 8)
        | (u32::from(header[6]) << 16);

    let max_xml_len = file_size.saturating_sub(7);
    if u64::from(xml_len) > max_xml_len {
        return Err(HandlerError::ParseError(format!(
            "PDN XML preamble length {} exceeds remaining file size {}",
            xml_len, max_xml_len
        )));
    }

    let mut xml_buf = vec![0u8; xml_len as usize];
    file.read_exact(&mut xml_buf)?;

    String::from_utf8(xml_buf)
        .map_err(|e| HandlerError::ParseError(format!("PDN preamble is not valid UTF-8: {e}")))
}

A few defensive details worth calling out:

Stack-allocated header. let mut header = [0u8; 7] is a fixed-size array on the stack. No heap allocation for the seven bytes we read first. We only allocate the Vec<u8> for the XML body after we've validated the length looks sane.

Bounds checking the length. The 24-bit length field is attacker-controlled in the sense that a malformed file could claim a 16MB preamble even when only a few KB exist on disk. We compare the declared length to the actual remaining file size before allocating. Without this check, a crafted file could trick us into allocating 16MB for what's actually a 100-byte file.

UTF-8 validation. String::from_utf8 returns a Result. If the preamble isn't valid UTF-8 (shouldn't ever happen for a real Paint.NET file, but a corrupt or hostile file might present otherwise), we return a clean error instead of producing garbled output.

Once we have the XML string, the metadata extraction is straightforward:

fn extract_metadata(&self, path: &Path) -> Result<serde_json::Value, HandlerError> {
    let xml = Self::read_xml_preamble(path)?;
    let doc = roxmltree::Document::parse(&xml)
        .map_err(|e| HandlerError::ParseError(format!("Failed to parse PDN preamble: {e}")))?;

    let pdn_image = doc
        .descendants()
        .find(|n| n.tag_name().name() == "pdnImage")
        .ok_or_else(|| HandlerError::ParseError(
            "PDN preamble missing <pdnImage> root".to_string()
        ))?;

    let width = pdn_image.attribute("width").and_then(|v| v.parse::<u32>().ok());
    let height = pdn_image.attribute("height").and_then(|v| v.parse::<u32>().ok());
    let layer_count = pdn_image
        .attribute("layers")
        .and_then(|v| v.parse::<u32>().ok())
        .unwrap_or(0);
    let saved_with_version = pdn_image.attribute("savedWithVersion").map(String::from);

    // ... package into JSON
}

We use roxmltree, which was already a dependency for our Krita handler (Krita files have a similar XML manifest tucked inside a zip archive). No new crates needed.

Thumbnail extraction follows the same shape: parse the XML, find the <thumb> element, decode the base64 PNG, hand it to the image crate for resizing. The whole thumbnail path is about 30 lines.

Test Fixtures

A handler is only as trustworthy as the files you test it against. We hit the standard problem here: how do you write tests for a proprietary format without a sample file?

Our first move was the pypdn project on GitHub. It's an MIT-licensed Python reader and ships with .pdn test fixtures from Paint.NET 3.5.10 and 4.21. Useful, but those are old versions. Paint.NET v5 is a major architectural rewrite (it moved to GPU-accelerated rendering with Direct2D), and we had no proof the file format hadn't moved with it.

We asked the user who filed the ticket. They sent three v5-saved files: a single-layer drop shadow study, a 14-layer HUD mockup, and a concept-art piece at v5.108. All three opened cleanly with our parser. All three had identical header structure to the v3.5 and v4.21 samples from pypdn. The format has been stable for over a decade.

That was the moment we knew we could ship.

We checked the smaller two files into src-tauri/tests/fixtures/ as test.pdn and test_many_layers.pdn. The unit tests assert real numbers from real files: 980x980 single-layer, 969x550 with 14 layers, both saved with Paint.NET versions starting with 5..

What We Deliberately Didn't Do

Three things we left on the floor on purpose.

Layer extraction. We can report the layer count (because it's in the XML preamble) but not layer names or blend modes (those are in the BinaryFormatter blob). We could parse the blob, but it's a huge engineering investment for a feature only power users would notice. The Krita and PSD handlers do surface layer names, and the PDN file details panel will look slightly thinner by comparison. We considered showing an empty layer list panel for PDN files, then realised that looks broken. Better to show nothing than to show a panel with placeholder text.

Export as PNG. PSD and Krita both expose an "Export as PNG" context action because their handlers can flatten the document. We can't, because the composite pixels live in the BinaryFormatter blob. The embedded thumbnail is the wrong size and quality for a real export. So we left the action off entirely. Consistent absence is less confusing than a broken or low-quality option.

Render profile auto-classification. Some image handlers run a heuristic to guess whether an image is pixel art (use nearest-neighbour resizing) or a photo (use bilinear). For PDN we'd be running that heuristic on the embedded preview thumbnail, which is small and downsampled. The verdict would be unreliable, and a wrong verdict makes the preview look worse than no auto-classification at all. We skip it and let users override via the render profile UI if they care.

The Cross-Platform Bonus

One side effect of parsing the file ourselves rather than calling out to the Windows shell extension: PDN thumbnails now work even without Paint.NET installed. On macOS or Linux, the .pdn icon in Explorer would just be a generic file. In AssetHoard, you get the real thumbnail because we're reading the bytes directly. That makes AssetHoard a better PDN viewer than Windows Explorer for anyone who's been handed a .pdn file by a collaborator and doesn't have Paint.NET on their machine.

The Takeaway

Sometimes a feature that looks like it needs a major engineering investment ("parse a closed-source .NET serialisation format") turns out to have a back door that the format's own designers already built in. The Paint.NET team needed Windows Explorer thumbnails to work without spinning up .NET, so they prepended a small XML manifest with the cheap stuff. That manifest is exactly what we needed.

Total work: about a day, including format research, fixture sourcing, the handler itself, and seven new unit tests. The result is that .pdn files now sit alongside .psd and .kra in the library, with thumbnails, dimensions, and version metadata.

If you're building something that needs to read a proprietary format, take a hard look at what the format's official tooling already does cheaply. The hard work might already be done.

Previous CC0, CC-BY, MIT: A Game Developer's Guide to Asset Licences
AssetHoard AssetHoard

The asset manager for indie game devs and artists

Product

Buy AssetHoard Local-first Documentation Quick Start Guide Roadmap Release Notes Forgot License?

Platforms

Unity Godot

Community

Discord Mastodon Bluesky Subscribe to updates

Company

Contact Careers Press Kit Privacy Policy

© 2026 AssetHoard. All rights reserved. Made in Melbourne 🇦🇺