diff --git a/Directory.Packages.props b/Directory.Packages.props
index 13b82516e..0f0798a82 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -41,6 +41,8 @@
+
+
diff --git a/README.md b/README.md
index 8beb1a036..054d4b7c8 100644
--- a/README.md
+++ b/README.md
@@ -166,7 +166,7 @@ To undo it, remove the entries in Windows Security → App & browser control →
| From | To |
|---|---|
-| **Images**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR, …; GIMP XCF, camera RAW (CR2, CR3, NEF, ARW, DNG, …), DICOM, OpenEXR, HDR, FITS, JPEG XR (Windows), iPhone Live Photo (LIVP), animated cursor (ANI) read | any of these (HEIC⁵), PDF, SVG (traced), resized, grayscale; Live/Motion Photos (iPhone, Android) → their video (MP4, MOV⁵), and a video → Live Photo (iPhone) or Motion Photo (Android)⁵, experimental |
+| **Images**: JPG, PNG, WEBP, AVIF, TIFF, BMP, GIF, APNG, ICO, ICNS, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, HEIC, QOI, DDS, PCX, MNG, CUR, …; GIMP XCF, camera RAW (CR2, CR3, NEF, ARW, DNG, …), DICOM, OpenEXR, HDR, FITS, JPEG XR (Windows), iPhone Live Photo (LIVP), Paint.NET PDN (↔ PSD with its layers), animated cursor (ANI) read | any of these (HEIC⁵), Paint.NET PDN, PDF, SVG (traced), resized, grayscale; Live/Motion Photos (iPhone, Android) → their video (MP4, MOV⁵), and a video → Live Photo (iPhone) or Motion Photo (Android)⁵, experimental |
| **Vector**: SVG, SVGZ, EMF, WMF, AI EPS, PS¹ CDR, VSD, CGM, ODG, FODG² | PDF (vector), PNG and other images, SVG ↔ SVGZ, SVG → DXF/DWG, PDF → EPS/PS¹, SVG → EMF, WMF, ODG² |
| **PDF** | DOCX, HWPX, TXT, PNG, JPG, TIFF, CBZ, PPTX (a picture of each page), SVG²; merge (in an order you choose first), split, page ranges, compress (smaller PDFs) |
| **Documents**: DOCX, DOCM, DOTX, EML, Outlook MSG; OFD (Chinese e-invoices, official documents) read DOC, ODT, FODT, OTT, RTF, WPS, WPD, Pages, ClarisWorks, MacWrite, T602, …² | PDF, DOCX, HWPX, EPUB, TXT, HTML; MSG → EML ODT, FODT, OTT² |
diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md
index 61d84a614..1e2c577f7 100644
--- a/THIRD-PARTY-NOTICES.md
+++ b/THIRD-PARTY-NOTICES.md
@@ -41,6 +41,7 @@ Each component remains under its own license.
| AngleSharp | MIT | https://github.com/AngleSharp/AngleSharp |
| pypandoc-hwpx (Pandoc AST mapping and package layout in `Filee.Engines/Hwp/Hwpx`, incl. the `blank.hwpx` reference document) | MIT, Copyright (c) 2024 pypandoc-hwpx Contributors | https://github.com/msjang/pypandoc-hwpx |
| Microsoft.Extensions.* | MIT | https://github.com/dotnet/runtime |
+| System.Formats.Nrbf (reads Paint.NET .pdn files) | MIT | https://github.com/dotnet/runtime |
| cu2qu (fontTools): the cubic-to-quadratic approach followed by `Filee.Engines/Fonts/Cff/CubicToQuadratic.cs` | Apache-2.0, Copyright 2016 Google Inc. | https://github.com/fonttools/fonttools |
libuiohook (the native input hook inside SharpHook) is the fork https://github.com/TolikPylypchuk/libuiohook, licensed
diff --git a/docs/ENGINES.md b/docs/ENGINES.md
index 15855ac9c..cdffe43dd 100644
--- a/docs/ENGINES.md
+++ b/docs/ENGINES.md
@@ -5,7 +5,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| Engine | How it gets there | Handles |
|---|---|---|
-| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP, the merged picture of a Krita KRA, the icon of a Windows program or library (EXE, DLL) and a Windows **animated cursor** (ANI, Filee's own reader) read |
+| ImageMagick (Magick.NET) | built in (library) | JPG, PNG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, MNG, QOI, DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster, MIFF ↔ each other, and **APNG** (Filee's own reader and writer); HEIC (written with FFmpeg), XCF, camera RAW (LibRaw), DICOM (uncompressed ones by Filee's own reader), **FITS** (Filee's own reader; TIFF at the file's own depth by Filee's own writer), Photo CD, OpenEXR, Radiance HDR, EMF/WMF (Windows), the photo of an iPhone LIVP, the merged picture of a Krita KRA, a **Paint.NET PDN** (Filee's own reader and writer: layers merged as Paint.NET shows them, or kept between PDN and PSD/PSB), the icon of a Windows program or library (EXE, DLL) and a Windows **animated cursor** (ANI, Filee's own reader) read |
| Windows Imaging Component | part of Windows (Windows only) | **JPEG XR** (JXR, WDP, HDP; HDR screenshots of Windows) → the images ImageMagick writes, and PDF, ICNS, HEIC and SVG through PNG; HDR tone-mapped to SDR |
| Vector graphics | built in (Svg.Skia, SkiaSharp, ACadSharp) | **SVG/SVGZ → PDF (vector) and images**, SVG ↔ SVGZ, **SVG → DXF** (→ DWG with the CAD engine), **raster images → SVG (tracing)**, PDF-compatible AI → PDF, **HP-GL / HP-GL/2 plot files (PLT) → SVG**, PDF and images (pen moves, packed PE polylines, arcs and filled polygons; text labels are not drawn), **Tajima DST embroidery → SVG** (stitches as lines, a palette colour per thread; → PNG, JPG, PDF from there) |
| ICNS | built in | images → ICNS (16–1024 px), ICNS → images |
@@ -346,6 +346,20 @@ in it also builds the package (artifact `windows-arm64-package`).
without one gives a clear error. Krita's backup of the previous save, `name.kra~`, is the same file and is read the
same way; its output is named `name.png`. Watch folders skip it (and any name ending in `~`), as it is written next
to the drawing on every save.
+- **PDN** (Paint.NET's own file) is read by Filee itself (`PaintNet`): an XML header, the document in .NET binary
+ serialization, read as plain data with System.Formats.Nrbf (no type it names is ever created), then each layer's BGRA
+ pixels in gzip-compressed chunks. For PNG, JPG and every other target the visible layers are merged with Paint.NET's
+ own blend formulas (all 14 modes, layer opacity), so the picture looks as it does in Paint.NET. To **PSD / PSB** the
+ layers are kept, for Photoshop, GIMP and Krita: names (also as Unicode, `luni`), opacity, visibility and the blend
+ modes Photoshop shares (Multiply, Additive as Linear Dodge, Color Burn, Color Dodge, Overlay, Difference, Lighten,
+ Darken, Screen; Reflect, Glow, Negation and Xor become Normal), with the merged picture first. ImageMagick's PSD
+ writer stores a layer's visibility in the wrong bit and locks its transparency, so `PaintNet.SetLayerFlags` sets the
+ flags afterwards. A preset that changes the picture (resize, rotate, …) gives one merged picture instead.
+ Filee also **writes** PDN (`PaintNetWriter`), in the shape Paint.NET 4.2 saves and every later version opens (the
+ same records, class metadata and 256 KB gzip chunks, and a thumbnail in the header): any picture becomes one layer
+ named Background, and a **PSD / PSB** keeps its pixel layers, each at its place on the canvas, with its Unicode name,
+ opacity, visibility and the blend modes Paint.NET shares. PDN → PSD → PDN gives back the same layers, pixel for
+ pixel. A PDN is a target only where the Design & print field is on.
- **EXE / DLL** (a Windows program or library, #1188) is read as its icon, the one Explorer shows: the first icon group
of its resources, put back together as an `.ico` in memory (`ProgramIcon`, Filee's own reader for 32 and 64-bit PE
files; only the headers and the icon are read, at most 64 MB, and the file is never loaded or run). To ICO it is
diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs
index fdc4f7b9b..c8acffd6e 100644
--- a/src/Filee.Core/Formats/FormatFields.cs
+++ b/src/Filee.Core/Formats/FormatFields.cs
@@ -75,8 +75,8 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow
(FormatGrade.Specialist,
[
"jp2", "psd", "psb", "ppm", "icns", "qoi", "dds", "cur", "xpm", "xbm", "pam", "pfm", "miff", "dcm",
- "exr", "hdr", "fits", "xcf", "kra", "raw", "eps", "ps", "ai", "cdr", "cgm", "plt", "dst", "fodg", "fodt",
- "fods", "fodp", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", "textile", "man",
+ "exr", "hdr", "fits", "xcf", "kra", "pdn", "raw", "eps", "ps", "ai", "cdr", "cgm", "plt", "dst", "fodg",
+ "fodt", "fods", "fodp", "rst", "tex", "org", "adoc", "typ", "mediawiki", "dbk", "ipynb", "textile", "man",
"jsonl", "yaml", "toml", "har", "plist", "htmlz", "txtz", "chm", "dv", "mxf", "cavs", "usm", "m4s", "dav", "bik",
"h265", "h264", "ac3", "caf", "mp2", "dss", "wv", "tta", "w64", "eac3", "ape", "tak", "dsf", "dff",
"dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "zxp", "mcpack", "mcaddon",
@@ -97,7 +97,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow
private static readonly Dictionary Overlaps = BuildOverlaps(
[
// Layout, print, photo post-production, signs and embroidery.
- (["psd", "psb", "exr", "hdr", "xcf", "kra", "raw", "eps", "ps", "ai", "cdr", "plt", "dst"], ["design"]),
+ (["psd", "psb", "exr", "hdr", "xcf", "kra", "pdn", "raw", "eps", "ps", "ai", "cdr", "plt", "dst"], ["design"]),
// Game textures and cutscenes, Minecraft packs and modpacks.
(["dds", "usm", "bik", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack"], ["game"]),
// Medical imaging and astronomy.
diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs
index abf5aa432..ba4fed38f 100644
--- a/src/Filee.Core/Formats/FormatRegistry.cs
+++ b/src/Filee.Core/Formats/FormatRegistry.cs
@@ -65,6 +65,8 @@ public static class FormatRegistry
// Krita's own file (#903): a ZIP whose merged picture (mergedimage.png) is read, as Krita shows it. ".kra~" is the
// backup Krita keeps of the previous save, the same file under another name (#1077).
new("kra", "Krita (KRA)", FormatCategory.Image, ["kra", "kra~"], Writable: false),
+ // Paint.NET's own file, read and written by Filee: layers merged for other pictures, kept between PDN and PSD.
+ new("pdn", "Paint.NET (PDN)", FormatCategory.Image, ["pdn"]),
// iPhone Live Photo as one ZIP (iCloud downloads): the photo, or its video with FFmpeg.
new("livp", "Live Photo (LIVP)", FormatCategory.Image, ["livp"], Writable: false),
// A Windows program or library (#1188), read as its icon: the picture Explorer shows for the file.
diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs
index 9d99960e2..f62114492 100644
--- a/src/Filee.Core/Settings/AppSettings.cs
+++ b/src/Filee.Core/Settings/AppSettings.cs
@@ -170,7 +170,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable gest
public sealed class AppSettings
{
/// Current schema version. Increase when the format changes and add a migration.
- public const int CurrentSchemaVersion = 85;
+ public const int CurrentSchemaVersion = 86;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index b878b85cf..6b7e65e67 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -1131,6 +1131,9 @@ public static bool ApplyToLibrary(int from, List presets, List
+
diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs
index 8e2f534c5..af9911721 100644
--- a/src/Filee.Engines/Magick/ImageEncoder.cs
+++ b/src/Filee.Engines/Magick/ImageEncoder.cs
@@ -92,9 +92,9 @@ public static bool KeepsAnimation(string sourceFormat, string targetFormat) =>
///
/// Packages of a picture and more, of which the picture is read (): an iPhone LIVP, a Krita
- /// KRA, the icon of a Windows program or library ().
+ /// KRA, a Paint.NET PDN (), the icon of a Windows program or library ().
///
- public static readonly string[] Packages = ["livp", "kra", FormatRegistry.Program];
+ public static readonly string[] Packages = ["livp", "kra", "pdn", FormatRegistry.Program];
///
/// Every format ImageMagick reads as input: the raster formats, the picture of and, on
@@ -193,6 +193,7 @@ public static List> SelectFrames(MagickImageCollection images
"jpg" or "jpeg" => 65_500,
"webp" => 16_383,
"avif" => 16_384,
+ "pdn" => PaintNetWriter.MaxSide,
_ => double.PositiveInfinity,
};
@@ -465,6 +466,19 @@ public static List Write(IReadOnlyList> frames, strin
ImageOptions? options = null, string? sourcePath = null, bool animation = false)
{
var written = new List();
+ if (targetFormat == "pdn")
+ {
+ // Paint.NET's own file, written by Filee (PaintNetWriter): one picture per file, as one layer.
+ for (var i = 0; i < frames.Count; i++)
+ {
+ var pdn = output.Allocate("pdn", frames.Count > 1 ? $"_p{i + 1}" : null);
+ if (pdn is null)
+ continue;
+ PaintNetWriter.Write(frames[i], pdn);
+ written.Add(pdn);
+ }
+ return written;
+ }
var (format, extension) = OutputFormat(targetFormat, options ?? new ImageOptions(), sourcePath);
if (targetFormat == "apng")
@@ -595,11 +609,16 @@ internal static void FitToSize(IMagickImage image, string path, MagickForm
}
}
- /// Writes one image as (APNG through ).
+ ///
+ /// Writes one image as (APNG through , Paint.NET's PDN through
+ /// ).
+ ///
public static void WriteImage(IMagickImage image, string path, string targetFormat)
{
if (targetFormat == "apng")
Apng.Write([image], path);
+ else if (targetFormat == "pdn")
+ PaintNetWriter.Write(image, path);
else
image.Write(path, ToMagickFormat(targetFormat));
}
diff --git a/src/Filee.Engines/Magick/ImageJoiner.cs b/src/Filee.Engines/Magick/ImageJoiner.cs
index 28f37d063..133c19425 100644
--- a/src/Filee.Engines/Magick/ImageJoiner.cs
+++ b/src/Filee.Engines/Magick/ImageJoiner.cs
@@ -118,7 +118,8 @@ internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, st
ImageEncoder.ApplyOptions(merged, rest, format);
cancellationToken.ThrowIfCancellationRequested();
ImageEncoder.WriteImage(merged, outputPath, format);
- if (options.MaxSizeMb > 0 || options.MinSizeMb > 0)
+ // A PDN (lossless, written by Filee) isn't fitted, like an animation: the queue refuses one too large.
+ if ((options.MaxSizeMb > 0 || options.MinSizeMb > 0) && format != "pdn")
ImageEncoder.FitToSize(merged, outputPath, ImageEncoder.OutputFormat(format, rest).Format, SizeLimit.Bytes(options.MaxSizeMb),
SizeLimit.MinimumBytes(options.MinSizeMb), options.MaxSizeKeepsPixels);
if (format == "cur")
diff --git a/src/Filee.Engines/Magick/ImageReader.cs b/src/Filee.Engines/Magick/ImageReader.cs
index e026c751c..6b7701005 100644
--- a/src/Filee.Engines/Magick/ImageReader.cs
+++ b/src/Filee.Engines/Magick/ImageReader.cs
@@ -2,8 +2,8 @@
// (LibRaw settings), layered PSD/PSB/XCF (one flattened picture instead of one file per layer) and Windows
// metafiles (rendered at the preset's DPI), APNG (Filee's own reader composes the frames) and uncompressed DICOM
// (Filee's own reader, as ImageMagick's Q8 build reads only 8-bit DICOM), FITS (Filee's own reader, stretched and
-// debayered as the preset says), the photo of an iPhone LIVP, the merged picture of a Krita KRA and the icon of a Windows
-// program. Used by every engine that takes a raster image as input.
+// debayered as the preset says), the photo of an iPhone LIVP, the merged picture of a Krita KRA, the layers of a Paint.NET
+// PDN merged as Paint.NET shows them and the icon of a Windows program. Used by every engine that takes a raster image as input.
using System.Buffers.Binary;
using Filee.Core.Formats;
@@ -35,6 +35,8 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr
return ReadLivePhoto(path, preset);
if (formatId == "kra")
return ReadPackaged(Krita.ReadMerged(path), "png", preset);
+ if (formatId == "pdn")
+ return [PaintNet.ReadFlattened(path)];
if (formatId == FormatRegistry.Program)
return ReadPackaged(ProgramIcon.Read(path), "ico", preset);
if (formatId == "fits")
diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs
index 43b136ed0..96a6a966c 100644
--- a/src/Filee.Engines/Magick/MagickImageConverter.cs
+++ b/src/Filee.Engines/Magick/MagickImageConverter.cs
@@ -29,9 +29,13 @@ public sealed class MagickImageConverter : IConverter, ITiffMerger, IImageTransf
internal Func<(string Ffmpeg, string Ffprobe)?> LocateFfmpeg { get; init; } = FfmpegConverter.Locate;
public IReadOnlyList Edges { get; } =
- (from source in ImageEncoder.Sources
- from target in ImageEncoder.Writable
- select ImageEncoder.Edge(source, target)).ToList();
+ [
+ .. from source in ImageEncoder.Sources
+ from target in ImageEncoder.Writable
+ select ImageEncoder.Edge(source, target),
+ // Paint.NET's own file, written by Filee (PaintNetWriter); a PSD keeps its layers there.
+ .. ImageEncoder.Sources.Where(source => source != "pdn").Select(source => ImageEncoder.Edge(source, "pdn")),
+ ];
public EngineStatus GetStatus() =>
EngineStatus.Available(MagickNET.ImageMagickVersion,
@@ -77,7 +81,7 @@ public Task MergeAsync(IReadOnlyList inputPaths, string outputPath, File
public bool CanRead(string format) => ImageEncoder.Sources.Contains(format);
- public bool CanWrite(string format) => ImageEncoder.Writable.Contains(format);
+ public bool CanWrite(string format) => format == "pdn" || ImageEncoder.Writable.Contains(format);
/// "Merge images": the pictures one under another or side by side in one image ().
public Task JoinAsync(IReadOnlyList<(string Path, string Format)> inputs, string outputPath, string format,
@@ -97,6 +101,12 @@ public Task> ConvertAsync(ConversionStep step, IProgressPaint.NET's layer blend modes, in the order of its LayerBlendMode enumeration.
+ internal enum BlendMode
+ {
+ Normal, Multiply, Additive, ColorBurn, ColorDodge, Reflect, Glow, Overlay, Difference, Negation, Lighten, Darken,
+ Screen, Xor,
+ }
+
+ /// One layer: its properties and, once read, its pixels (BGRA, not premultiplied, top row first).
+ internal sealed record Layer(string Name, bool Visible, byte Opacity, BlendMode Blend, byte[] Bgra);
+
+ /// Paint.NET canvases are at most 65,535 pixels on a side.
+ private const int MaxSide = 65_535;
+
+ /// More layers than Paint.NET users make; a file claiming more is damaged.
+ private const int MaxLayers = 4_096;
+
+ ///
+ /// The most pixels Filee reads from a .pdn, every layer counted (layers × width × height), as for FITS: 1 GB of BGRA.
+ /// Checked before anything is allocated, so a small file that claims a huge canvas or thousands of layers asks for
+ /// nothing.
+ ///
+ internal const long MaxPixels = 256L * 1024 * 1024;
+
+ ///
+ /// gzip never packs more than about 1,032 bytes into one: the pixels a file claims can't be much more than this many
+ /// times what is left of it.
+ ///
+ private const long MaxPackingRatio = 1_100;
+
+ /// Fewest bytes a layer's chunks hold (Paint.NET writes 256 KB): smaller ones only on a small layer.
+ private const int MinChunkSize = 4_096;
+
+ private static readonly InvalidDataException Damaged =
+ new("This Paint.NET file is damaged or of an unknown kind. Open it in Paint.NET and save it again.");
+
+ /// Width and height of the canvas, and its layers bottom first, each read as the caller takes it.
+ /// The file is not a Paint.NET image Filee can read.
+ public static (int Width, int Height, IEnumerable Layers) Open(string path)
+ {
+ var stream = File.OpenRead(path);
+ try
+ {
+ return Open(stream);
+ }
+ catch (Exception ex) when (ex is KeyNotFoundException or InvalidOperationException or InvalidCastException)
+ {
+ // A member missing, or of another type than Paint.NET writes.
+ stream.Dispose();
+ throw new InvalidDataException(Damaged.Message, ex);
+ }
+ catch
+ {
+ stream.Dispose();
+ throw;
+ }
+ }
+
+ private static (int Width, int Height, IEnumerable Layers) Open(FileStream stream)
+ {
+ var data = OpenDocument(stream, out var document);
+ var width = document.GetInt32("width");
+ var height = document.GetInt32("height");
+ if (width is < 1 or > MaxSide || height is < 1 or > MaxSide)
+ throw Damaged;
+ var layers = Layers(document);
+ var pixels = (long)width * height * Math.Max(1, layers.Count);
+ if (pixels > MaxPixels)
+ throw new InvalidDataException("This Paint.NET picture is too large to convert.");
+ // A file can't hold far more pixels than its size allows; with everything gzip-wrapped (old files) it can.
+ if (data == stream && pixels * 4 > (stream.Length - stream.Position) * MaxPackingRatio)
+ throw Damaged;
+ return (width, height, ReadPixels(stream, data, layers, width, height));
+ }
+
+ /// The picture as Paint.NET shows it: every visible layer merged, bottom first.
+ public static MagickImage ReadFlattened(string path)
+ {
+ var (width, height, layers) = Open(path);
+ var canvas = new byte[width * height * 4];
+ foreach (var layer in layers)
+ {
+ if (layer.Visible)
+ Blend(canvas, layer.Bgra, layer.Opacity, layer.Blend);
+ }
+ return FromBgra(canvas, width, height);
+ }
+
+ ///
+ /// True when a .pdn goes to PSD with its layers: the preset changes nothing about the picture. Otherwise it is
+ /// converted from the merged picture, as for any image.
+ ///
+ public static bool KeepsLayers(ImageOptions options) =>
+ !options.EditsPicture && !options.ReworksPixels && options.MaxSizeMb <= 0 && options.MinSizeMb <= 0;
+
+ ///
+ /// Writes the .pdn as a PSD (or PSB) with one layer per Paint.NET layer: its name, opacity, visibility and, where
+ /// Photoshop has the same one, its blend mode. Reflect, Glow, Negation and Xor have none; those layers become Normal.
+ /// The merged picture goes first, for programs that read only that.
+ ///
+ public static IReadOnlyList SaveLayered(ConversionStep step, IProgress? progress, CancellationToken ct)
+ {
+ var (width, height, layers) = Open(step.InputPath);
+ using var images = new MagickImageCollection();
+ var canvas = new byte[width * height * 4];
+ var visible = new List();
+ foreach (var layer in layers)
+ {
+ ct.ThrowIfCancellationRequested();
+ if (layer.Visible)
+ Blend(canvas, layer.Bgra, layer.Opacity, layer.Blend);
+ images.Add(ToPsdLayer(layer, width, height));
+ visible.Add(layer.Visible);
+ }
+ images.Insert(0, FromBgra(canvas, width, height));
+ progress?.Report(0.7);
+
+ var format = step.To == "psb" ? MagickFormat.Psb : MagickFormat.Psd;
+ var path = step.Output.Allocate(step.To);
+ if (path is null)
+ return [];
+ foreach (var image in images)
+ {
+ image.Settings.SetDefine(MagickFormat.Psd, "additional-info", "all");
+ image.Settings.Compression = CompressionMethod.RLE;
+ image.Page = new MagickGeometry(0, 0, (uint)width, (uint)height);
+ }
+ images.Write(path, format);
+ SetLayerFlags(path, visible, format == MagickFormat.Psb);
+ progress?.Report(1);
+ return [path];
+ }
+
+ ///
+ /// Checks the header and decodes the serialized document. Returns the stream the pixel data follows on: the file
+ /// itself, or for files of old Paint.NET versions that compressed everything after the header, a gzip reader.
+ ///
+ private static Stream OpenDocument(Stream stream, out ClassRecord document)
+ {
+ Span header = stackalloc byte[7];
+ if (stream.ReadAtLeast(header, 7, throwOnEndOfStream: false) < 7 || !header[..4].SequenceEqual("PDN3"u8))
+ throw Damaged;
+ var xmlLength = header[4] | header[5] << 8 | header[6] << 16;
+ if (stream.Seek(xmlLength, SeekOrigin.Current) > stream.Length - 2)
+ throw Damaged;
+ Span marker = stackalloc byte[2];
+ stream.ReadExactly(marker);
+ Stream data = stream;
+ if (marker[0] == 0x1F && marker[1] == 0x8B)
+ {
+ stream.Seek(-2, SeekOrigin.Current);
+ data = new GZipStream(stream, CompressionMode.Decompress, leaveOpen: true);
+ }
+ else if (marker[0] != 0x00 || marker[1] != 0x01)
+ {
+ throw Damaged;
+ }
+
+ try
+ {
+ document = NrbfDecoder.Decode(data, out _, new PayloadOptions(), leaveOpen: true) as ClassRecord
+ ?? throw Damaged;
+ }
+ catch (Exception ex) when (ex is System.Runtime.Serialization.SerializationException or NotSupportedException or EndOfStreamException
+ or DecoderFallbackException or FormatException)
+ {
+ throw new InvalidDataException(Damaged.Message, ex);
+ }
+ if (document.TypeName.FullName != "PaintDotNet.Document")
+ throw Damaged;
+ return data;
+ }
+
+ /// The layers' properties, bottom first, from the serialized document.
+ private static List<(string Name, bool Visible, byte Opacity, BlendMode Blend, long Length, int Stride)> Layers(
+ ClassRecord document)
+ {
+ var list = document.GetClassRecord("layers") ?? throw Damaged;
+ var count = Member(list, "ArrayList+_size", "_size");
+ if (count is < 0 or > MaxLayers
+ || (list.GetSerializationRecord(MemberName(list, "ArrayList+_items", "_items"))
+ as SZArrayRecord)?.GetArray() is not { } items
+ || items.Length < count)
+ throw Damaged;
+
+ var layers = new List<(string, bool, byte, BlendMode, long, int)>(count);
+ for (var i = 0; i < count; i++)
+ {
+ if (items[i] is not ClassRecord layer)
+ throw Damaged;
+ var properties = layer.GetClassRecord(MemberName(layer, "Layer+properties", "properties")) ?? throw Damaged;
+ var surface = layer.GetClassRecord("surface") ?? throw Damaged;
+ var memory = surface.GetClassRecord("scan0") ?? throw Damaged;
+ layers.Add((properties.GetString("name") ?? "",
+ properties.GetBoolean("visible"),
+ properties.GetByte("opacity"),
+ BlendOf(layer, properties),
+ memory.GetInt64("length64"),
+ surface.GetInt32("stride")));
+ }
+ return layers;
+ }
+
+ ///
+ /// The layer's blend mode: the LayerBlendMode of Paint.NET 4 and later, or the blend operation's class name that
+ /// Paint.NET 3 stored instead. Modes this reader doesn't know are drawn as Normal.
+ ///
+ private static BlendMode BlendOf(ClassRecord layer, ClassRecord properties)
+ {
+ if (properties.HasMember("blendMode"))
+ {
+ var value = properties.GetRawValue("blendMode") switch
+ {
+ ClassRecord mode when mode.HasMember("value__") => mode.GetInt32("value__"),
+ int raw => raw,
+ _ => 0,
+ };
+ return Enum.IsDefined((BlendMode)value) ? (BlendMode)value : BlendMode.Normal;
+ }
+ if (layer.HasMember("properties") && layer.GetClassRecord("properties") is { } bitmap
+ && bitmap.HasMember("blendOp") && bitmap.GetClassRecord("blendOp") is { } op)
+ {
+ var name = op.TypeName.FullName;
+ var plus = name.LastIndexOf('+');
+ var className = plus < 0 ? name : name[(plus + 1)..];
+ if (className.EndsWith("BlendOp", StringComparison.Ordinal)
+ && Enum.TryParse(className[..^"BlendOp".Length], out var mode))
+ return mode;
+ }
+ return BlendMode.Normal;
+ }
+
+ ///
+ /// The layers' pixels, which follow the document in its order: per layer a format byte (0 gzip, 1 stored), the
+ /// chunk size, then every chunk as its number and length (big-endian) and its bytes. Chunks may come in any order.
+ ///
+ private static IEnumerable ReadPixels(Stream file, Stream data,
+ List<(string Name, bool Visible, byte Opacity, BlendMode Blend, long Length, int Stride)> layers, int width, int height)
+ {
+ using (file)
+ using (data == file ? null : data)
+ {
+ foreach (var layer in layers)
+ {
+ if (layer.Length != (long)width * height * 4 || layer.Stride != width * 4)
+ throw Damaged;
+ byte[] pixels;
+ try
+ {
+ pixels = ReadLayer(data, (int)layer.Length);
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidDataException(Damaged.Message, ex);
+ }
+ yield return new Layer(layer.Name, layer.Visible, layer.Opacity, layer.Blend, pixels);
+ }
+ }
+ }
+
+ /// The bytes of one layer's pixels, from its chunks.
+ private static byte[] ReadLayer(Stream data, int length)
+ {
+ var pixels = new byte[length];
+ var compressed = data.ReadByte() switch
+ {
+ 0 => true,
+ 1 => false,
+ _ => throw Damaged,
+ };
+ var chunkSize = ReadUInt32(data);
+ if (chunkSize == 0)
+ throw Damaged;
+ var chunks = (int)((length + (long)chunkSize - 1) / chunkSize);
+ // Tiny chunks over a large layer are damage, and would make the bookkeeping below large.
+ if (chunkSize < MinChunkSize && chunks > Math.Max(64, length / MinChunkSize))
+ throw Damaged;
+ var seen = new bool[chunks];
+ for (var i = 0; i < chunks; i++)
+ {
+ var chunk = ReadUInt32(data);
+ var size = ReadUInt32(data);
+ if (chunk >= chunks || seen[chunk])
+ throw Damaged;
+ seen[chunk] = true;
+ var offset = (int)(chunk * chunkSize);
+ var target = pixels.AsSpan(offset, (int)Math.Min(chunkSize, length - offset));
+ // gzip never makes a chunk much larger than its pixels; a larger size is damage, not a reason to allocate it.
+ if (size > target.Length + target.Length / 8 + 1024L)
+ throw Damaged;
+ ReadChunk(data, (int)size, compressed, target);
+ }
+ return pixels;
+ }
+
+ private static uint ReadUInt32(Stream data)
+ {
+ Span number = stackalloc byte[4];
+ data.ReadExactly(number);
+ return BinaryPrimitives.ReadUInt32BigEndian(number);
+ }
+
+ /// One chunk of a layer: bytes that fill exactly.
+ private static void ReadChunk(Stream data, int size, bool compressed, Span target)
+ {
+ if (!compressed)
+ {
+ if (size != target.Length)
+ throw Damaged;
+ data.ReadExactly(target);
+ return;
+ }
+ var packed = new byte[size];
+ data.ReadExactly(packed);
+ using var gzip = new GZipStream(new MemoryStream(packed), CompressionMode.Decompress);
+ try
+ {
+ gzip.ReadExactly(target);
+ // Nothing may be left over: a chunk that unpacks to more than its share is damaged (or a zip bomb).
+ if (gzip.ReadByte() != -1)
+ throw Damaged;
+ }
+ catch (EndOfStreamException ex)
+ {
+ throw new InvalidDataException(Damaged.Message, ex);
+ }
+ }
+
+ ///
+ /// Paint.NET's blend of one layer onto the canvas below it (UserBlendOps): the layer's alpha times its opacity
+ /// covers the canvas, and where both have colour the mode's function of the two takes its place.
+ ///
+ internal static void Blend(byte[] canvas, byte[] layer, byte opacity, BlendMode mode)
+ {
+ for (var i = 0; i < canvas.Length; i += 4)
+ {
+ var top = Scale(layer[i + 3], opacity);
+ if (top == 0)
+ continue;
+ int bottom = canvas[i + 3];
+ var both = Scale(bottom, top);
+ var onlyBottom = bottom - both;
+ var onlyTop = top - both;
+ var total = onlyBottom + top;
+ for (var c = 0; c < 3; c++)
+ {
+ int a = canvas[i + c], b = layer[i + c];
+ var mixed = both == 0 ? 0 : Function(mode, a, b);
+ canvas[i + c] = (byte)((a * onlyBottom + b * onlyTop + mixed * both + total / 2) / total);
+ }
+ canvas[i + 3] = (byte)total;
+ }
+ }
+
+ /// a × b / 255, rounded.
+ private static int Scale(int a, int b)
+ {
+ var product = a * b + 128;
+ return (product + (product >> 8)) >> 8;
+ }
+
+ /// The colour a blend mode gives for bottom colour and top colour .
+ private static int Function(BlendMode mode, int a, int b) => mode switch
+ {
+ BlendMode.Multiply => Scale(a, b),
+ BlendMode.Additive => Math.Min(255, a + b),
+ BlendMode.ColorBurn => b == 0 ? 0 : Math.Max(0, 255 - (255 - a) * 255 / b),
+ BlendMode.ColorDodge => b == 255 ? 255 : Math.Min(255, a * 255 / (255 - b)),
+ BlendMode.Reflect => b == 255 ? 255 : Math.Min(255, a * a / (255 - b)),
+ BlendMode.Glow => a == 255 ? 255 : Math.Min(255, b * b / (255 - a)),
+ BlendMode.Overlay => a < 128 ? Scale(2 * a, b) : 255 - Scale(2 * (255 - a), 255 - b),
+ BlendMode.Difference => Math.Abs(a - b),
+ BlendMode.Negation => 255 - Math.Abs(255 - a - b),
+ BlendMode.Lighten => Math.Max(a, b),
+ BlendMode.Darken => Math.Min(a, b),
+ BlendMode.Screen => a + b - Scale(a, b),
+ BlendMode.Xor => a ^ b,
+ _ => b,
+ };
+
+ private static MagickImage FromBgra(byte[] bgra, int width, int height)
+ {
+ var image = new MagickImage();
+ image.ReadPixels(bgra, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.BGRA));
+ return image;
+ }
+
+ ///
+ /// A Paint.NET layer as ImageMagick's PSD writer takes it: the name in "label" (ASCII, as PSD's own field holds) and
+ /// as Unicode ("luni", which Photoshop, GIMP and Krita show), the opacity in "psd:layer.opacity" with the alpha
+ /// multiplied by it (the writer divides it out again). Visibility is set afterwards, see .
+ ///
+ private static MagickImage ToPsdLayer(Layer layer, int width, int height)
+ {
+ if (layer.Opacity < 255)
+ {
+ for (var i = 3; i < layer.Bgra.Length; i += 4)
+ layer.Bgra[i] = (byte)Scale(layer.Bgra[i], layer.Opacity);
+ }
+ var image = FromBgra(layer.Bgra, width, height);
+ image.Label = AsciiName(layer.Name);
+ image.SetProfile(new ImageProfile("psd:additional-info", UnicodeName(layer.Name)));
+ if (layer.Opacity < 255)
+ image.SetArtifact("psd:layer.opacity", layer.Opacity.ToString(System.Globalization.CultureInfo.InvariantCulture));
+ image.Compose = layer.Blend switch
+ {
+ BlendMode.Multiply => CompositeOperator.Multiply,
+ BlendMode.Additive => CompositeOperator.LinearDodge,
+ BlendMode.ColorBurn => CompositeOperator.ColorBurn,
+ BlendMode.ColorDodge => CompositeOperator.ColorDodge,
+ BlendMode.Overlay => CompositeOperator.Overlay,
+ BlendMode.Difference => CompositeOperator.Difference,
+ BlendMode.Lighten => CompositeOperator.Lighten,
+ BlendMode.Darken => CompositeOperator.Darken,
+ BlendMode.Screen => CompositeOperator.Screen,
+ _ => CompositeOperator.Over,
+ };
+ return image;
+ }
+
+ ///
+ /// Sets each layer's flags in a PSD ImageMagick wrote: hidden where Paint.NET hides the layer, and nothing else.
+ /// ImageMagick writes the flags byte as 1 for a visible layer and 0 for a hidden one, but the visibility bit is 2
+ /// (set = hidden) and 1 locks the layer's transparent pixels: every layer would show, locked. Left as it is when the
+ /// file is not laid out as expected.
+ ///
+ internal static void SetLayerFlags(string path, IReadOnlyList visible, bool psb)
+ {
+ using var file = File.Open(path, FileMode.Open, FileAccess.ReadWrite);
+ var records = PsdLayerRecords.Read(file, psb);
+ if (records is null || records.Count != visible.Count)
+ return;
+ for (var i = 0; i < records.Count; i++)
+ {
+ file.Position = records[i].FlagsOffset;
+ file.WriteByte(visible[i] ? (byte)0 : (byte)2);
+ }
+ }
+
+ /// The layer name for PSD's Pascal string: accents dropped, other non-ASCII letters as '_', 250 at most.
+ internal static string AsciiName(string name)
+ {
+ var builder = new StringBuilder();
+ foreach (var c in name.Normalize(NormalizationForm.FormD))
+ {
+ if (char.GetUnicodeCategory(c) == System.Globalization.UnicodeCategory.NonSpacingMark)
+ continue;
+ builder.Append(c is >= ' ' and <= '~' ? c : '_');
+ if (builder.Length == 250)
+ break;
+ }
+ return builder.Length == 0 ? "Layer" : builder.ToString();
+ }
+
+ /// PSD's "luni" block: the layer name as a length-prefixed UTF-16BE string, padded to four bytes.
+ internal static byte[] UnicodeName(string name)
+ {
+ if (name.Length > 1_000)
+ name = name[..1_000];
+ var text = Encoding.BigEndianUnicode.GetBytes(name);
+ var body = 4 + text.Length;
+ var padded = (body + 3) & ~3;
+ var block = new byte[12 + padded];
+ "8BIMluni"u8.CopyTo(block);
+ BinaryPrimitives.WriteUInt32BigEndian(block.AsSpan(8), (uint)padded);
+ BinaryPrimitives.WriteUInt32BigEndian(block.AsSpan(12), (uint)name.Length);
+ text.CopyTo(block, 16);
+ return block;
+ }
+
+ private static string MemberName(ClassRecord record, params string[] names) =>
+ names.FirstOrDefault(record.HasMember) ?? throw Damaged;
+
+ private static T Member(ClassRecord record, params string[] names) where T : struct =>
+ record.GetRawValue(MemberName(record, names)) is T value ? value : throw Damaged;
+}
diff --git a/src/Filee.Engines/Magick/PaintNetWriter.cs b/src/Filee.Engines/Magick/PaintNetWriter.cs
new file mode 100644
index 000000000..03fea3100
--- /dev/null
+++ b/src/Filee.Engines/Magick/PaintNetWriter.cs
@@ -0,0 +1,485 @@
+// Writes Paint.NET's own file (.pdn) as Paint.NET 4.2 does, which every later Paint.NET opens: "PDN3", the XML header
+// with a thumbnail, the document in .NET binary serialization (NRBF) and each layer's BGRA pixels in gzip-compressed
+// 256 KB chunks. The serialization is written record by record here, in the order and shape BinaryFormatter gives
+// Paint.NET's own classes (class metadata once per type, objects queued and referenced by id, enums inline), so the
+// file matches one Paint.NET saved itself; see PaintNet for the reader.
+
+using System.Buffers.Binary;
+using System.IO.Compression;
+using System.Text;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+internal static class PaintNetWriter
+{
+ /// A layer to write: full canvas size, BGRA (not premultiplied), top row first.
+ internal sealed record Layer(string Name, byte[] Bgra, bool Visible = true, byte Opacity = 255,
+ PaintNet.BlendMode Blend = PaintNet.BlendMode.Normal);
+
+ /// The Paint.NET version the file says it was saved with; its assemblies are named after it.
+ private const string SavedWith = "4.21.6589.7045";
+
+ private const string DataLibrary = "PaintDotNet.Data, Version=" + SavedWith + ", Culture=neutral, PublicKeyToken=null";
+ private const string CoreLibrary = "PaintDotNet.Core, Version=" + SavedWith + ", Culture=neutral, PublicKeyToken=null";
+
+ private const string StringPairs =
+ "System.Collections.Generic.KeyValuePair`2[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, " +
+ "PublicKeyToken=b77a5c561934e089],[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, " +
+ "PublicKeyToken=b77a5c561934e089]]";
+
+ /// Paint.NET writes its layers' pixels in chunks of this many bytes.
+ private const int ChunkSize = 256 * 1024;
+
+ /// Longest side of the thumbnail in the header, as Paint.NET makes it.
+ private const int ThumbnailSide = 256;
+
+ /// The largest canvas Paint.NET opens.
+ public const int MaxSide = 65_535;
+
+ /// One picture as a .pdn of a single layer named "Background".
+ public static void Write(IMagickImage image, string path) =>
+ Write(path, (int)image.Width, (int)image.Height, [new Layer("Background", ToBgra(image))]);
+
+ ///
+ /// Writes a PSD (or PSB) as a .pdn with its layers: each pixel layer at its place on the canvas, with its name, opacity,
+ /// visibility and, where Paint.NET has the same one, its blend mode (others are drawn Normal). Groups and adjustment
+ /// layers have no pixels of their own in Paint.NET's terms; a PSD without layers becomes one layer.
+ ///
+ public static IReadOnlyList SaveLayered(Core.Conversion.ConversionStep step, IProgress? progress,
+ CancellationToken ct)
+ {
+ using var psd = new MagickImageCollection();
+ var settings = new MagickReadSettings { Format = step.From == "psb" ? MagickFormat.Psb : MagickFormat.Psd };
+ psd.Read(step.InputPath, settings);
+ var width = (int)psd[0].Width;
+ var height = (int)psd[0].Height;
+ // The names as written (ImageMagick damages any that is not ASCII), when every record became a layer here.
+ List? records;
+ using (var file = File.OpenRead(step.InputPath))
+ records = PsdLayerRecords.Read(file, step.From == "psb");
+ if (records?.Count != psd.Count - 1)
+ records = null;
+ var layers = new List();
+ for (var i = 1; i < psd.Count; i++)
+ {
+ ct.ThrowIfCancellationRequested();
+ layers.Add(FromPsdLayer(psd[i], width, height, records?[i - 1].Name));
+ }
+ if (layers.Count == 0)
+ layers.Add(new Layer("Background", ToBgra(psd[0])));
+ progress?.Report(0.6);
+
+ var path = step.Output.Allocate("pdn");
+ if (path is null)
+ return [];
+ Write(path, width, height, layers);
+ progress?.Report(1);
+ return [path];
+ }
+
+ ///
+ /// One layer ImageMagick read from a PSD, on a canvas of its own: its alpha back without the layer's opacity (the
+ /// reader multiplies it in), the opacity, visibility (hidden layers come back composed as "No") and blend mode.
+ ///
+ private static Layer FromPsdLayer(IMagickImage image, int width, int height, string? name)
+ {
+ var opacity = byte.TryParse(image.GetArtifact("psd:layer.opacity"), out var value) ? value : (byte)255;
+ var pixels = ToBgra(image);
+ if (opacity is > 0 and < 255)
+ {
+ for (var i = 3; i < pixels.Length; i += 4)
+ pixels[i] = (byte)Math.Min(255, (pixels[i] * 255 + opacity / 2) / opacity);
+ }
+
+ // The layer's place: its offset on the canvas, the part of it inside the canvas copied row by row.
+ var canvas = new byte[width * height * 4];
+ int layerWidth = (int)image.Width, layerHeight = (int)image.Height;
+ int left = image.Page.X, top = image.Page.Y;
+ var from = Math.Max(0, -left);
+ var to = Math.Min(layerWidth, width - left);
+ if (to > from)
+ {
+ for (var y = Math.Max(0, -top); y < layerHeight && top + y < height; y++)
+ Array.Copy(pixels, (y * layerWidth + from) * 4, canvas, ((top + y) * width + left + from) * 4, (to - from) * 4);
+ }
+
+ var blend = image.Compose switch
+ {
+ CompositeOperator.Multiply => PaintNet.BlendMode.Multiply,
+ CompositeOperator.LinearDodge => PaintNet.BlendMode.Additive,
+ CompositeOperator.ColorBurn => PaintNet.BlendMode.ColorBurn,
+ CompositeOperator.ColorDodge => PaintNet.BlendMode.ColorDodge,
+ CompositeOperator.Overlay => PaintNet.BlendMode.Overlay,
+ CompositeOperator.Difference => PaintNet.BlendMode.Difference,
+ CompositeOperator.Lighten => PaintNet.BlendMode.Lighten,
+ CompositeOperator.Darken => PaintNet.BlendMode.Darken,
+ CompositeOperator.Screen => PaintNet.BlendMode.Screen,
+ _ => PaintNet.BlendMode.Normal,
+ };
+ return new Layer(name ?? PsdLayerName(image), canvas, image.Compose != CompositeOperator.No, opacity, blend);
+ }
+
+ ///
+ /// The layer's name when the file's own records could not be matched to the layers: its Unicode name ("luni" in the
+ /// layer's additional information, which ImageMagick keeps as a profile but reads as ASCII only), else its label.
+ ///
+ internal static string PsdLayerName(IMagickImage image)
+ {
+ var info = image.GetProfile("psd:additional-info")?.ToByteArray();
+ for (var p = 0; info is not null && p + 12 <= info.Length;)
+ {
+ var key = Encoding.ASCII.GetString(info, p + 4, 4);
+ var size = (int)Math.Min(BinaryPrimitives.ReadUInt32BigEndian(info.AsSpan(p + 8)), int.MaxValue);
+ p += 12;
+ if (size > info.Length - p)
+ break;
+ if (key == "luni" && size >= 4)
+ {
+ var length = BinaryPrimitives.ReadUInt32BigEndian(info.AsSpan(p));
+ if (length <= (size - 4) / 2)
+ return Encoding.BigEndianUnicode.GetString(info, p + 4, (int)length * 2).TrimEnd('\0');
+ }
+ p += size;
+ }
+ return string.IsNullOrEmpty(image.Label) ? "Layer" : image.Label;
+ }
+
+ /// The pixels of as BGRA bytes, with an alpha channel and in sRGB.
+ public static byte[] ToBgra(IMagickImage image)
+ {
+ using var copy = image.Clone();
+ if (copy.ColorSpace != ColorSpace.sRGB)
+ copy.TransformColorSpace(ColorProfiles.SRGB);
+ copy.ColorType = ColorType.TrueColorAlpha;
+ using var pixels = copy.GetPixelsUnsafe();
+ return pixels.ToByteArray(PixelMapping.BGRA)!;
+ }
+
+ /// Writes a .pdn of , bottom first, each × .
+ public static void Write(string path, int width, int height, IReadOnlyList layers)
+ {
+ if (width is < 1 or > MaxSide || height is < 1 or > MaxSide)
+ throw new InvalidOperationException(
+ $"Paint.NET opens pictures of at most {MaxSide:N0} pixels on a side; this one is {width:N0} × {height:N0}.");
+ if (layers.Count == 0 || layers.Any(l => l.Bgra.Length != width * height * 4))
+ throw new ArgumentException("Every layer must cover the canvas.", nameof(layers));
+
+ using var file = File.Create(path);
+ var xml = Encoding.UTF8.GetBytes(
+ $"" +
+ $"");
+ file.Write("PDN3"u8);
+ file.Write([(byte)xml.Length, (byte)(xml.Length >> 8), (byte)(xml.Length >> 16)]);
+ file.Write(xml);
+ file.Write([0x00, 0x01]);
+
+ new Serializer(file).Write(Document(width, height, layers));
+
+ foreach (var layer in layers)
+ WritePixels(file, layer.Bgra);
+ }
+
+ /// The picture as Paint.NET shows it, at most 256 pixels on a side, as base64 PNG.
+ private static string Thumbnail(int width, int height, IReadOnlyList layers)
+ {
+ var canvas = new byte[width * height * 4];
+ foreach (var layer in layers.Where(l => l.Visible))
+ PaintNet.Blend(canvas, layer.Bgra, layer.Opacity, layer.Blend);
+ using var image = new MagickImage();
+ image.ReadPixels(canvas, new PixelReadSettings((uint)width, (uint)height, StorageType.Char, PixelMapping.BGRA));
+ if (Math.Max(width, height) > ThumbnailSide)
+ image.Thumbnail(new MagickGeometry(ThumbnailSide, ThumbnailSide));
+ return Convert.ToBase64String(image.ToByteArray(MagickFormat.Png32));
+ }
+
+ /// One layer's pixels: format 0 (gzip), the chunk size, then each chunk's number, length and bytes.
+ private static void WritePixels(Stream file, byte[] bgra)
+ {
+ file.WriteByte(0);
+ WriteUInt32(file, ChunkSize);
+ using var packed = new MemoryStream();
+ for (var chunk = 0; chunk * (long)ChunkSize < bgra.Length; chunk++)
+ {
+ var offset = chunk * ChunkSize;
+ packed.SetLength(0);
+ using (var gzip = new GZipStream(packed, CompressionLevel.Fastest, leaveOpen: true))
+ gzip.Write(bgra, offset, Math.Min(ChunkSize, bgra.Length - offset));
+ WriteUInt32(file, (uint)chunk);
+ WriteUInt32(file, (uint)packed.Length);
+ packed.WriteTo(file);
+ }
+ }
+
+ private static void WriteUInt32(Stream stream, uint value)
+ {
+ Span bytes = stackalloc byte[4];
+ BinaryPrimitives.WriteUInt32BigEndian(bytes, value);
+ stream.Write(bytes);
+ }
+
+ /// Paint.NET's Document and everything under it, as the object graph BinaryFormatter walks.
+ private static Obj Document(int width, int height, IReadOnlyList layers)
+ {
+ // One empty metadata list shared by the layers, as in Paint.NET's own files; the document has its own.
+ var noMetadata = new EmptyArray(StringPairs);
+ var document = new Obj("PaintDotNet.Document", DataLibrary);
+ var list = new Obj("PaintDotNet.LayerList", DataLibrary);
+ var items = new ObjectArray([.. layers.Select((layer, i) => (object)BitmapLayer(layer, i == 0, width, height, noMetadata))]);
+ list.Members.AddRange(
+ [
+ new("parent", new Ref(document)),
+ new("ArrayList+_items", new Ref(items)),
+ new("ArrayList+_size", layers.Count),
+ new("ArrayList+_version", layers.Count),
+ ]);
+ var version = new Obj("System.Version", null);
+ var parts = SavedWith.Split('.').Select(int.Parse).ToArray();
+ version.Members.AddRange(
+ [
+ new("_Major", parts[0]), new("_Minor", parts[1]), new("_Build", parts[2]), new("_Revision", parts[3]),
+ ]);
+ document.Members.AddRange(
+ [
+ new("isDisposed", false),
+ new("layers", new Ref(list)),
+ new("width", width),
+ new("height", height),
+ new("savedWith", new Ref(version)),
+ new("userMetadataItems", new Ref(new EmptyArray(StringPairs))),
+ ]);
+ return document;
+ }
+
+ private static Obj BitmapLayer(Layer layer, bool background, int width, int height, EmptyArray noMetadata)
+ {
+ var blendOp = new Obj($"PaintDotNet.UserBlendOps+{layer.Blend}BlendOp", DataLibrary);
+ var bitmapProperties = new Obj("PaintDotNet.BitmapLayer+BitmapLayerProperties", DataLibrary);
+ bitmapProperties.Members.Add(new("blendOp", new Ref(blendOp)));
+
+ var memory = new Obj("PaintDotNet.MemoryBlock", CoreLibrary);
+ memory.Members.AddRange([new("length64", (long)width * height * 4), new("hasParent", false), new("deferred", true)]);
+ var surface = new Obj("PaintDotNet.Surface", CoreLibrary);
+ surface.Members.AddRange(
+ [new("width", width), new("height", height), new("stride", width * 4), new("scan0", new Ref(memory))]);
+
+ var mode = new Obj("PaintDotNet.LayerBlendMode", DataLibrary);
+ mode.Members.Add(new("value__", (int)layer.Blend));
+ var properties = new Obj("PaintDotNet.Layer+LayerProperties", DataLibrary);
+ properties.Members.AddRange(
+ [
+ new("name", layer.Name),
+ new("userMetadataItems", new Ref(noMetadata)),
+ new("visible", layer.Visible),
+ new("isBackground", background),
+ new("opacity", layer.Opacity),
+ new("blendMode", mode), // an enum: written inline
+ ]);
+
+ var bitmapLayer = new Obj("PaintDotNet.BitmapLayer", DataLibrary);
+ bitmapLayer.Members.AddRange(
+ [
+ new("properties", new Ref(bitmapProperties)),
+ new("surface", new Ref(surface)),
+ new("Layer+isDisposed", false),
+ new("Layer+width", width),
+ new("Layer+height", height),
+ new("Layer+properties", new Ref(properties)),
+ ]);
+ return bitmapLayer;
+ }
+
+ /// An object of a class: its type, its assembly (null for mscorlib) and its fields in order.
+ private sealed class Obj(string type, string? library)
+ {
+ public string Type { get; } = type;
+ public string? Library { get; } = library;
+ public List Members { get; } = [];
+ }
+
+ /// A field: a primitive, a string, an inline value (an enum's ) or a .
+ private sealed record Member(string Name, object Value);
+
+ /// A reference to an object written later, as BinaryFormatter queues them.
+ private sealed record Ref(object Target);
+
+ /// An object[] (an ArrayList's items).
+ private sealed record ObjectArray(object[] Items);
+
+ /// An empty array of a system type (Paint.NET's metadata lists).
+ private sealed record EmptyArray(string ElementType);
+
+ /// NRBF writer for the object graph above (MS-NRBF records).
+ private sealed class Serializer(Stream stream)
+ {
+ private readonly BinaryWriter _writer = new(stream, Encoding.UTF8, leaveOpen: true);
+ private readonly Queue