Skip to content

feat(image): read and write Paint.NET .pdn files, keeping layers with PSD - #1384

Merged
KnifeLemon merged 6 commits into
KnifeLemon:mainfrom
Ugoyo:claude/confident-fermat-rwyqkw
Nov 12, 2026
Merged

KnifeLemon merged 6 commits into
KnifeLemon:mainfrom
Ugoyo:claude/confident-fermat-rwyqkw

Conversation

@Ugoyo

@Ugoyo Ugoyo commented Nov 11, 2026

Copy link
Copy Markdown

What does this change?

Filee reads and writes Paint.NET's own .pdn files, with its own reader and writer (Filee.Engines/Magick/PaintNet.cs, PaintNetWriter.cs). The document part is .NET binary serialization. It is read as plain data with System.Formats.Nrbf, which never creates the types it names. It is written record by record, in the shape Paint.NET 4.2 saves, which every later Paint.NET opens.

  • PDN → PNG, JPG, WebP, PDF, …: the visible layers are merged with Paint.NET's own blend formulas (all 14 modes, layer opacity).
  • PDN → PSD/PSB keeps the layers: names (also stored as Unicode luni), opacity, visibility and the blend modes both programs share, with the merged picture first. ImageMagick's PSD writer puts visibility in the wrong flag bit and locks transparency on every layer, so SetLayerFlags sets the flags after writing.
  • Any picture → PDN: one layer named Background.
  • PSD/PSB → PDN keeps the layers: each pixel layer at its place on the canvas. Names are read from the file's own records (PsdLayerRecords: the Unicode name, or the Mac Roman one), because ImageMagick damages any name that is not ASCII.
  • A preset that changes the picture (resize, rotate, …) gives one merged picture.
  • .pdn is graded specialist in Design & print, like .kra. Settings schema 79 teaches the images donut .pdn (renumber if another change takes 79 first).

Type of change

  • New format or engine
  • Documentation
  • Build, CI or dependencies (System.Formats.Nrbf 10.0.14, MIT, dotnet/runtime)

