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