From f2d432d2d197c4287b1d029464a322ad16cb79e2 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 09:44:27 +0000 Subject: [PATCH 1/3] feat(image): read Paint.NET .pdn files, and keep their layers as PSD 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 Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o --- Directory.Packages.props | 2 + README.md | 2 +- THIRD-PARTY-NOTICES.md | 1 + docs/ENGINES.md | 11 +- src/Filee.Core/Formats/FormatFields.cs | 6 +- src/Filee.Core/Formats/FormatRegistry.cs | 2 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + src/Filee.Engines/Filee.Engines.csproj | 1 + src/Filee.Engines/Magick/ImageEncoder.cs | 4 +- src/Filee.Engines/Magick/ImageReader.cs | 6 +- .../Magick/MagickImageConverter.cs | 3 + src/Filee.Engines/Magick/PaintNet.cs | 496 ++++++++++++++++++ tests/Filee.Core.Tests/FormatFieldsTests.cs | 8 +- tests/Filee.Core.Tests/PaintNetFormatTests.cs | 47 ++ tests/Filee.Engines.Tests/PaintNetTests.cs | 144 +++++ tests/Filee.Engines.Tests/PdnBuilder.cs | 163 ++++++ 17 files changed, 888 insertions(+), 14 deletions(-) create mode 100644 src/Filee.Engines/Magick/PaintNet.cs create mode 100644 tests/Filee.Core.Tests/PaintNetFormatTests.cs create mode 100644 tests/Filee.Engines.Tests/PaintNetTests.cs create mode 100644 tests/Filee.Engines.Tests/PdnBuilder.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f17c97ca4..caf0d72a4 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -40,6 +40,8 @@ + + diff --git a/README.md b/README.md index f4800749c..5a198d336 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,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) 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) 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 | | **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 9524e4a0d..afceefb9d 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -40,6 +40,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 48aa8f65f..e9458d6d7 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 and the icon of a Windows program or library (EXE, DLL) 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: layers merged as Paint.NET shows them, or kept as PSD/PSB layers) and the icon of a Windows program or library (EXE, DLL) 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 | @@ -337,6 +337,15 @@ 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. - **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 dae0079d1..bbae719c1 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", "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 9cfb125fc..9c52e3201 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -63,6 +63,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: its layers merged as Paint.NET shows them, or kept as layers in a PSD. + new("pdn", "Paint.NET (PDN)", FormatCategory.Image, ["pdn"], Writable: false), // 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 3089b2e56..a9067c42e 100644 --- a/src/Filee.Core/Settings/AppSettings.cs +++ b/src/Filee.Core/Settings/AppSettings.cs @@ -139,7 +139,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 = 78; + public const int CurrentSchemaVersion = 79; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d8d7825a8..0e123ecc5 100644 --- a/src/Filee.Core/Settings/UserDataStore.cs +++ b/src/Filee.Core/Settings/UserDataStore.cs @@ -1089,6 +1089,10 @@ 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 1640d3914..b3faef19e 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -90,9 +90,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 diff --git a/src/Filee.Engines/Magick/ImageReader.cs b/src/Filee.Engines/Magick/ImageReader.cs index eba20fb9b..e35ea5919 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; @@ -33,6 +33,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 6752a7b0b..14a873c10 100644 --- a/src/Filee.Engines/Magick/MagickImageConverter.cs +++ b/src/Filee.Engines/Magick/MagickImageConverter.cs @@ -93,6 +93,9 @@ 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; + + 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; + if ((long)width * height * 4 > Array.MaxLength) + throw new InvalidDataException("This Paint.NET picture is too large to convert."); + var layers = Layers(document); + 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); + 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 reader = new BinaryReader(file); + // Reads a section's length and moves past the section. + void Skip() + { + var length = BinaryPrimitives.ReadUInt32BigEndian(reader.ReadBytes(4)); + file.Position += length; + } + file.Position = 26; // file header + Skip(); // colour mode data + Skip(); // image resources + file.Position += psb ? 16 : 8; // layer and mask information length, layer information length + var count = Math.Abs(BinaryPrimitives.ReadInt16BigEndian(reader.ReadBytes(2))); + if (count != visible.Count) + return; + var flags = new List(count); + for (var i = 0; i < count; i++) + { + file.Position += 16; // bounds + var channels = BinaryPrimitives.ReadUInt16BigEndian(reader.ReadBytes(2)); + file.Position += channels * (psb ? 10 : 6); + if (!reader.ReadBytes(4).AsSpan().SequenceEqual("8BIM"u8)) + return; + file.Position += 6; // blend mode, opacity, clipping + flags.Add(file.Position); + file.Position += 2; // flags, filler + Skip(); // extra data: mask, blending ranges, name, additional information + if (file.Position > file.Length) + return; + } + for (var i = 0; i < count; i++) + { + file.Position = flags[i]; + 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/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index 39c64a174..d9e0e8c4f 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -32,7 +32,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => "hdr", "htmlz", "icns", "ipynb", "jar", "jp2", "jsonl", "kml", "kmz", "kra", "lha", "lit", "lrf", "lwp", "lz", "lzma", "m4s", "man", "mcaddon", "mcpack", "mctemplate", "mcw", "mcworld", "mediawiki", "miff", "mng", "mobi", "mod", "mp2", "mpc", "mrpack", "mxf", "obj", "oeb", - "org", "osm", "otf", "pam", "pcd", "pcx", "pdb", "pfm", "plist", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", + "org", "osm", "otf", "pam", "pcd", "pcx", "pdb", "pdn", "pfm", "plist", "plt", "ply", "pml", "ppm", "prc", "ps", "psb", "psd", "psw", "qoi", "ras", "raw", "rb", "rm", "rpm", "rst", "scc", "sda", "sdc", "sdw", "sgi", "shp", "snb", "spx", "ssa", "stl", "sub", "swf", "tak", "tar", "tar7z", "tbz2", "tcr", "tex", "textile", "tgz", "tlz", "toml", "tta", "ttf", "ttml", "txtz", "txz", "typ", "tz", "usm", "vob", "voc", "w64", "wbmp", "wmf", "woff", "woff2", @@ -46,7 +46,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "osm", "shp"] }, { "font", ["otf", "ttf", "woff", "woff2"] }, { "game", ["bik", "dds", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "usm"] }, - { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "kra", "plt", "ps", "psb", "psd", "raw", "xcf"] }, + { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "kra", "pdn", "plt", "ps", "psb", "psd", "raw", "xcf"] }, { "science", ["dcm", "fits"] }, { "developer", [ "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", @@ -73,7 +73,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "geo", ["fit", "geojson", "gpx", "kml", "kmz", "osm", "shp"] }, { "font", ["eot", "fon", "otf", "ttf", "woff", "woff2"] }, { "game", ["bik", "dds", "flc", "mcaddon", "mcpack", "mctemplate", "mcworld", "mrpack", "tga", "usm"] }, - { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "kra", "pcd", "plt", "ps", "psb", "psd", "raw", "xcf"] }, + { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "kra", "pcd", "pdn", "plt", "ps", "psb", "psd", "raw", "xcf"] }, { "science", ["dcm", "fits", "ipynb", "jp2"] }, { "developer", [ "bz2", "cab", "caf", "chm", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", @@ -145,7 +145,7 @@ public void Grade_counts_match_the_published_table() { [FormatGrade.Everyday] = 19, [FormatGrade.General] = 96, - [FormatGrade.Specialist] = 115, + [FormatGrade.Specialist] = 116, [FormatGrade.Legacy] = 51, }, counts); diff --git a/tests/Filee.Core.Tests/PaintNetFormatTests.cs b/tests/Filee.Core.Tests/PaintNetFormatTests.cs new file mode 100644 index 000000000..2b4407eb9 --- /dev/null +++ b/tests/Filee.Core.Tests/PaintNetFormatTests.cs @@ -0,0 +1,47 @@ +// Paint.NET pictures (.pdn) are read as their merged picture, or as layers for PSD: they open the images donut, which +// existing libraries learn without taking a donut of the user's own. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class PaintNetFormatTests +{ + [Theory] + [InlineData("drawing.pdn")] + [InlineData("LOGO.PDN")] + public void A_pdn_uses_the_image_donut(string file) + { + Assert.Same(FormatRegistry.Get("pdn"), FormatRegistry.Detect(file)); + Assert.Equal("images", ProfileSelector.Select(BuiltInData.CreateProfiles(), [file])?.Id); + } + + [Fact] + public void A_pdn_is_read_only() + { + var pdn = FormatRegistry.Get("pdn")!; + Assert.Equal(FormatCategory.Image, pdn.Category); + Assert.False(pdn.Writable); + } + + [Fact] + public void Version_78_libraries_learn_pdn_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var images = profiles.Single(p => p.Id == "images"); + images.Extensions.Remove("pdn"); + Assert.True(SettingsMigrations.ApplyToLibrary(78, presets, profiles)); + Assert.Contains("pdn", images.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles)); + + images.Extensions.Remove("pdn"); + profiles.Insert(0, new ToolbarProfile { Id = "my-paint", Extensions = ["PDN"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(78, presets, profiles)); + Assert.DoesNotContain("pdn", images.Extensions); // the user's own donut has it + Assert.Equal("my-paint", ProfileSelector.Select(profiles, ["drawing.pdn"])?.Id); + } +} diff --git a/tests/Filee.Engines.Tests/PaintNetTests.cs b/tests/Filee.Engines.Tests/PaintNetTests.cs new file mode 100644 index 000000000..4327f100c --- /dev/null +++ b/tests/Filee.Engines.Tests/PaintNetTests.cs @@ -0,0 +1,144 @@ +// Paint.NET's .pdn: merged as Paint.NET shows it for PNG, JPG and the other picture targets, and kept as layers (names, +// opacity, blend mode, visibility) for PSD, which Photoshop, GIMP and Krita open. The files are built by PdnBuilder. + +using System.Text; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using ImageMagick; + +namespace Filee.Engines.Tests; + +public class PaintNetTests(EngineFixture fx) : IClassFixture +{ + private const int Width = 16, Height = 8; + + /// + /// An orange background (R 200, G 100, B 50), a light blue layer over its left half in Multiply at half opacity and a + /// hidden red layer, saved as . + /// + private string Pdn(string name = "drawing.pdn") + { + var half = PdnBuilder.Fill(Width, Height, 255, 200, 100, 255); + for (var y = 0; y < Height; y++) + Array.Clear(half, (y * Width + Width / 2) * 4, Width / 2 * 4); + byte[] bytes = PdnBuilder.Build(Width, Height, + [ + new("Arrière-plan", PdnBuilder.Fill(Width, Height, 50, 100, 200, 255)), + new("Calque 2", half, Opacity: 128, BlendMode: 1), + new("Hidden", PdnBuilder.Fill(Width, Height, 0, 0, 255, 255), Visible: false), + ]); + var path = Path.Combine(fx.NewFolder(), name); + File.WriteAllBytes(path, bytes); + return path; + } + + private static string Errors(ConversionJob job) => + string.Join("; ", job.Files.Select(f => $"{f.ErrorKey}: {f.ErrorDetail}")); + + private static void AssertColour(IMagickImage image, int x, int y, int r, int g, int b) + { + using var pixels = image.GetPixels(); + var colour = pixels.GetPixel(x, y)!.ToColor()!; + Assert.InRange(colour.R, r - 1, r + 1); + Assert.InRange(colour.G, g - 1, g + 1); + Assert.InRange(colour.B, b - 1, b + 1); + Assert.Equal(255, colour.A); + } + + [Fact] + public async Task A_pdn_converts_to_png_as_paint_net_shows_it() + { + var pdn = Pdn(); + Assert.Equal("pdn", FormatRegistry.Detect(pdn)!.Id); + + var job = await fx.ConvertAsync([pdn], new Preset { TargetFormat = "png" }); + Assert.True(job.State == JobState.Completed, Errors(job)); + var output = Assert.Single(job.Outputs); + Assert.Equal("drawing.png", Path.GetFileName(output)); + using var png = new MagickImage(output); + Assert.Equal((16u, 8u), (png.Width, png.Height)); + // Multiply at half opacity: halfway between the background and background × layer (200 × 100 / 255 = 78, …). + AssertColour(png, 2, 4, 139, 89, 50); + // The background alone on the right; the hidden red layer is not drawn. + AssertColour(png, 12, 4, 200, 100, 50); + } + + [Theory] + [InlineData("jpg")] + [InlineData("webp")] + [InlineData("pdf")] + public async Task A_pdn_reaches_other_picture_targets(string target) + { + var job = await fx.ConvertAsync([Pdn()], new Preset { TargetFormat = target }); + Assert.True(job.State == JobState.Completed, Errors(job)); + Assert.EndsWith("." + target, Assert.Single(job.Outputs)); + } + + [Fact] + public async Task A_pdn_as_psd_keeps_its_layers() + { + var job = await fx.ConvertAsync([Pdn()], new Preset { TargetFormat = "psd" }); + Assert.True(job.State == JobState.Completed, Errors(job)); + var output = Assert.Single(job.Outputs); + + // The merged picture first, then one layer per Paint.NET layer, bottom first. + using var psd = new MagickImageCollection(output); + Assert.Equal(4, psd.Count); + AssertColour(psd[0], 2, 4, 139, 89, 50); + Assert.Equal("Calque 2", psd[2].Label); + Assert.Equal(CompositeOperator.Multiply, psd[2].Compose); + Assert.Equal("Hidden", psd[3].Label); + Assert.Equal(CompositeOperator.No, psd[3].Compose); // hidden + + // PSD's own name field holds ASCII; the Unicode name follows it, which Photoshop, GIMP and Krita show. + var bytes = File.ReadAllBytes(output); + Assert.True(bytes.AsSpan().IndexOf("\u000cArriere-plan"u8) > 0); + Assert.True(bytes.AsSpan().IndexOf("8BIMluni"u8) > 0); + Assert.True(bytes.AsSpan().IndexOf(Encoding.BigEndianUnicode.GetBytes("Arrière-plan")) > 0); + + // The layer keeps its own colour; its opacity is the layer's, not merged into the picture. + using var pixels = psd[2].GetPixels(); + var layerColour = pixels.GetPixel(2, 4)!.ToColor()!; + Assert.Equal((100, 200, 255), (layerColour.R, layerColour.G, layerColour.B)); + Assert.Equal("128", psd[2].GetArtifact("psd:layer.opacity")); + } + + [Fact] + public async Task A_pdn_resized_to_psd_is_one_merged_picture() + { + var preset = new Preset { TargetFormat = "psd" }; + preset.Image.Resize = ResizeMode.Percent; + preset.Image.ResizePercent = 50; + var job = await fx.ConvertAsync([Pdn()], preset); + Assert.True(job.State == JobState.Completed, Errors(job)); + + using var psd = new MagickImageCollection(Assert.Single(job.Outputs)); + Assert.Equal((8u, 4u), (psd[0].Width, psd[0].Height)); + Assert.True(psd.Count <= 2); // the picture, and at most ImageMagick's one layer of it + } + + [Fact] + public async Task A_damaged_pdn_says_so() + { + var pdn = Pdn(); + var bytes = File.ReadAllBytes(pdn); + File.WriteAllBytes(pdn, bytes[..(bytes.Length / 2)]); + + var job = await fx.ConvertAsync([pdn], new Preset { TargetFormat = "png" }); + var file = Assert.Single(job.Files); + Assert.Equal(FileState.Failed, file.State); + } + + [Fact] + public async Task A_file_that_is_not_a_pdn_says_so() + { + var path = Path.Combine(fx.NewFolder(), "fake.pdn"); + File.WriteAllText(path, "not a Paint.NET picture"); + + var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "png" }); + var file = Assert.Single(job.Files); + Assert.Equal(FileState.Failed, file.State); + Assert.Contains("Paint.NET", file.ErrorDetail); + } +} diff --git a/tests/Filee.Engines.Tests/PdnBuilder.cs b/tests/Filee.Engines.Tests/PdnBuilder.cs new file mode 100644 index 000000000..81d91cd7e --- /dev/null +++ b/tests/Filee.Engines.Tests/PdnBuilder.cs @@ -0,0 +1,163 @@ +// Builds Paint.NET .pdn files for the tests as Paint.NET 4 and 5 lay them out: "PDN3", the XML header, the document in +// .NET binary serialization (written record by record here, as BinaryFormatter would) and every layer's BGRA pixels in +// gzip-compressed chunks, written in reverse order as Paint.NET's parallel writer may. + +using System.Buffers.Binary; +using System.IO.Compression; +using System.Text; + +namespace Filee.Engines.Tests; + +internal static class PdnBuilder +{ + /// A layer of the test picture: its properties and its pixels (BGRA, top row first). + public sealed record Layer(string Name, byte[] Bgra, bool Visible = true, byte Opacity = 255, int BlendMode = 0); + + private const string DataLibrary = "PaintDotNet.Data, Version=5.1.0.0, Culture=neutral, PublicKeyToken=null"; + private const string CoreLibrary = "PaintDotNet.Core, Version=5.1.0.0, Culture=neutral, PublicKeyToken=null"; + + /// Pixels of one colour (BGRA order) over a whole width x height layer. + public static byte[] Fill(int width, int height, byte b, byte g, byte r, byte a) + { + var pixels = new byte[width * height * 4]; + for (var i = 0; i < pixels.Length; i += 4) + (pixels[i], pixels[i + 1], pixels[i + 2], pixels[i + 3]) = (b, g, r, a); + return pixels; + } + + /// The bytes of a .pdn holding , bottom first. + /// Bytes of a layer per chunk; small ones make several chunks of a small picture. + public static byte[] Build(int width, int height, IReadOnlyList layers, int chunkSize = 100) + { + using var file = new MemoryStream(); + 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]); + + var nrbf = new BinaryWriter(file, Encoding.UTF8, leaveOpen: true); + var ids = 10; + // SerializedStreamHeader: root object 1, header -1, version 1.0. + nrbf.Write((byte)0); + nrbf.Write(1); + nrbf.Write(-1); + nrbf.Write(1); + nrbf.Write(0); + Library(nrbf, 2, DataLibrary); + Library(nrbf, 3, CoreLibrary); + + // Document { layers: LayerList, width, height } + ClassHeader(nrbf, 1, "PaintDotNet.Document", 2, + ("layers", Class("PaintDotNet.LayerList", 2)), ("width", Int32), ("height", Int32)); + // LayerList (an ArrayList) { _items, _size }: the array has room for one more, empty, as an ArrayList grows. + ClassHeader(nrbf, 4, "PaintDotNet.LayerList", 2, ("ArrayList+_items", ObjectArray), ("ArrayList+_size", Int32)); + nrbf.Write((byte)16); // ArraySingleObject + nrbf.Write(5); + nrbf.Write(layers.Count + 1); + foreach (var layer in layers) + { + ClassHeader(nrbf, ids++, "PaintDotNet.BitmapLayer", 2, + ("surface", Class("PaintDotNet.Surface", 3)), + ("Layer+properties", Class("PaintDotNet.Layer+LayerProperties", 2))); + ClassHeader(nrbf, ids++, "PaintDotNet.Surface", 3, + ("width", Int32), ("height", Int32), ("stride", Int32), ("scan0", Class("PaintDotNet.MemoryBlock", 3))); + nrbf.Write(width); + nrbf.Write(height); + nrbf.Write(width * 4); + ClassHeader(nrbf, ids++, "PaintDotNet.MemoryBlock", 3, + ("length64", Int64), ("hasParent", Boolean), ("deferred", Boolean)); + nrbf.Write((long)width * height * 4); + nrbf.Write(false); + nrbf.Write(true); + ClassHeader(nrbf, ids++, "PaintDotNet.Layer+LayerProperties", 2, + ("name", String), ("visible", Boolean), ("isBackground", Boolean), ("opacity", Byte), + ("blendMode", Class("PaintDotNet.LayerBlendMode", 2))); + nrbf.Write((byte)6); // BinaryObjectString + nrbf.Write(ids++); + nrbf.Write(layer.Name); + nrbf.Write(layer.Visible); + nrbf.Write(ReferenceEquals(layer, layers[0])); + nrbf.Write(layer.Opacity); + ClassHeader(nrbf, ids++, "PaintDotNet.LayerBlendMode", 2, ("value__", Int32)); + nrbf.Write(layer.BlendMode); + } + nrbf.Write((byte)10); // ObjectNull: the array's unused room + nrbf.Write(layers.Count); // ArrayList+_size + nrbf.Write(width); + nrbf.Write(height); + nrbf.Write((byte)11); // MessageEnd + nrbf.Flush(); + + foreach (var layer in layers) + { + file.WriteByte(0); // gzip + WriteUInt32(file, (uint)chunkSize); + var chunks = (layer.Bgra.Length + chunkSize - 1) / chunkSize; + for (var chunk = chunks - 1; chunk >= 0; chunk--) + { + var offset = chunk * chunkSize; + using var packed = new MemoryStream(); + using (var gzip = new GZipStream(packed, CompressionLevel.Fastest, leaveOpen: true)) + gzip.Write(layer.Bgra, offset, Math.Min(chunkSize, layer.Bgra.Length - offset)); + WriteUInt32(file, (uint)chunk); + WriteUInt32(file, (uint)packed.Length); + packed.WriteTo(file); + } + } + return file.ToArray(); + } + + // Member types: the BinaryTypeEnumeration and, for primitives and classes, what follows it in MemberTypeInfo. + private static readonly (byte Kind, object? Info) Int32 = (0, (byte)8); + private static readonly (byte Kind, object? Info) Int64 = (0, (byte)9); + private static readonly (byte Kind, object? Info) Boolean = (0, (byte)1); + private static readonly (byte Kind, object? Info) Byte = (0, (byte)2); + private static readonly (byte Kind, object? Info) String = (1, null); + private static readonly (byte Kind, object? Info) ObjectArray = (5, null); + + private static (byte Kind, object? Info) Class(string name, int library) => (4, (name, library)); + + private static void Library(BinaryWriter writer, int id, string name) + { + writer.Write((byte)12); // BinaryLibrary + writer.Write(id); + writer.Write(name); + } + + /// A ClassWithMembersAndTypes record up to its member values, which the caller writes next. + private static void ClassHeader(BinaryWriter writer, int id, string name, int library, + params (string Name, (byte Kind, object? Info) Type)[] members) + { + writer.Write((byte)5); + writer.Write(id); + writer.Write(name); + writer.Write(members.Length); + foreach (var member in members) + writer.Write(member.Name); + foreach (var member in members) + writer.Write(member.Type.Kind); + foreach (var member in members) + { + switch (member.Type.Info) + { + case byte primitive: + writer.Write(primitive); + break; + case (string className, int classLibrary): + writer.Write(className); + writer.Write(classLibrary); + break; + } + } + writer.Write(library); + } + + private static void WriteUInt32(Stream stream, uint value) + { + Span bytes = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32BigEndian(bytes, value); + stream.Write(bytes); + } +} From 253b9c87962b5314bb938872c3222cad68ea9adb Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 10:14:14 +0000 Subject: [PATCH 2/3] feat(image): write Paint.NET .pdn files, a PSD with its layers 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 Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o --- README.md | 2 +- docs/ENGINES.md | 7 +- src/Filee.Core/Formats/FormatRegistry.cs | 4 +- src/Filee.Engines/Magick/ImageEncoder.cs | 13 + .../Magick/MagickImageConverter.cs | 15 +- src/Filee.Engines/Magick/PaintNet.cs | 34 +- src/Filee.Engines/Magick/PaintNetWriter.cs | 485 ++++++++++++++++++ src/Filee.Engines/Magick/PsdLayerRecords.cs | 94 ++++ tests/Filee.Core.Tests/PaintNetFormatTests.cs | 11 +- tests/Filee.Engines.Tests/PaintNetTests.cs | 54 +- 10 files changed, 676 insertions(+), 43 deletions(-) create mode 100644 src/Filee.Engines/Magick/PaintNetWriter.cs create mode 100644 src/Filee.Engines/Magick/PsdLayerRecords.cs diff --git a/README.md b/README.md index 5a198d336..8f4d4532a 100644 --- a/README.md +++ b/README.md @@ -164,7 +164,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), Paint.NET PDN (→ PSD with its layers) 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) 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/docs/ENGINES.md b/docs/ENGINES.md index e9458d6d7..349bcde2f 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, a **Paint.NET PDN** (Filee's own reader: layers merged as Paint.NET shows them, or kept as PSD/PSB layers) and the icon of a Windows program or library (EXE, DLL) 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) and the icon of a Windows program or library (EXE, DLL) 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,11 @@ in it also builds the package (artifact `windows-arm64-package`). 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/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 9c52e3201..483f06bdb 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -63,8 +63,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: its layers merged as Paint.NET shows them, or kept as layers in a PSD. - new("pdn", "Paint.NET (PDN)", FormatCategory.Image, ["pdn"], 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.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index b3faef19e..15fe3460d 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -463,6 +463,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") diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs index 14a873c10..5fbdfbab2 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, @@ -96,6 +100,9 @@ public Task> ConvertAsync(ConversionStep step, IProgress visible, bool psb) { using var file = File.Open(path, FileMode.Open, FileAccess.ReadWrite); - var reader = new BinaryReader(file); - // Reads a section's length and moves past the section. - void Skip() - { - var length = BinaryPrimitives.ReadUInt32BigEndian(reader.ReadBytes(4)); - file.Position += length; - } - file.Position = 26; // file header - Skip(); // colour mode data - Skip(); // image resources - file.Position += psb ? 16 : 8; // layer and mask information length, layer information length - var count = Math.Abs(BinaryPrimitives.ReadInt16BigEndian(reader.ReadBytes(2))); - if (count != visible.Count) + var records = PsdLayerRecords.Read(file, psb); + if (records is null || records.Count != visible.Count) return; - var flags = new List(count); - for (var i = 0; i < count; i++) - { - file.Position += 16; // bounds - var channels = BinaryPrimitives.ReadUInt16BigEndian(reader.ReadBytes(2)); - file.Position += channels * (psb ? 10 : 6); - if (!reader.ReadBytes(4).AsSpan().SequenceEqual("8BIM"u8)) - return; - file.Position += 6; // blend mode, opacity, clipping - flags.Add(file.Position); - file.Position += 2; // flags, filler - Skip(); // extra data: mask, blending ranges, name, additional information - if (file.Position > file.Length) - return; - } - for (var i = 0; i < count; i++) + for (var i = 0; i < records.Count; i++) { - file.Position = flags[i]; + file.Position = records[i].FlagsOffset; file.WriteByte(visible[i] ? (byte)0 : (byte)2); } } 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 _queue = new(); + private readonly Dictionary _ids = new(ReferenceEqualityComparer.Instance); + private readonly Dictionary _libraries = []; + private readonly Dictionary _metadata = []; + private int _next = 1; + + public void Write(Obj root) + { + Id(root); + _writer.Write((byte)0); // SerializedStreamHeader + _writer.Write(1); + _writer.Write(-1); + _writer.Write(1); + _writer.Write(0); + _queue.Enqueue(root); + while (_queue.TryDequeue(out var next)) + { + switch (next) + { + case Obj obj: + WriteObject(obj, _ids[obj]); + break; + case ObjectArray array: + _writer.Write((byte)16); // ArraySingleObject + _writer.Write(_ids[array]); + _writer.Write(array.Items.Length); + foreach (var item in array.Items) + WriteReference(item); + break; + case EmptyArray empty: + _writer.Write((byte)7); // BinaryArray + _writer.Write(_ids[empty]); + _writer.Write((byte)0); // single-dimensional + _writer.Write(1); // rank + _writer.Write(0); // length + _writer.Write((byte)3); // SystemClass + _writer.Write(empty.ElementType); + break; + } + } + _writer.Write((byte)11); // MessageEnd + _writer.Flush(); + } + + private int Id(object target) + { + if (!_ids.TryGetValue(target, out var id)) + _ids[target] = id = _next++; + return id; + } + + private void WriteReference(object target) + { + if (!_ids.ContainsKey(target)) + { + Id(target); + _queue.Enqueue(target); + } + _writer.Write((byte)9); // MemberReference + _writer.Write(_ids[target]); + } + + private int Library(string name) + { + if (_libraries.TryGetValue(name, out var id)) + return id; + id = _libraries[name] = _next++; + _writer.Write((byte)12); // BinaryLibrary + _writer.Write(id); + _writer.Write(name); + return id; + } + + /// + /// A class record: the full metadata the first time a type is written with these member types, after that + /// ClassWithId pointing at it. Values follow; objects inside are queued, enums written inline. + /// + private void WriteObject(Obj obj, int id) + { + var types = obj.Members.Select(m => MemberType(m.Value)).ToList(); + foreach (var library in types.Select(t => t.Library).Append(obj.Library).OfType()) + Library(library); + var signature = obj.Type + "|" + string.Join("|", types.Select(t => $"{t.Kind}:{t.Info}:{t.Library}")); + if (_metadata.TryGetValue(signature, out var metadata)) + { + _writer.Write((byte)1); // ClassWithId + _writer.Write(id); + _writer.Write(metadata); + } + else + { + _metadata[signature] = id; + _writer.Write(obj.Library is null ? (byte)4 : (byte)5); // (System)ClassWithMembersAndTypes + _writer.Write(id); + _writer.Write(obj.Type); + _writer.Write(obj.Members.Count); + foreach (var member in obj.Members) + _writer.Write(member.Name); + foreach (var type in types) + _writer.Write(type.Kind); + foreach (var type in types) + { + switch (type.Kind) + { + case 0: + _writer.Write((byte)type.Info!); + break; + case 3: + _writer.Write((string)type.Info!); + break; + case 4: + _writer.Write((string)type.Info!); + _writer.Write(_libraries[type.Library!]); + break; + } + } + if (obj.Library is not null) + _writer.Write(_libraries[obj.Library]); + } + + foreach (var member in obj.Members) + { + switch (member.Value) + { + case bool flag: + _writer.Write(flag); + break; + case byte number: + _writer.Write(number); + break; + case int number: + _writer.Write(number); + break; + case long number: + _writer.Write(number); + break; + case string text: + _writer.Write((byte)6); // BinaryObjectString + _writer.Write(_next++); + _writer.Write(text); + break; + case Obj inline: + // A value type (an enum) is written in place, under an id of its own made negative. + WriteObject(inline, -_next++); + break; + case Ref reference: + WriteReference(reference.Target); + break; + } + } + } + + /// BinaryTypeEnumeration and what follows it in MemberTypeInfo, with the library of a class. + private static (byte Kind, object? Info, string? Library) MemberType(object value) => value switch + { + bool => (0, (byte)1, null), + byte => (0, (byte)2, null), + int => (0, (byte)8, null), + long => (0, (byte)9, null), + string => (1, null, null), + Obj { Library: null } inline => (3, inline.Type, null), + Obj inline => (4, inline.Type, inline.Library), + Ref { Target: Obj { Library: null } target } => (3, target.Type, null), + Ref { Target: Obj target } => (4, target.Type, target.Library), + Ref { Target: ObjectArray } => (5, null, null), + Ref { Target: EmptyArray empty } => (3, empty.ElementType + "[]", null), + _ => throw new NotSupportedException(value.GetType().Name), + }; + } +} diff --git a/src/Filee.Engines/Magick/PsdLayerRecords.cs b/src/Filee.Engines/Magick/PsdLayerRecords.cs new file mode 100644 index 000000000..2cb70509a --- /dev/null +++ b/src/Filee.Engines/Magick/PsdLayerRecords.cs @@ -0,0 +1,94 @@ +// The layer records of a PSD or PSB (Adobe's layer and mask information section), read directly: each layer's name and +// where its flags byte is. ImageMagick reads a Unicode name ("luni") as ASCII only and a name in the Pascal field as +// UTF-8, though Photoshop writes it in Mac Roman, so "Arrière-plan" comes back damaged; Filee reads both itself. Used +// when a PSD becomes a Paint.NET file (PaintNetWriter) and to set the flags ImageMagick writes wrong (PaintNet). + +using System.Buffers.Binary; +using System.Text; + +namespace Filee.Engines.Magick; + +internal static class PsdLayerRecords +{ + /// A layer record: the offset of its flags byte in the file, and its name. + internal sealed record Record(long FlagsOffset, string Name); + + /// Additional information whose length is 8 bytes in a PSB (4 in a PSD), from Adobe's file format spec. + private static readonly HashSet LongKeysInPsb = + ["LMsk", "Lr16", "Lr32", "Layr", "Mt16", "Mt32", "Mtrn", "Alph", "FMsk", "lnk2", "FEid", "FXid", "PxSD"]; + + static PsdLayerRecords() => Encoding.RegisterProvider(CodePagesEncodingProvider.Instance); + + /// The layer records bottom first, or null when the file is not laid out as a PSD/PSB should be. + public static List? Read(Stream file, bool psb) + { + try + { + return ReadRecords(file, psb); + } + catch (EndOfStreamException) + { + return null; + } + } + + private static List? ReadRecords(Stream file, bool psb) + { + var reader = new BinaryReader(file, Encoding.ASCII, leaveOpen: true); + uint UInt32() => BinaryPrimitives.ReadUInt32BigEndian(reader.ReadBytes(4)); + void Skip(long length) => file.Position += length; + + file.Position = 0; + if (!reader.ReadBytes(4).AsSpan().SequenceEqual("8BPS"u8)) + return null; + file.Position = 26; // file header + Skip(UInt32()); // colour mode data + Skip(UInt32()); // image resources + Skip(psb ? 16 : 8); // layer and mask information length, layer information length + var count = Math.Abs(BinaryPrimitives.ReadInt16BigEndian(reader.ReadBytes(2))); + var records = new List(count); + for (var i = 0; i < count; i++) + { + Skip(16); // bounds + var channels = BinaryPrimitives.ReadUInt16BigEndian(reader.ReadBytes(2)); + Skip(channels * (psb ? 10 : 6)); + if (!reader.ReadBytes(4).AsSpan().SequenceEqual("8BIM"u8)) + return null; + Skip(6); // blend mode, opacity, clipping + var flags = file.Position; + Skip(2); // flags, filler + var extraLength = UInt32(); + var end = file.Position + extraLength; + if (end > file.Length) + return null; + Skip(UInt32()); // layer mask data + Skip(UInt32()); // blending ranges + var nameLength = reader.ReadByte(); + var name = Encoding.GetEncoding(10000).GetString(reader.ReadBytes(nameLength)); // Mac Roman + Skip((4 - (nameLength + 1) % 4) % 4); + while (file.Position + 12 <= end) + { + var signature = Encoding.ASCII.GetString(reader.ReadBytes(4)); + if (signature is not ("8BIM" or "8B64")) + break; + var key = Encoding.ASCII.GetString(reader.ReadBytes(4)); + var length = psb && LongKeysInPsb.Contains(key) + ? (long)BinaryPrimitives.ReadUInt64BigEndian(reader.ReadBytes(8)) + : UInt32(); + if (length < 0 || file.Position + length > end) + break; + if (key == "luni" && length >= 4) + { + var characters = UInt32(); + if (characters <= (length - 4) / 2) + name = Encoding.BigEndianUnicode.GetString(reader.ReadBytes((int)characters * 2)).TrimEnd('\0'); + break; + } + Skip(length); + } + file.Position = end; + records.Add(new Record(flags, name)); + } + return records; + } +} diff --git a/tests/Filee.Core.Tests/PaintNetFormatTests.cs b/tests/Filee.Core.Tests/PaintNetFormatTests.cs index 2b4407eb9..c795877fe 100644 --- a/tests/Filee.Core.Tests/PaintNetFormatTests.cs +++ b/tests/Filee.Core.Tests/PaintNetFormatTests.cs @@ -1,5 +1,5 @@ -// Paint.NET pictures (.pdn) are read as their merged picture, or as layers for PSD: they open the images donut, which -// existing libraries learn without taking a donut of the user's own. +// Paint.NET pictures (.pdn), read and written by Filee: they open the images donut, which existing libraries learn without +// taking a donut of the user's own, and are a target only where the Design & print field is on. using Filee.Core.Formats; using Filee.Core.Presets; @@ -20,11 +20,14 @@ public void A_pdn_uses_the_image_donut(string file) } [Fact] - public void A_pdn_is_read_only() + public void A_pdn_is_a_design_target() { var pdn = FormatRegistry.Get("pdn")!; Assert.Equal(FormatCategory.Image, pdn.Category); - Assert.False(pdn.Writable); + Assert.True(pdn.Writable); + Assert.Equal(FormatGrade.Specialist, FormatFields.GradeOf(pdn)); + Assert.False(FormatFields.IsShown("pdn", new HashSet { "design" })); + Assert.True(FormatFields.IsShown("pdn", new HashSet())); } [Fact] diff --git a/tests/Filee.Engines.Tests/PaintNetTests.cs b/tests/Filee.Engines.Tests/PaintNetTests.cs index 4327f100c..7b12f07e6 100644 --- a/tests/Filee.Engines.Tests/PaintNetTests.cs +++ b/tests/Filee.Engines.Tests/PaintNetTests.cs @@ -1,5 +1,6 @@ // Paint.NET's .pdn: merged as Paint.NET shows it for PNG, JPG and the other picture targets, and kept as layers (names, -// opacity, blend mode, visibility) for PSD, which Photoshop, GIMP and Krita open. The files are built by PdnBuilder. +// opacity, blend mode, visibility) for PSD, which Photoshop, GIMP and Krita open. The other way, pictures become a .pdn +// of one layer and a PSD a .pdn with its layers. The test files are built by PdnBuilder. using System.Text; using Filee.Core.Conversion; @@ -141,4 +142,55 @@ public async Task A_file_that_is_not_a_pdn_says_so() Assert.Equal(FileState.Failed, file.State); Assert.Contains("Paint.NET", file.ErrorDetail); } + + [Fact] + public async Task A_png_becomes_a_pdn_of_one_layer() + { + var folder = fx.NewFolder(); + var png = Path.Combine(folder, "photo.png"); + using (var picture = new MagickImage(MagickColors.Transparent, 40, 20)) + { + picture.Draw(new ImageMagick.Drawing.Drawables().FillColor(MagickColors.Red).Rectangle(0, 0, 19, 19)); + picture.Write(png); + } + + var job = await fx.ConvertAsync([png], new Preset { TargetFormat = "pdn" }); + Assert.True(job.State == JobState.Completed, Errors(job)); + var pdn = Assert.Single(job.Outputs); + Assert.Equal("photo.pdn", Path.GetFileName(pdn)); + Assert.Equal("PDN3"u8.ToArray(), File.ReadAllBytes(pdn)[..4]); + + var (width, height, layers) = Magick.PaintNet.Open(pdn); + var layer = Assert.Single(layers.ToList()); + Assert.Equal((40, 20), (width, height)); + Assert.Equal(("Background", true, (byte)255), (layer.Name, layer.Visible, layer.Opacity)); + Assert.Equal(new byte[] { 0, 0, 255, 255 }, layer.Bgra[..4]); // red + Assert.Equal(0, layer.Bgra[(10 * 40 + 30) * 4 + 3]); // transparent + } + + [Fact] + public async Task A_pdn_through_psd_and_back_keeps_its_layers() + { + var psdJob = await fx.ConvertAsync([Pdn()], new Preset { TargetFormat = "psd" }); + Assert.True(psdJob.State == JobState.Completed, Errors(psdJob)); + var job = await fx.ConvertAsync([Assert.Single(psdJob.Outputs)], new Preset { TargetFormat = "pdn" }); + Assert.True(job.State == JobState.Completed, Errors(job)); + + var (_, _, read) = Magick.PaintNet.Open(Assert.Single(job.Outputs)); + var layers = read.ToList(); + Assert.Equal( + [("Arrière-plan", true, 255, Magick.PaintNet.BlendMode.Normal), + ("Calque 2", true, 128, Magick.PaintNet.BlendMode.Multiply), + ("Hidden", false, 255, Magick.PaintNet.BlendMode.Normal)], + layers.Select(l => (l.Name, l.Visible, (int)l.Opacity, l.Blend))); + // The layer's own pixels, without its opacity baked in: opaque light blue left, transparent right. + var pixel = (4 * Width + 2) * 4; + Assert.Equal(new byte[] { 255, 200, 100, 255 }, layers[1].Bgra[pixel..(pixel + 4)]); + Assert.Equal(0, layers[1].Bgra[(4 * Width + 12) * 4 + 3]); + + // And it still converts to the same picture. + var png = await fx.ConvertAsync([Assert.Single(job.Outputs)], new Preset { TargetFormat = "png" }); + using var merged = new MagickImage(Assert.Single(png.Outputs)); + AssertColour(merged, 2, 4, 139, 89, 50); + } } From d12f4d9689dd51f1ce5da4f70d074ce76dc56c34 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:01:50 +0000 Subject: [PATCH 3/3] fix(image): PDN in Merge images, a pixel budget, and sturdier PSD records 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 Claude-Session: https://claude.ai/code/session_01BPxh5s9u8aZQzKRbXnqj8o --- src/Filee.Engines/Magick/ImageEncoder.cs | 8 ++- src/Filee.Engines/Magick/ImageJoiner.cs | 3 +- src/Filee.Engines/Magick/PaintNet.cs | 27 +++++++- src/Filee.Engines/Magick/PsdLayerRecords.cs | 32 +++++---- tests/Filee.Engines.Tests/PaintNetTests.cs | 72 +++++++++++++++++++++ tests/Filee.Engines.Tests/PdnBuilder.cs | 6 +- 6 files changed, 130 insertions(+), 18 deletions(-) diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 15fe3460d..a0401088b 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -191,6 +191,7 @@ public static List> SelectFrames(MagickImageCollection images "jpg" or "jpeg" => 65_500, "webp" => 16_383, "avif" => 16_384, + "pdn" => PaintNetWriter.MaxSide, _ => double.PositiveInfinity, }; @@ -603,11 +604,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 1594b4693..a5d1ddeb6 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); progress?.Report(1); diff --git a/src/Filee.Engines/Magick/PaintNet.cs b/src/Filee.Engines/Magick/PaintNet.cs index aed4f8cec..5075d3cf2 100644 --- a/src/Filee.Engines/Magick/PaintNet.cs +++ b/src/Filee.Engines/Magick/PaintNet.cs @@ -32,6 +32,22 @@ internal sealed record Layer(string Name, bool Visible, byte Opacity, BlendMode /// 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."); @@ -64,9 +80,13 @@ private static (int Width, int Height, IEnumerable Layers) Open(FileStrea var height = document.GetInt32("height"); if (width is < 1 or > MaxSide || height is < 1 or > MaxSide) throw Damaged; - if ((long)width * height * 4 > Array.MaxLength) - throw new InvalidDataException("This Paint.NET picture is too large to convert."); 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)); } @@ -269,6 +289,9 @@ private static byte[] ReadLayer(Stream data, int length) 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++) { diff --git a/src/Filee.Engines/Magick/PsdLayerRecords.cs b/src/Filee.Engines/Magick/PsdLayerRecords.cs index 2cb70509a..06a84bd70 100644 --- a/src/Filee.Engines/Magick/PsdLayerRecords.cs +++ b/src/Filee.Engines/Magick/PsdLayerRecords.cs @@ -26,8 +26,9 @@ internal sealed record Record(long FlagsOffset, string Name); { return ReadRecords(file, psb); } - catch (EndOfStreamException) + catch (Exception ex) when (ex is EndOfStreamException or ArgumentException or OverflowException) { + // Not laid out as expected: the caller keeps ImageMagick's names. return null; } } @@ -35,24 +36,31 @@ internal sealed record Record(long FlagsOffset, string Name); private static List? ReadRecords(Stream file, bool psb) { var reader = new BinaryReader(file, Encoding.ASCII, leaveOpen: true); - uint UInt32() => BinaryPrimitives.ReadUInt32BigEndian(reader.ReadBytes(4)); + // Exactly count bytes, or EndOfStreamException: past the end ReadBytes gives fewer, which the parsers below + // would fail on with another exception. + byte[] Bytes(int count) + { + var bytes = reader.ReadBytes(count); + return bytes.Length == count ? bytes : throw new EndOfStreamException(); + } + uint UInt32() => BinaryPrimitives.ReadUInt32BigEndian(Bytes(4)); void Skip(long length) => file.Position += length; file.Position = 0; - if (!reader.ReadBytes(4).AsSpan().SequenceEqual("8BPS"u8)) + if (!Bytes(4).AsSpan().SequenceEqual("8BPS"u8)) return null; file.Position = 26; // file header Skip(UInt32()); // colour mode data Skip(UInt32()); // image resources Skip(psb ? 16 : 8); // layer and mask information length, layer information length - var count = Math.Abs(BinaryPrimitives.ReadInt16BigEndian(reader.ReadBytes(2))); + var count = Math.Abs(BinaryPrimitives.ReadInt16BigEndian(Bytes(2))); var records = new List(count); for (var i = 0; i < count; i++) { Skip(16); // bounds - var channels = BinaryPrimitives.ReadUInt16BigEndian(reader.ReadBytes(2)); + var channels = BinaryPrimitives.ReadUInt16BigEndian(Bytes(2)); Skip(channels * (psb ? 10 : 6)); - if (!reader.ReadBytes(4).AsSpan().SequenceEqual("8BIM"u8)) + if (!Bytes(4).AsSpan().SequenceEqual("8BIM"u8)) return null; Skip(6); // blend mode, opacity, clipping var flags = file.Position; @@ -63,17 +71,17 @@ internal sealed record Record(long FlagsOffset, string Name); return null; Skip(UInt32()); // layer mask data Skip(UInt32()); // blending ranges - var nameLength = reader.ReadByte(); - var name = Encoding.GetEncoding(10000).GetString(reader.ReadBytes(nameLength)); // Mac Roman + var nameLength = Bytes(1)[0]; + var name = Encoding.GetEncoding(10000).GetString(Bytes(nameLength)); // Mac Roman Skip((4 - (nameLength + 1) % 4) % 4); while (file.Position + 12 <= end) { - var signature = Encoding.ASCII.GetString(reader.ReadBytes(4)); + var signature = Encoding.ASCII.GetString(Bytes(4)); if (signature is not ("8BIM" or "8B64")) break; - var key = Encoding.ASCII.GetString(reader.ReadBytes(4)); + var key = Encoding.ASCII.GetString(Bytes(4)); var length = psb && LongKeysInPsb.Contains(key) - ? (long)BinaryPrimitives.ReadUInt64BigEndian(reader.ReadBytes(8)) + ? (long)BinaryPrimitives.ReadUInt64BigEndian(Bytes(8)) : UInt32(); if (length < 0 || file.Position + length > end) break; @@ -81,7 +89,7 @@ internal sealed record Record(long FlagsOffset, string Name); { var characters = UInt32(); if (characters <= (length - 4) / 2) - name = Encoding.BigEndianUnicode.GetString(reader.ReadBytes((int)characters * 2)).TrimEnd('\0'); + name = Encoding.BigEndianUnicode.GetString(Bytes((int)characters * 2)).TrimEnd('\0'); break; } Skip(length); diff --git a/tests/Filee.Engines.Tests/PaintNetTests.cs b/tests/Filee.Engines.Tests/PaintNetTests.cs index 7b12f07e6..b85c4294c 100644 --- a/tests/Filee.Engines.Tests/PaintNetTests.cs +++ b/tests/Filee.Engines.Tests/PaintNetTests.cs @@ -193,4 +193,76 @@ public async Task A_pdn_through_psd_and_back_keeps_its_layers() using var merged = new MagickImage(Assert.Single(png.Outputs)); AssertColour(merged, 2, 4, 139, 89, 50); } + + [Fact] + public async Task Two_pdn_merged_make_one_pdn() + { + // "Merge images" keeps the first file's format: a .pdn, written by Filee, not ImageMagick. + var first = Pdn("first.pdn"); + var second = Pdn("second.pdn"); + var preset = Core.Presets.BuiltInData.CreatePresets().Single(p => p.Id == "merge-images").Clone(); + + var job = await fx.ConvertAsync([first, second], preset); + Assert.True(job.State == JobState.Completed, Errors(job)); + var merged = Assert.Single(job.Outputs); + Assert.EndsWith(".pdn", merged); + var (width, height, layers) = Magick.PaintNet.Open(merged); + Assert.Single(layers.ToList()); + Assert.True(width * height >= 2 * Width * Height); + } + + [Fact] + public void A_pdn_claiming_more_pixels_than_it_holds_is_refused_before_anything_is_allocated() + { + var folder = fx.NewFolder(); + // A 16 × 8 file whose header and serialized document claim 20,000 × 20,000 with 3 layers. + var huge = Path.Combine(folder, "huge.pdn"); + File.WriteAllBytes(huge, PdnBuilder.Build(20_000, 20_000, + [new("A", []), new("B", []), new("C", [])], pixelsWritten: false)); + var ex = Assert.Throws(() => Magick.PaintNet.Open(huge)); + Assert.Contains("too large", ex.Message); + + // Within the budget but far more than a few hundred bytes can unpack to. + var claimed = Path.Combine(folder, "claimed.pdn"); + File.WriteAllBytes(claimed, PdnBuilder.Build(4_000, 4_000, [new("A", [])], pixelsWritten: false)); + Assert.Throws(() => Magick.PaintNet.Open(claimed)); + } + + [Fact] + public void A_large_layer_in_tiny_chunks_is_refused() + { + // 256 × 256 (256 KB) in 16-byte chunks: 16,384 chunks, damage rather than a Paint.NET file. + var path = Path.Combine(fx.NewFolder(), "chunks.pdn"); + File.WriteAllBytes(path, PdnBuilder.Build(256, 256, [new("A", PdnBuilder.Fill(256, 256, 1, 2, 3, 255))], chunkSize: 16)); + var (_, _, layers) = Magick.PaintNet.Open(path); + Assert.Throws(() => layers.ToList()); + } + + [Fact] + public async Task A_picture_wider_than_paint_net_opens_is_scaled_to_fit() + { + var png = Path.Combine(fx.NewFolder(), "strip.png"); + using (var strip = new MagickImage(MagickColors.Orange, 70_000, 2)) + strip.Write(png); + + var job = await fx.ConvertAsync([png], new Preset { TargetFormat = "pdn" }); + Assert.True(job.State == JobState.Completed, Errors(job)); + var (width, _, _) = Magick.PaintNet.Open(Assert.Single(job.Outputs)); + Assert.True(width <= Magick.PaintNetWriter.MaxSide); + } + + [Fact] + public async Task A_cut_off_psd_gives_no_layer_names_instead_of_failing() + { + var psdJob = await fx.ConvertAsync([Pdn()], new Preset { TargetFormat = "psd" }); + var psd = Assert.Single(psdJob.Outputs); + // Cut inside the last layer record's Unicode name: no names (PSD → PDN then keeps ImageMagick's), no exception. + var bytes = File.ReadAllBytes(psd); + var luni = bytes.AsSpan().LastIndexOf("8BIMluni"u8); + using (var file = File.OpenRead(psd)) + Assert.NotNull(Magick.PsdLayerRecords.Read(file, psb: false)); + File.WriteAllBytes(psd, bytes[..(luni + 10)]); + using (var file = File.OpenRead(psd)) + Assert.Null(Magick.PsdLayerRecords.Read(file, psb: false)); + } } diff --git a/tests/Filee.Engines.Tests/PdnBuilder.cs b/tests/Filee.Engines.Tests/PdnBuilder.cs index 81d91cd7e..82617cf7b 100644 --- a/tests/Filee.Engines.Tests/PdnBuilder.cs +++ b/tests/Filee.Engines.Tests/PdnBuilder.cs @@ -27,7 +27,9 @@ public static byte[] Fill(int width, int height, byte b, byte g, byte r, byte a) /// The bytes of a .pdn holding , bottom first. /// Bytes of a layer per chunk; small ones make several chunks of a small picture. - public static byte[] Build(int width, int height, IReadOnlyList layers, int chunkSize = 100) + /// False ends the file after the document: a header that claims pixels it lacks. + public static byte[] Build(int width, int height, IReadOnlyList layers, int chunkSize = 100, + bool pixelsWritten = true) { using var file = new MemoryStream(); var xml = Encoding.UTF8.GetBytes( @@ -90,7 +92,7 @@ public static byte[] Build(int width, int height, IReadOnlyList layers, i nrbf.Write((byte)11); // MessageEnd nrbf.Flush(); - foreach (var layer in layers) + foreach (var layer in pixelsWritten ? layers : []) { file.WriteByte(0); // gzip WriteUInt32(file, (uint)chunkSize);