How was it tested?

  • dotnet format --verify-no-changes on the changed files
  • dotnet test: new PaintNetTests (Engines; .pdn files built by PdnBuilder) and PaintNetFormatTests (Core) pass. The same 18 Engines tests and 1 Core test fail on main in my Linux environment (missing Pandoc/FFmpeg/LibreOffice, running as root). App tests were not run: the headless suite hangs there.
  • Real Paint.NET 4.2 files (pypdn's MIT samples): the PNG matches the thumbnail Paint.NET embeds. The PSD checked with psd-tools has the right names, opacity, blend modes and visibility, and no lock. PDN → PSD → PDN gives the same layers, pixel for pixel. pypdn reads the written files.
  • Tried in the app: a PSD → PDN file with 4 layers (accented names, Multiply 70 %, a hidden layer) opens in Paint.NET on Windows with every layer as expected.

Checklist

  • New UI strings are in all src/Filee.App/Assets/i18n/*.json files (none added)
  • New third-party code or binaries are listed in THIRD-PARTY-NOTICES.md
  • The PR does one thing

Paint.NET's own format is read by Filee itself: the document (layer names,
visibility, opacity, blend mode) is .NET binary serialization, decoded as
plain data with System.Formats.Nrbf (no type is ever instantiated), then
each layer's BGRA pixels come in gzip-compressed chunks.

- PNG, JPG, WebP, PDF and every other picture target get the visible
  layers merged with Paint.NET's own blend formulas (all 14 modes, layer
  opacity), so the picture looks as it does in Paint.NET.
- PSD/PSB keeps the layers for Photoshop, GIMP and Krita: names (also as
  Unicode "luni"), opacity, visibility and the blend modes Photoshop
  shares; the merged picture comes first. ImageMagick's PSD writer puts
  visibility in the wrong flag bit and locks transparency, so the flags
  are set afterwards. A preset that changes the picture gives one merged
  picture instead.
- .pdn is read only, graded specialist in Design & print like .kra.
  Settings schema 79 teaches the images donut .pdn; custom claims stay put.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o
Filee now writes PDN too (PaintNetWriter), in the shape Paint.NET 4.2 saves
and every later version opens: the same NRBF records and class metadata, the
256 KB gzip chunks and a thumbnail in the header.

- Any picture becomes a .pdn of one layer named Background.
- A PSD/PSB keeps its pixel layers, each at its place on the canvas, with its
  name, opacity, visibility and the blend modes Paint.NET shares. Layer names
  are read from the file's own records (PsdLayerRecords: the Unicode name, or
  the Mac Roman one), as ImageMagick damages any that is not ASCII.
- PDN -> PSD -> PDN gives back the same layers, pixel for pixel.
- PDN is now a target, shown where the Design & print field is on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o
@Ugoyo
Ugoyo requested a review from KnifeLemon as a code owner November 11, 2026 10:30
@github-actions github-actions Bot added the feature New features label Nov 11, 2026

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for this. A PDN reader and writer of our own, with layers kept both ways and checked in Paint.NET itself, is a big piece of work, and reading the serialized part with System.Formats.Nrbf is exactly the right call.

A few things to change:

  1. "Merge images" breaks on .pdn files. CanWrite now says yes to pdn, and IImageJoiner.CanWrite is the same method. The built-in "Merge images" preset is "same as source", so when the first file dropped is a .pdn, JobQueue picks pdn as the target. ImageJoiner.Join then calls ImageEncoder.WriteImage(merged, …, "pdn"), and ToMagickFormat throws "ImageMagick cannot handle 'pdn'". The FitToSize call after it would throw too. Could you either write the merged picture with PaintNetWriter.Write in ImageJoiner, or keep pdn out of what the joiner can write, so it falls back to PNG? A test with two .pdn files on the merge preset would cover it.
  2. Check a pixel budget before allocating. The reader caps the canvas only at Array.MaxLength. ReadFlattened and ReadLayer allocate the full canvas and the full layer before reading any chunk, so a tiny file whose header claims 23,000 × 23,000 allocates about 4 GB before it fails. With chunkSize = 1, seen becomes a 2 GB array too. For PDN → PSD, every layer stays in memory, and with up to 4,096 layers that compress to almost nothing, a small file can ask for a lot. Could you check layers × width × height against one limit before the first allocation (something in the range of FITS' 256 M samples), and refuse a chunkSize that is unreasonably small?
  3. Small: ImageEncoder.MaxSide could get "pdn" => 65_535. Then a very wide picture is scaled down to fit, as JPG and WebP are, instead of failing in PaintNetWriter.Write.
  4. Small: PsdLayerRecords.Read catches only EndOfStreamException. Past the end of the file, reader.ReadBytes(4) returns fewer bytes and ReadUInt32BigEndian throws ArgumentOutOfRangeException, so an unusual PSD fails PSD → PDN instead of using ImageMagick's names. Catching that as well, or reading with ReadExactly, would keep the fallback working.

We'll take care of the settings schema number when we merge (79 is wanted by a few open PRs), and of the specialist count together with #1390. CI hasn't run yet because first-time PRs need a maintainer to approve the workflow. We'll start it.

…ords

From the review of this PR:

- "Merge images" keeps the first file's format, so two .pdn merge into a
  .pdn: ImageEncoder.WriteImage writes PDN with PaintNetWriter, and the
  joiner doesn't fit a PDN to a size (lossless, like an animation; the queue
  refuses one too large).
- The reader checks layers x width x height against one budget (256 M
  pixels, as FITS) before it allocates anything, and that the pixels claimed
  can come out of what is left of the file. A large layer in tiny chunks is
  refused.
- ImageEncoder.MaxSide knows PDN's 65,535 px, so a very wide picture is
  scaled to fit instead of failing.
- PsdLayerRecords reads exactly the bytes it asks for; a record cut off or
  out of shape gives no names (ImageMagick's are used) instead of failing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o
@Ugoyo

Ugoyo commented Nov 11, 2026

Copy link
Copy Markdown
Author

Thanks for the careful review! All four are fixed in d12f4d9:

  1. Merge images: ImageEncoder.WriteImage now writes PDN with PaintNetWriter, so two .pdn files on "Merge images" give one .pdn. The joiner doesn't fit a PDN to a size limit (it's lossless, like an animation), so the queue refuses one that is too large. Test: Two_pdn_merged_make_one_pdn.
  2. Pixel budget: before anything is allocated, layers × width × height is checked against 256 M pixels (as for FITS), and against what the rest of the file can unpack to. A large layer in chunks smaller than 4 KB is refused. Tests: A_pdn_claiming_more_pixels_than_it_holds_is_refused_before_anything_is_allocated, A_large_layer_in_tiny_chunks_is_refused.
  3. MaxSide: "pdn" => 65_535, so a very wide picture is scaled to fit. Test: A_picture_wider_than_paint_net_opens_is_scaled_to_fit.
  4. PsdLayerRecords: it now reads exactly the bytes it asks for. A cut-off or unusual record gives no names, so ImageMagick's are used, instead of failing. Test: A_cut_off_psd_gives_no_layer_names_instead_of_failing.

Noted for the schema number and the specialist count, thanks for handling those.

KnifeLemon#1349 took schema 79 for iPhone ringtones. The images donut now learns pdn in
schema 80; FormatFieldsTests counts include M4R (general) and PDN
(specialist).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o
Main took schemas 80 to 83 (PDF/A preset, tracker modules, .zxp, .mpg4). The
images donut now learns pdn in schema 84; FormatFieldsTests counts include
PDN with the formats main added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o
KnifeLemon pushed a commit that referenced this pull request Nov 12, 2026
An .ani is a RIFF "ACON" file whose frames are whole .cur/.ico files.
Filee's own reader (Ani) takes the frames out, has ImageMagick decode
each at its largest size, and builds the animation step by step,
following the "seq " and "rate" chunks (jiffies of 1/60 s, rounded to
hundredths) with an endless loop.

- GIF, APNG, WebP and the other animated targets keep every step and
  its time; other targets get the first step.
- To CUR, the first frame is written as stored (every size and its
  hotspot) unless the preset changes the picture; then the hotspot
  follows a resize, turn or mirror. ImageMagick writes CUR as an icon
  (type 1, no hotspot), so that file is made a cursor afterwards.
- Files are read defensively: bounded size, frame and step counts,
  frame dimensions; raw-bitmap frames and damaged files are refused
  with a clear message.
- Read only, specialist, Developer field (like CUR).
- Settings schema 80: the images donut learns .ani; custom claims stay
  put. (May need renumbering depending on merge order: 79 is taken by
  #1384.)

Fixes #1365

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BPxh5pr2HpsMpvTujh7kW6
Main took schema 84 (folder ZIP restore) and 85 (.ani), so the Paint.NET
step is v86 now; the format counts include .ani as well.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@KnifeLemon KnifeLemon left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you, all four are in and each one has its test. Routing PDN through PaintNetWriter in WriteImage, and checking the budget before anything is allocated (with the packing-ratio check on top), is just what we hoped for.

We merged main into your branch, moved the .pdn migration to schema 86 (main has taken up to 85 since, with #1390 at 85), and set the specialist count for both new formats. Thanks again, this is a big addition!

@KnifeLemon
KnifeLemon merged commit 24c85da into KnifeLemon:main Nov 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants