Sometime in the last couple years, MS Paint started adding some new features that were interesting- among them was the ability to work with image layers. I had a nice laugh when I discovered that yes, you can add layers to your Paint project but as soon as you want to save the file, it required converting back to non-layer supporting formats and thus flattening your image. Hilarious.
So I was glad to see more recently that MS Paint added the ability to *actually save* files with layers, using a new .paint file type accessed by the new “save as a project” button. This now elevates the historically very basic Paint app to… a still basic app but now with full stack ordered, transparency/alpha channel layers.
Since Paint had never had a proprietary file format in the past, I was curious if they invented a whole new format for this project file type, and if it could be utilized by other applications- specifically something like Paint.Net. I initially assumed it was some form of renamed zip file archive of ordered png images under the hood, but some quick analyses showed this was not the case.
So I decided to dig deeper.
The end result is open source on GitHub: mspaintlib — a .NET library that reads and writes .paint, and also includes a Paint.NET v5 file-type plugin that uses the library to enable importing and exporting .paint files from/to Paint.Net. Files written by the library re-open in MS Paint with all layers intact. There’s also a full format spec in the repo. But first, the fun part.
“It’s just HEIF”
Checking the hex view of a sample .paint file showed:
00 00 00 20 66 74 79 70 6D 69 66 31 ...size = 0x20 type = "ftyp" "mif1"
This turned out to not a proprietary format, but a ISOBMFF box. ISOBMFF (ISO/IEC 14496-12) is the base container format under MP4, MOV, HEIC, and basically everything Apple-or-camera-shaped. The mif1 brand is HEIF’s “image” profile. In other words: MS Paint’s new format is HEIF.
Specifically, it’s a small subset of HEIF:
| Layer | Standard | What it gives you |
|---|---|---|
| Container | ISO/IEC 14496-12 | The box-tree on-disk structure |
| HEIF | ISO/IEC 23008-12 | Image items, derivations, references |
| MIAF | ISO/IEC 23000-22 | Application constraints (cameras, etc.) |
| Pixel storage | ISO/IEC 23001-17 | Uncompressed image items (unci) |
The interesting part is that last row. ISO/IEC 23001-17 — “uncompressed video/images in ISOBMFF” — is what Paint uses for the pixel data. Not HEVC, not AVIF, not any compressed image codec. Each layer is a flat RGBA buffer with a generic compressor (DEFLATE) wrapped around it. That’s why you can write a working .paint reader without libheif or libheif-rs or any codec at all: there’s no codec to decode, just DeflateStream.
The box tree
Inside meta, MS Paint emits this hierarchy:
ftyp major=mif1, compat=[mif1, gcmi, isoa, miaf]meta
├─ hdlr handler = "pict"
├─ pitm primary_item_id = <iovl item id>
├─ iinf N item info entries (one per layer + one for the iovl)
│ └─ infe v2 type=unci for each layer
│ └─ infe v2 type=iovl for the composition
├─ iloc v1 (32-bit offsets and lengths)
├─ iprp
│ ├─ ipco shared properties:
│ │ ├─ cmpC compression_algorithm = "defl"
│ │ ├─ ispe width, height
│ │ ├─ colr nclx primaries=1 (sRGB), transfer=13, matrix=0
│ │ ├─ cmpd 4 channels: 4=R, 5=G, 6=B, 7=Alpha
│ │ ├─ uncC profile="gene", pixel-interleaved, 8-bit
│ │ └─ pixi 4 channels × 8 bits
│ └─ ipma per-item property associations
├─ iref
│ └─ dimg from iovl, to_items = [layer1, layer2, ...] in z-order
└─ idat inline data for the iovl item
free padding (variable size)
mdat layer pixel data (concatenated DEFLATE streams)
Each layer is a unci (uncompressed) image item. The composition — what defines “this is layer 1 below layer 2 below layer 3, all on a 333×271 canvas with a white background” — is an iovl (image overlay) item. It references each layer via an iref dimg (derived image) link.
The actual pixel bytes for each layer live in mdat, addressed by iloc extents. The 30-byte iovl payload — canvas size, fill color, per-layer offsets — lives inline in idat. That’s it. Walk the boxes, resolve the references, inflate the DEFLATE streams. You have a multi-layer image.
Two gotchas
You’d expect a bunch of “first try” mistakes when reverse-engineering an ISO standard from a single sample, and yes, there were some. Most of them were boring. Two were interesting.
defl ≠ zlib
The cmpC property declares the compression algorithm as a four-byte tag. MS Paint writes "defl". In .NET, the natural reach is for ZLibStream — zlib contains DEFLATE, after all. Wrong. "defl" per ISO/IEC 23001-17 means raw DEFLATE (RFC 1951), no zlib header. Use DeflateStream, not ZLibStream. They differ by a 2-byte header and a 4-byte Adler-32 checksum at the end. Try to inflate one with the other and you get “unexpected end of stream” or “invalid block type” with no hint that you’re using the wrong wrapper. Lost some time on this one.
The pixels are BGRA, not RGBA
This one cost more.
The cmpd property declares four “components” with channel types 4=R, 5=G, 6=B, 7=Alpha. In that order. So you’d reasonably assume each pixel is four bytes: R, G, B, A. I wrote a parser that decoded a layer to a 333×271 RGBA buffer, and then I noticed everything in the test image was unmistakably blue-tinted instead of red.
The trick is uncC, which sits next to cmpd in the property table. It declares each on-disk component slot as a component_index referring back to a cmpd entry. So the four bytes of a pixel are:
- byte 0 → cmpd[uncC.component[0].component_index]
- byte 1 → cmpd[uncC.component[1].component_index]
- byte 2 → cmpd[uncC.component[2].component_index]
- byte 3 → cmpd[uncC.component[3].component_index]
In MS Paint’s output, the indices are [2, 1, 0, 3]. Translation: byte 0 points to cmpd[2] which is B. Byte 1 → G. Byte 2 → R. Byte 3 → A. The pixels on disk are BGRA.
You can imagine why: BGRA matches the byte order of pretty much every Windows GDI surface, every Direct2D bitmap, every Paint.NET Surface. MS Paint is a Win32-shaped program; its in-memory format is BGRA; the serializer just dumps memory and lets cmpd+uncC describe the order. That’s a perfectly reasonable design. It’s also why a parser that reads the cmpd listing alone and assumes that’s the byte order will produce red-and-blue-swapped images for everyone using this format. The tell — “the cmpd channel registry is not the same thing as the on-disk byte order” — is the kind of thing you only learn by trying.
I updated the spec. The reader now builds a swizzle table from uncC.component_index → cmpd channel and converts to whatever output order it wants — RGBA in the public API. The writer goes the other way.
Writing one
Reading a format is half the job; if you can’t write it back, your library is read-only and your plugin doesn’t help when someone wants to create a .paint. So I built a writer that mirrors MS Paint’s exact box layout — same item IDs, same property assignments (the iovl item gets ispe and pixi; layer items get all six properties), same iref dimg structure, same mdat ordering.
The one mildly annoying bit is iloc. It tells the reader where each item’s bytes live in the file: mdat offset and length per layer. But you can’t write iloc until you know how big mdat is going to be — and you can’t know mdat‘s size until you’ve compressed all the layers. And you don’t know mdat‘s file offset until you know the size of the meta box that contains iloc. There’s a circular dependency.
The shape that resolves it cleanly:
- Compress all the layers in memory. Now you know all extent lengths.
- Write
metato aMemoryStreamwith placeholder zeros for the layer offsets, but record where in the buffer those zeros live. - Now you know
meta‘s size. Compute the absolute offset wheremdat‘s data will start:ftyp_size + meta_size + free_size + mdat_header_size. - Patch the recorded offset positions in the buffer.
- Emit
ftyp→meta→free→mdatto the output stream.
A library that emits files MS Paint can read is one round-trip away from proving itself: load a real .paint, save it back out, open the result in MS Paint. It works. Cool!
The Paint.NET integration, and one philosophical question
The Paint.NET v5 plugin API is nicely built. You inherit from FileType, you set LoadExtensions and SaveExtensions in FileTypeOptions, you override OnLoad(Stream) and OnSave(...). The plugin builds against five Paint.NET assemblies (referenced via a PaintDotNetPath MSBuild property in Directory.Build.props, defaulting to C:\Program Files\paint.net\), and gets dropped into Paint.NET’s FileTypes\ folder.
The interesting design question shows up in the iovl payload. It includes a 4-channel canvas fill color– this is the background you see in MS Paint when a layer’s pixels are transparent. In MS Paint that fill is a document property: visible behind transparent layer pixels, but not editable as a layer. In other words- the “Background” layer in MS paint is not an actual layer, but a fake layer at the bottom of the stack that cannot contain drawing data- just a selectable solid color.
Paint.NET, and most other layer-based graphical apps, have no equivalent. Its canvas under the bottom layer is the familiar transparent checkerboard. There are a few options:
- Render the fill in Paint.NET by synthesizing an extra non-editable bottom layer. This matches MS Paint visually but adds something the user can’t really interact with. And on save, you’d have to detect “is the bottom layer a solid color?” to convert back to a canvas fill — a fragile heuristic.
- Synthesize an editable bottom layer. Matches visually, but the user could draw on it, and round-trip back to MS Paint would have to collapse it back to a fill (or lose the edits). Also fragile.
- Don’t render it; preserve the value somewhere safe so it survives a round-trip.
I went with option 3. On load, the plugin stashes the canvas fill color in Paint.NET’s Document.CustomHeaders (a free-form metadata string the user typically doesn’t see). On save, it reads it back. So:
- MS Paint → Paint.NET: the background looks transparent in Paint.NET, but the color is preserved.
- Paint.NET → MS Paint: write back, MS Paint shows the original background color again.
The full path — make a .paint in MS Paint, open it in Paint.NET, edit a layer, save it as .paint, open in MS Paint, see your edits with the right background — works.
I’m aware this may not be the preferred option to use, and I’m open to discussion on better ways to handle this.
What’s still open
The spec is written from a small number of samples. Three things remain unknown:
- Layer names. Our samples have empty
infe.item_namestrings. MS Paint’s UI lets you rename layers, but I haven’t yet seen a sample with renamed layers to confirm where the name ends up. Could be ininfe.item_name, in audtauser-data box, or in an extension property. - Per-layer visibility / opacity. Same story. The UI exposes them but I don’t know yet how they’re encoded.
- Layers smaller than the canvas. Per spec, each
unciitem carries its ownispe(image spatial extent), so they could be smaller than the canvas. Our samples are all canvas-sized, so the writer currently rejects non-canvas-sized layers with aNotSupportedException. Easy to lift once needed.
If you have a sample exhibiting any of these, drop it as an issue on the repo. It’ll either confirm a guess or surface another interesting wrinkle.
Tools and links
- The library + Paint.NET plugin: github.com/setiri/mspaintlib
- Format spec:
PAINT-FORMAT-SPEC.md - A
.paintbox-tree dumper (handy if you’re reverse-engineering something similar):dotnet run --project tools -- dump path/to.paint
The library is pure managed .NET 8 — no native dependencies, no libheif. The plugin targets net9.0-windows to match Paint.NET v5’s runtime.
If MS Paint changes its format later, the library will throw a PaintFormatException rather than silently misparse, and the spec will need a v0.3.
If you build something interesting on top of the library — an importer for GIMP or Krita, a CI step that flattens .paint to PNG, a server-side thumbnailer — I’d love to hear about it.


