From 3278e05af5afaa4746790cd20efa3e14a5df9b83 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 14:26:25 +0000 Subject: [PATCH 1/4] feat(image): read Windows animated cursors (.ani) An .ani is a RIFF "ACON" file whose frames are whole .cur/.ico files. Filee's own reader (Ani) takes the frames out, has ImageMagick decode each at its largest size, and builds the animation step by step, following the "seq " and "rate" chunks (jiffies of 1/60 s, rounded to hundredths) with an endless loop. - GIF, APNG, WebP and the other animated targets keep every step and its time; other targets get the first step. - To CUR, the first frame is written as stored (every size and its hotspot) unless the preset changes the picture; then the hotspot follows a resize, turn or mirror. ImageMagick writes CUR as an icon (type 1, no hotspot), so that file is made a cursor afterwards. - Files are read defensively: bounded size, frame and step counts, frame dimensions; raw-bitmap frames and damaged files are refused with a clear message. - Read only, specialist, Developer field (like CUR). - Settings schema 80: the images donut learns .ani; custom claims stay put. (May need renumbering depending on merge order: 79 is taken by #1384.) Fixes #1365 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5pr2HpsMpvTujh7kW6 --- README.md | 2 +- docs/ENGINES.md | 11 +- src/Filee.Core/Formats/FormatFields.cs | 4 +- src/Filee.Core/Formats/FormatRegistry.cs | 2 + src/Filee.Core/Settings/AppSettings.cs | 2 +- src/Filee.Core/Settings/UserDataStore.cs | 4 + src/Filee.Engines/Magick/Ani.cs | 404 ++++++++++++++++++ src/Filee.Engines/Magick/ImageEncoder.cs | 12 +- src/Filee.Engines/Magick/ImageReader.cs | 2 + .../Magick/MagickImageConverter.cs | 11 +- tests/Filee.Core.Tests/AniFormatTests.cs | 48 +++ tests/Filee.Core.Tests/FormatFieldsTests.cs | 8 +- tests/Filee.Engines.Tests/AniBuilder.cs | 129 ++++++ tests/Filee.Engines.Tests/AniTests.cs | 261 +++++++++++ 14 files changed, 884 insertions(+), 16 deletions(-) create mode 100644 src/Filee.Engines/Magick/Ani.cs create mode 100644 tests/Filee.Core.Tests/AniFormatTests.cs create mode 100644 tests/Filee.Engines.Tests/AniBuilder.cs create mode 100644 tests/Filee.Engines.Tests/AniTests.cs diff --git a/README.md b/README.md index f4800749c..8359c51d9 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), 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 | | **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 48aa8f65f..e22d39bce 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, 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 | @@ -344,6 +344,15 @@ in it also builds the package (artifact `windows-arm64-package`). sizes; then, as for every other target, the largest size is converted. A file without an icon of its own, or a 16-bit or damaged one, gives a clear error. Programs are converted only when dropped or named: watch folders and folders given to the command line skip them, and Recent conversions never opens one. +- **ANI** (a Windows animated cursor, #1365) is read by Filee itself (`Ani`): a RIFF file whose frames are whole .cur or + .ico files, each decoded by ImageMagick at its largest size. To GIF, APNG, WebP and the other animated targets every + step plays in the order of its `seq ` chunk (frames can come back), for the time its `rate` chunk or the header gives + (jiffies of 1/60 s, rounded to hundredths), looping forever; other targets get the first step. To **CUR** the first + frame is written as stored, every size and its hotspot; a preset that changes the picture converts it, and the + hotspot follows it when it is resized, turned or mirrored (trimmed or set on a background, it goes to the top left + corner). ImageMagick writes a CUR as an icon (type 1, no hotspot): that file is made a cursor afterwards. Files are + read in memory with their sizes checked (at most 64 MB, 1,024 frames, 4,096 steps, 1,024 px a side); frames stored + as raw bitmaps (no `AF_ICON` flag, which Windows' own loaders hardly use) and damaged files give a clear error. - **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there). - **Camera RAW** (DNG, CR2, CR3, NEF, ARW, …, and `.raw`) is decoded by LibRaw with the camera's white balance, diff --git a/src/Filee.Core/Formats/FormatFields.cs b/src/Filee.Core/Formats/FormatFields.cs index dae0079d1..d42ce1411 100644 --- a/src/Filee.Core/Formats/FormatFields.cs +++ b/src/Filee.Core/Formats/FormatFields.cs @@ -82,7 +82,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow "dts", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "mcpack", "mcaddon", "mcworld", "mctemplate", "mrpack", "cab", "cpio", "deb", "rpm", "dwg", "ttf", "otf", "woff", "woff2", "ass", "ssa", "ttml", "scc", "glb", "gltf", - "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm" + "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm", "ani" ]), (FormatGrade.Legacy, [ @@ -105,7 +105,7 @@ .. Enum.GetValues().Select(category => category.ToString().ToLow // Software work: raw image formats of graphics tools, app icons and cursors, config and data files, flat XML // ODF for version control, man pages, streaming segments, packages and compressed archives. ([ - "ppm", "icns", "qoi", "cur", "xpm", "xbm", "pam", "pfm", "miff", "fodg", "fodt", "fods", "fodp", "man", + "ppm", "icns", "qoi", "cur", "ani", "xpm", "xbm", "pam", "pfm", "miff", "fodg", "fodt", "fods", "fodp", "man", "jsonl", "yaml", "toml", "har", "plist", "m4s", "tar", "tgz", "tbz2", "txz", "tar7z", "tlz", "gz", "bz2", "xz", "lz", "lzma", "jar", "cab", "cpio", "deb", "rpm" ], ["developer"]), diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs index 9cfb125fc..5d732b8a9 100644 --- a/src/Filee.Core/Formats/FormatRegistry.cs +++ b/src/Filee.Core/Formats/FormatRegistry.cs @@ -42,6 +42,8 @@ public static class FormatRegistry new("dds", "DDS", FormatCategory.Image, ["dds"]), new("pcx", "PCX", FormatCategory.Image, ["pcx"]), new("cur", "CUR", FormatCategory.Image, ["cur"]), + // Windows animated cursor (#1365), read by Filee: every step with its time, or the first one as a picture. + new("ani", "Animated cursor (ANI)", FormatCategory.Image, ["ani"], Writable: false), new("xpm", "XPM", FormatCategory.Image, ["xpm"]), new("xbm", "XBM", FormatCategory.Image, ["xbm"]), new("wbmp", "WBMP", FormatCategory.Image, ["wbmp"]), diff --git a/src/Filee.Core/Settings/AppSettings.cs b/src/Filee.Core/Settings/AppSettings.cs index 3089b2e56..d4885d98c 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 = 80; public int SchemaVersion { get; set; } = CurrentSchemaVersion; diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs index d8d7825a8..238a7f6ee 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, ListReal animated cursors are a few kilobytes to a few megabytes. + private const long MaxFileBytes = 64L * 1024 * 1024; + + /// Frames in the "LIST fram". Real cursors have up to a few hundred. + private const int MaxFrames = 1024; + + /// Steps of the animation (a frame can be shown by several steps). + private const int MaxSteps = 4096; + + /// The widest or tallest picture a frame may hold. A cursor stores at most 256 px. + private const int MaxSide = 1024; + + /// All steps together: 1,024 steps of a 256 px cursor. + private const long MaxAnimationPixels = 1024L * 256 * 256; + + /// The longest step a GIF stores (65,535 hundredths of a second). + private const uint MaxDelay = ushort.MaxValue; + + private const uint IconFlag = 1; // AF_ICON: the frames are .ico/.cur files, not raw bitmaps + + /// One frame: a whole .cur or .ico file. + private sealed record Frame(byte[] Data, bool IsCursor); + + /// A step of the animation: the frame it shows and for how long, in jiffies (1/60 s). + private readonly record struct Step(int Frame, uint Jiffies); + + /// One size of a frame as its directory lists it: the cursor's hotspot (0, 0 in an .ico). + private readonly record struct Entry(int HotspotX, int HotspotY); + + /// + /// The animation: one image per step, each as large as the largest frame (smaller frames at the top left), with the + /// step's time and an endless loop, as Windows plays it. + /// + /// The file isn't an animated cursor Filee can read, or it is damaged. + public static MagickImageCollection Read(string path) + { + var (frames, steps) = Parse(path); + var pictures = Decode(frames); + try + { + var (width, height) = Canvas(pictures); + if ((long)steps.Count * width * height > MaxAnimationPixels) + throw new InvalidDataException("This animated cursor has too many steps or too large pictures to convert."); + var images = new MagickImageCollection(); + try + { + foreach (var step in steps) + { + var image = (MagickImage)pictures[step.Frame].Image.Clone(); + if (image.Width != width || image.Height != height) + { + image.BackgroundColor = MagickColors.Transparent; + image.Extent(width, height, Gravity.Northwest); + } + image.Page = new MagickGeometry(0, 0, width, height); + image.AnimationTicksPerSecond = 100; + image.AnimationDelay = Delay(step.Jiffies); + image.AnimationIterations = 0; + image.GifDisposeMethod = GifDisposeMethod.Background; + images.Add(image); + } + return images; + } + catch + { + images.Dispose(); + throw; + } + } + finally + { + foreach (var picture in pictures) + picture.Image.Dispose(); + } + } + + /// A step's time in hundredths of a second, as GIF, WebP and APNG frames carry it. + internal static uint Delay(uint jiffies) => (uint)Math.Min(MaxDelay, Math.Round(jiffies * 100.0 / 60)); + + /// + /// The hotspot of the animation's first picture (the point of the cursor that clicks), and the size of the pictures + /// gives. Null when the first frame is an .ico, which has none. + /// + public static (int X, int Y, uint Width, uint Height)? Hotspot(string path) + { + var (frames, steps) = Parse(path); + var pictures = Decode(frames); + try + { + var (width, height) = Canvas(pictures); + var first = pictures[steps[0].Frame]; + return frames[steps[0].Frame].IsCursor ? (first.Hotspot.HotspotX, first.Hotspot.HotspotY, width, height) : null; + } + finally + { + foreach (var picture in pictures) + picture.Image.Dispose(); + } + } + + /// + /// True when an ANI goes to CUR as its first frame is: the preset changes nothing about the picture, so the .cur + /// stored in the file is written as it is, every size and the hotspot with it. + /// + public static bool KeptAsItIs(ImageOptions options) => + !options.EditsPicture && !options.ReworksPixels && options.MaxSizeMb <= 0 && options.MinSizeMb <= 0; + + /// + /// The .cur of the first step written unchanged; null when that frame is an .ico (it has no hotspot), which is then + /// converted as any picture. + /// + public static IReadOnlyList? SaveFirstCursor(ConversionStep step, IProgress? progress) + { + var (frames, steps) = Parse(step.InputPath); + var frame = frames[steps[0].Frame]; + if (!frame.IsCursor) + return null; + Entries(frame.Data); // checked as Read would + progress?.Report(0.5); + var path = step.Output.Allocate("cur"); + if (path is null) + return []; + File.WriteAllBytes(path, frame.Data); + progress?.Report(1); + return [path]; + } + + /// + /// Makes the .cur ImageMagick wrote from the ANI's first picture a cursor with that picture's hotspot, where it is + /// after the preset turned, mirrored or resized the picture. ImageMagick's CUR writer writes an icon (type 1), whose + /// directory holds colour planes and bits per pixel where a cursor's holds its hotspot. When the hotspot isn't known + /// (the frame is an .ico, or the picture was trimmed or set on a background) it is the top left corner. + /// + public static void KeepHotspot(string sourcePath, IEnumerable written, ImageOptions options) + { + var hotspot = options.TrimTransparent || options.AddsBackdrop ? null : Hotspot(sourcePath); + foreach (var path in written) + { + var data = File.ReadAllBytes(path); + if (data.Length < 22 || BinaryPrimitives.ReadUInt16LittleEndian(data) != 0 + || BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(2)) is not (1 or 2)) + continue; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(2), 2); + var count = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(4)); + for (var i = 0; i < count && 6 + i * 16 + 16 <= data.Length; i++) + { + var entry = data.AsSpan(6 + i * 16); + var (x, y) = hotspot is { } known ? Moved(known, options, Side(entry[0]), Side(entry[1])) : (0, 0); + BinaryPrimitives.WriteUInt16LittleEndian(entry[4..], (ushort)x); + BinaryPrimitives.WriteUInt16LittleEndian(entry[6..], (ushort)y); + } + File.WriteAllBytes(path, data); + } + } + + private static uint Side(byte stored) => stored == 0 ? 256u : stored; + + /// + /// Where the hotspot of a .Width × Height picture is once it is turned and mirrored as + /// say () and scaled to × . + /// + internal static (int X, int Y) Moved((int X, int Y, uint Width, uint Height) hotspot, ImageOptions options, uint width, uint height) + { + var (x, y, w, h) = (hotspot.X, hotspot.Y, (int)hotspot.Width, (int)hotspot.Height); + (x, y, w, h) = options.Rotation switch + { + ImageRotation.Right90 => (h - 1 - y, x, h, w), + ImageRotation.Left90 => (y, w - 1 - x, h, w), + ImageRotation.Rotate180 => (w - 1 - x, h - 1 - y, w, h), + _ => (x, y, w, h), + }; + if (options.Flip == ImageFlip.Horizontal) + x = w - 1 - x; + else if (options.Flip == ImageFlip.Vertical) + y = h - 1 - y; + // The centre of the hotspot's pixel is scaled, so it stays on the same part of the picture. + var scaledX = (int)Math.Floor((x + 0.5) * width / w); + var scaledY = (int)Math.Floor((y + 0.5) * height / h); + return (Math.Clamp(scaledX, 0, (int)width - 1), Math.Clamp(scaledY, 0, (int)height - 1)); + } + + private static (uint Width, uint Height) Canvas(List<(MagickImage Image, Entry Hotspot)> pictures) => + (pictures.Max(p => p.Image.Width), pictures.Max(p => p.Image.Height)); + + /// Each frame's largest size, decoded by ImageMagick, with its hotspot. + private static List<(MagickImage Image, Entry Hotspot)> Decode(List frames) + { + var pictures = new List<(MagickImage, Entry)>(frames.Count); + try + { + foreach (var frame in frames) + { + var entries = Entries(frame.Data); + using var sizes = new MagickImageCollection(); + try + { + sizes.Read(frame.Data, new MagickReadSettings { Format = frame.IsCursor ? MagickFormat.Cur : MagickFormat.Icon }); + } + catch (MagickException ex) + { + throw new InvalidDataException("A frame of this animated cursor is damaged. (" + ex.Message + ")", ex); + } + if (sizes.Count == 0) + throw Damaged(); + // ImageMagick returns the sizes in the directory's order. + var largest = Enumerable.Range(0, sizes.Count).MaxBy(i => (long)sizes[i].Width * sizes[i].Height); + if (sizes[largest].Width > MaxSide || sizes[largest].Height > MaxSide) + throw new InvalidDataException("A frame of this animated cursor is too large to be a cursor."); + pictures.Add(((MagickImage)sizes[largest].Clone(), largest < entries.Count ? entries[largest] : default)); + } + return pictures; + } + catch + { + foreach (var (image, _) in pictures) + image.Dispose(); + throw; + } + } + + /// + /// The directory of a frame (a .cur or .ico file): each size's hotspot, after checking that the sizes it points to + /// are in the frame and are not larger than a cursor can be. + /// + private static List Entries(byte[] icon) + { + var count = BinaryPrimitives.ReadUInt16LittleEndian(icon.AsSpan(4)); + if (count == 0 || 6 + count * 16 > icon.Length) + throw Damaged(); + var entries = new List(count); + for (var i = 0; i < count; i++) + { + var entry = icon.AsSpan(6 + i * 16, 16); + var size = BinaryPrimitives.ReadUInt32LittleEndian(entry[8..]); + var offset = BinaryPrimitives.ReadUInt32LittleEndian(entry[12..]); + if (size == 0 || offset > icon.Length || size > icon.Length - offset) + throw Damaged(); + var picture = icon.AsSpan((int)offset, (int)size); + // A size is a PNG (its IHDR gives the size) or a BMP without its file header (BITMAPINFOHEADER; its height + // counts the picture and its mask). + var (width, height) = picture.Length >= 24 && picture[..8].SequenceEqual(PngSignature) + ? ((long)BinaryPrimitives.ReadUInt32BigEndian(picture[16..]), (long)BinaryPrimitives.ReadUInt32BigEndian(picture[20..])) + : picture.Length >= 12 + ? (Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(picture[4..])), Math.Abs((long)BinaryPrimitives.ReadInt32LittleEndian(picture[8..])) / 2) + : throw Damaged(); + if (width > MaxSide || height > MaxSide) + throw new InvalidDataException("A frame of this animated cursor is too large to be a cursor."); + entries.Add(new Entry(BinaryPrimitives.ReadUInt16LittleEndian(entry[4..]), BinaryPrimitives.ReadUInt16LittleEndian(entry[6..]))); + } + return entries; + } + + private static ReadOnlySpan PngSignature => [0x89, (byte)'P', (byte)'N', (byte)'G', 0x0d, 0x0a, 0x1a, 0x0a]; + + /// The frames and steps of the file, checked. + private static (List Frames, List Steps) Parse(string path) + { + byte[] data; + using (var stream = File.OpenRead(path)) + { + if (stream.Length > MaxFileBytes) + throw new InvalidDataException("This animated cursor is too large to be one (more than 64 MB)."); + data = new byte[stream.Length]; + stream.ReadExactly(data); + } + if (data.Length < 12 || !data.AsSpan(0, 4).SequenceEqual("RIFF"u8) || !data.AsSpan(8, 4).SequenceEqual("ACON"u8)) + throw new InvalidDataException("This is not a Windows animated cursor (.ani), or the file is damaged."); + // The RIFF size counts what follows it; a file can end with padding after it, never stop short of it. + var riffSize = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(4)); + if (riffSize > data.Length - 8L) + throw CutShort(); + var end = 8 + (int)riffSize; + + uint? frameCount = null, stepCount = null, defaultJiffies = null, flags = null; + uint[]? rates = null, sequence = null; + var frames = new List(); + // "LIST INFO" (the cursor's title and author) and any other chunk are skipped. + foreach (var (id, body) in Chunks(data, 12, end)) + { + switch (id) + { + case "anih" when body.Length >= 36: + frameCount = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(body.Start + 4)); + stepCount = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(body.Start + 8)); + defaultJiffies = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(body.Start + 28)); + flags = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(body.Start + 32)); + break; + case "anih": + throw Damaged(); + case "rate": + rates = Numbers(data, body); + break; + case "seq ": + sequence = Numbers(data, body); + break; + case "LIST" when body.Length >= 4 && data.AsSpan(body.Start, 4).SequenceEqual("fram"u8): + foreach (var (frameId, frame) in Chunks(data, body.Start + 4, body.End)) + { + if (frameId != "icon") + continue; + if (frames.Count == MaxFrames) + throw new InvalidDataException($"This animated cursor has more than {MaxFrames:N0} frames."); + frames.Add(ReadFrame(data[frame.Start..frame.End])); + } + break; + } + } + + if (flags is null || frameCount is null || stepCount is null || defaultJiffies is null) + throw new InvalidDataException("This animated cursor has no header (anih chunk): the file is damaged."); + if ((flags & IconFlag) == 0) + throw new InvalidDataException( + "This animated cursor stores its frames as raw bitmaps, which Filee can't read. Open it in a cursor " + + "editor and save it again, or save its frames as .cur files."); + if (frames.Count == 0) + throw new InvalidDataException("This animated cursor has no frames (icon chunks): the file is damaged."); + + // Without "seq " the steps show the frames in order; with it, each step names its frame. + var count = sequence?.Length ?? frames.Count; + if (count == 0) + throw Damaged(); + if (count > MaxSteps) + throw new InvalidDataException($"This animated cursor has more than {MaxSteps:N0} steps."); + var steps = new List(count); + for (var i = 0; i < count; i++) + { + var frame = sequence is null ? (uint)i : sequence[i]; + if (frame >= frames.Count) + throw Damaged(); + steps.Add(new Step((int)frame, rates is not null && i < rates.Length ? rates[i] : defaultJiffies.Value)); + } + return (frames, steps); + } + + /// Where a chunk's body is in the file. + private readonly record struct Part(int Start, int End) + { + public int Length => End - Start; + } + + /// The chunks between and : id, and where the body is. + private static IEnumerable<(string Id, Part Body)> Chunks(byte[] data, int start, int end) + { + var at = start; + while (at + 8 <= end) + { + var id = System.Text.Encoding.ASCII.GetString(data, at, 4); + var size = BinaryPrimitives.ReadUInt32LittleEndian(data.AsSpan(at + 4)); + if (size > end - (at + 8L)) + throw CutShort(); + var bodyStart = at + 8; + yield return (id, new Part(bodyStart, bodyStart + (int)size)); + // Chunks start on an even byte: an odd-sized body is followed by a pad byte. + at = bodyStart + (int)size + (int)(size & 1); + } + } + + private static uint[] Numbers(byte[] data, Part body) + { + var bytes = data.AsSpan(body.Start, body.Length); + if (bytes.Length / 4 > MaxSteps) + throw new InvalidDataException($"This animated cursor has more than {MaxSteps:N0} steps."); + var numbers = new uint[bytes.Length / 4]; + for (var i = 0; i < numbers.Length; i++) + numbers[i] = BinaryPrimitives.ReadUInt32LittleEndian(bytes[(i * 4)..]); + return numbers; + } + + /// A frame: a .cur (type 2) or .ico (type 1) file. + private static Frame ReadFrame(byte[] icon) + { + if (icon.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(icon) != 0) + throw Damaged(); + return BinaryPrimitives.ReadUInt16LittleEndian(icon.AsSpan(2)) switch + { + 1 => new Frame(icon, false), + 2 => new Frame(icon, true), + _ => throw Damaged(), + }; + } + + private static InvalidDataException Damaged() => + new("This animated cursor is damaged: a frame or the order of its steps can't be read."); + + private static InvalidDataException CutShort() => + new("This animated cursor is cut short or damaged: a part of it runs past the end of the file."); +} diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 1640d3914..5dae72608 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -15,14 +15,15 @@ internal static class ImageEncoder /// Camera RAW is decoded by LibRaw; XCF, HEIC, DICOM, Photo CD, OpenEXR, Radiance HDR and FITS are read only. EMF/WMF /// are Windows metafiles, which ImageMagick renders through GDI+ on Windows only (). APNG is /// read and written by Filee's own (its frames decoded and encoded by ImageMagick); FITS is read - /// by Filee's own and written to TIFF by . + /// by Filee's own and written to TIFF by . ANI (an animated cursor) is + /// read by Filee's own , its frames decoded by ImageMagick. /// public static readonly string[] Readable = [ "png", "jpg", "webp", "tiff", "bmp", "gif", "ico", "avif", "heic", "jxl", "jp2", "psd", "psb", "tga", "ppm", "xcf", "raw", "apng", "mng", "qoi", "dds", "pcx", "cur", "xpm", "xbm", "wbmp", "pam", "pfm", "sgi", "ras", "miff", - "dcm", "pcd", "exr", "hdr", "fits", + "dcm", "pcd", "exr", "hdr", "fits", "ani", ]; /// Vector metafiles ImageMagick rasterizes (Windows only: it uses GDI+). @@ -43,11 +44,12 @@ internal static class ImageEncoder private static readonly HashSet MultiFrame = ["tiff", "gif", "apng", "mng"]; /// - /// Formats whose frames are an animation; an AVIF only when it is an image sequence (). + /// Formats whose frames are an animation; an AVIF only when it is an image sequence (), and + /// an animated cursor (), read only. /// Between these, all frames are kept with their timing (an animated GIF becomes an animated WebP, JPEG XL or AVIF /// and the other way round); any other target gets the first frame. /// - private static readonly HashSet Animated = ["gif", "webp", "apng", "mng", "jxl", AvifSequence.Format]; + private static readonly HashSet Animated = ["gif", "webp", "apng", "mng", "jxl", AvifSequence.Format, "ani"]; /// /// The edge an image engine offers from a picture in to . GIF @@ -161,7 +163,7 @@ public static (MagickFormat Format, string Extension) OutputFormat(string target /// /// Chooses which frames of a (possibly multi-frame) source are converted. - /// ICO, CUR, DDS, a program's icon: the largest image. Animations (GIF, WebP, APNG, MNG, JPEG XL, AVIF): every frame + /// ICO, CUR, DDS, a program's icon: the largest image. Animations (GIF, WebP, APNG, MNG, JPEG XL, AVIF, ANI): every frame /// for an animated or TIFF target, else the first frame. /// PSD/PSB: the composite (frame 0; any further frames are layers). Otherwise: all frames/pages. /// diff --git a/src/Filee.Engines/Magick/ImageReader.cs b/src/Filee.Engines/Magick/ImageReader.cs index eba20fb9b..e026c751c 100644 --- a/src/Filee.Engines/Magick/ImageReader.cs +++ b/src/Filee.Engines/Magick/ImageReader.cs @@ -27,6 +27,8 @@ public static MagickImageCollection Read(string path, string formatId, Preset pr { if (formatId == "apng") return Apng.Read(path); + if (formatId == "ani") + return Ani.Read(path); if (formatId == "dcm" && Dicom.TryRead(path) is { } scan) return scan; if (formatId == "livp") diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs index 6752a7b0b..87167a61f 100644 --- a/src/Filee.Engines/Magick/MagickImageConverter.cs +++ b/src/Filee.Engines/Magick/MagickImageConverter.cs @@ -12,7 +12,7 @@ namespace Filee.Engines.Magick; /// /// Converts between PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF, JPEG XL, JPEG 2000, PSD/PSB, TGA, PPM, APNG, MNG, QOI, /// DDS, PCX, CUR, XPM, XBM, WBMP, PAM, PFM, SGI, Sun Raster and MIFF, and reads HEIC, XCF, camera RAW, DICOM, -/// Photo CD, OpenEXR, Radiance HDR, FITS and (on Windows) EMF/WMF. +/// Photo CD, OpenEXR, Radiance HDR, FITS, animated cursors (ANI) and (on Windows) EMF/WMF. /// public sealed class MagickImageConverter : IConverter, ITiffMerger, IImageTransformer, IImageJoiner { @@ -87,6 +87,10 @@ public Task JoinAsync(IReadOnlyList<(string Path, string Format)> inputs, string public Task> ConvertAsync(ConversionStep step, IProgress? progress, CancellationToken cancellationToken) => Task.Run>(async () => { + // An animated cursor as CUR is its first frame as stored, every size and the hotspot, unless the preset + // changes it; then the hotspot follows the picture into the converted cursor. + if (step.From == "ani" && step.To == "cur" && Ani.KeptAsItIs(step.Preset.Image) && Ani.SaveFirstCursor(step, progress) is { } cursor) + return cursor; // FITS → TIFF keeps the file's depth (16 or 32 bits), which the Q8 build can't: Filee writes it. if (step.From == "fits" && step.To == "tiff") return FitsRendering.WriteTiff(step, progress, cancellationToken); @@ -97,7 +101,10 @@ public Task> ConvertAsync(ConversionStep step, IProgress diff --git a/tests/Filee.Core.Tests/AniFormatTests.cs b/tests/Filee.Core.Tests/AniFormatTests.cs new file mode 100644 index 000000000..da9576ce4 --- /dev/null +++ b/tests/Filee.Core.Tests/AniFormatTests.cs @@ -0,0 +1,48 @@ +// Windows animated cursors (.ani, #1365) are read as pictures: they open the images donut, which existing libraries learn +// without taking a donut of the user's own. Read only, and a cursor format like CUR: specialist, in the Developer field. + +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Core.Profiles; +using Filee.Core.Settings; + +namespace Filee.Core.Tests; + +public class AniFormatTests +{ + [Theory] + [InlineData("busy.ani")] + [InlineData("WAIT.ANI")] + public void An_animated_cursor_uses_the_image_donut(string file) + { + Assert.Same(FormatRegistry.Get("ani"), FormatRegistry.Detect(file)); + Assert.Equal("images", ProfileSelector.Select(BuiltInData.CreateProfiles(), [file])?.Id); + } + + [Fact] + public void It_is_read_only_and_classed_as_cur_is() + { + var ani = FormatRegistry.Get("ani"); + Assert.False(ani.Writable); + Assert.Equal(FormatFields.GradeOf(FormatRegistry.Get("cur")), FormatFields.GradeOf(ani)); + Assert.Equal(["developer"], FormatFields.Of(ani).Select(f => f.Id)); + } + + [Fact] + public void Version_80_libraries_learn_ani_without_taking_custom_claims() + { + var presets = BuiltInData.CreatePresets(); + var profiles = BuiltInData.CreateProfiles(); + var images = profiles.Single(p => p.Id == "images"); + images.Extensions.Remove("ani"); + Assert.True(SettingsMigrations.ApplyToLibrary(79, presets, profiles)); + Assert.Contains("ani", images.Extensions); + Assert.False(SettingsMigrations.ApplyToLibrary(79, presets, profiles)); + + images.Extensions.Remove("ani"); + profiles.Insert(0, new ToolbarProfile { Id = "my-cursors", Extensions = ["ANI"] }); + Assert.False(SettingsMigrations.ApplyToLibrary(79, presets, profiles)); + Assert.DoesNotContain("ani", images.Extensions); // the user's own donut has it + Assert.Equal("my-cursors", ProfileSelector.Select(profiles, ["busy.ani"])?.Id); + } +} diff --git a/tests/Filee.Core.Tests/FormatFieldsTests.cs b/tests/Filee.Core.Tests/FormatFieldsTests.cs index 39c64a174..3e2b0bf64 100644 --- a/tests/Filee.Core.Tests/FormatFieldsTests.cs +++ b/tests/Filee.Core.Tests/FormatFieldsTests.cs @@ -25,7 +25,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => // Every specialist and legacy format. private static readonly string[] HiddenWithEverythingOff = [ - "3g2", "3gp", "3mf", "602", "ac3", "adoc", "ai", "amr", "ape", "arj", "ass", "au", "azw", "azw4", "bik", + "3g2", "3gp", "3mf", "602", "ac3", "adoc", "ai", "amr", "ani", "ape", "arj", "ass", "au", "azw", "azw4", "bik", "bz2", "cab", "caf", "cavs", "cdr", "cgm", "chm", "cpio", "cur", "cwk", "dav", "dbk", "dcm", "dds", "deb", "dff", "dot", "dsf", "dss", "dst", "dts", "dv", "dwg", "eac3", "eot", "eps", "exr", "fbx", "fit", "fits", "flc", "flv", "fodg", "fodp", "fods", "fodt", "fon", "geojson", "glb", "gltf", "gpx", "gz", "h264", "h265", "har", @@ -49,7 +49,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "kra", "plt", "ps", "psb", "psd", "raw", "xcf"] }, { "science", ["dcm", "fits"] }, { "developer", [ - "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", + "ani", "bz2", "cab", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" ] }, @@ -76,7 +76,7 @@ private static string[] Hidden(IReadOnlySet turnedOff) => { "design", ["ai", "cdr", "dst", "eps", "exr", "hdr", "jp2", "kra", "pcd", "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", + "ani", "bz2", "cab", "caf", "chm", "cpio", "cur", "deb", "fodg", "fodp", "fods", "fodt", "gz", "har", "icns", "ipynb", "jar", "jsonl", "lz", "lzma", "m4s", "man", "miff", "pam", "pfm", "plist", "ppm", "qoi", "rpm", "tar", "tar7z", "tbz2", "tgz", "tlz", "toml", "txz", "xbm", "xpm", "xz", "yaml" ] }, @@ -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.Engines.Tests/AniBuilder.cs b/tests/Filee.Engines.Tests/AniBuilder.cs new file mode 100644 index 000000000..dca9f3d2c --- /dev/null +++ b/tests/Filee.Engines.Tests/AniBuilder.cs @@ -0,0 +1,129 @@ +// Builds Windows animated cursors (.ani) as cursor editors write them: a RIFF "ACON" file with its "anih" header, optional +// "rate" and "seq " chunks, the frames as whole .cur files in a "LIST fram", and a "LIST INFO" with a title and author. +// The pictures of the frames are written by ImageMagick, the .cur files around them here (ImageMagick writes a "CUR" as an +// icon, one size per file, without a hotspot). + +using System.Buffers.Binary; +using System.Text; +using ImageMagick; + +namespace Filee.Engines.Tests; + +internal sealed class AniBuilder +{ + /// The frames: each a whole .cur (or .ico) file. + public List Frames { get; } = []; + + /// Which frame each step shows; null writes no "seq " chunk. + public uint[]? Sequence { get; set; } + + /// Each step's time in jiffies (1/60 s); null writes no "rate" chunk. + public uint[]? Rates { get; set; } + + /// The default time of a step, in jiffies. + public uint DefaultJiffies { get; set; } = 6; + + /// AF_ICON: the frames are .ico/.cur files. Without it they would be raw bitmaps. + public bool IconFrames { get; set; } = true; + + /// + /// A .cur file holding one size per entry of : a square of that colour and side, with that + /// hotspot. + /// + public static byte[] Cursor(params (IMagickColor Colour, uint Side, ushort HotspotX, ushort HotspotY)[] sizes) + { + // ImageMagick writes one size per file: each size's picture (a BMP without its file header) is taken from an + // .ico of its own and put in one directory. + var pictures = new List<(byte[] Entry, byte[] Data)>(); + foreach (var size in sizes) + { + using var image = new MagickImage(size.Colour, size.Side, size.Side); + image.Alpha(AlphaAction.Set); + // A transparent corner, so transparency is checked too. + using (var pixels = image.GetPixels()) + pixels.SetArea(0, 0, 2, 2, new byte[2 * 2 * image.ChannelCount]); + var ico = image.ToByteArray(MagickFormat.Ico); + var length = BinaryPrimitives.ReadInt32LittleEndian(ico.AsSpan(6 + 8)); + var offset = BinaryPrimitives.ReadInt32LittleEndian(ico.AsSpan(6 + 12)); + pictures.Add((ico[6..22], ico[offset..(offset + length)])); + } + var file = new byte[6 + 16 * pictures.Count + pictures.Sum(p => p.Data.Length)]; + BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(2), 2); // a cursor + BinaryPrimitives.WriteUInt16LittleEndian(file.AsSpan(4), (ushort)pictures.Count); + var at = 6 + 16 * pictures.Count; + for (var i = 0; i < pictures.Count; i++) + { + var entry = file.AsSpan(6 + i * 16, 16); + pictures[i].Entry.CopyTo(entry); + BinaryPrimitives.WriteUInt16LittleEndian(entry[4..], sizes[i].HotspotX); + BinaryPrimitives.WriteUInt16LittleEndian(entry[6..], sizes[i].HotspotY); + BinaryPrimitives.WriteInt32LittleEndian(entry[12..], at); + pictures[i].Data.CopyTo(file, at); + at += pictures[i].Data.Length; + } + return file; + } + + /// A one-size cursor of , px, hotspot at the top left. + public AniBuilder Frame(IMagickColor colour, uint side = 32) + { + Frames.Add(Cursor((colour, side, 0, 0))); + return this; + } + + public byte[] Build() + { + var body = new MemoryStream(); + body.Write("ACON"u8); + var steps = (uint)(Sequence?.Length ?? Frames.Count); + var header = new byte[36]; + uint[] fields = [36, (uint)Frames.Count, steps, 0, 0, 0, 0, DefaultJiffies, (IconFrames ? 1u : 0) | (Sequence is null ? 0 : 2u)]; + for (var i = 0; i < fields.Length; i++) + BinaryPrimitives.WriteUInt32LittleEndian(header.AsSpan(i * 4), fields[i]); + Chunk(body, "anih", header); + if (Rates is not null) + Chunk(body, "rate", Numbers(Rates)); + if (Sequence is not null) + Chunk(body, "seq ", Numbers(Sequence)); + + var info = new MemoryStream(); + info.Write("INFO"u8); + Chunk(info, "INAM", Encoding.ASCII.GetBytes("Busy\0")); // odd size: padded + Chunk(info, "IART", Encoding.ASCII.GetBytes("Filee tests\0")); + Chunk(body, "LIST", info.ToArray()); + + var frames = new MemoryStream(); + frames.Write("fram"u8); + foreach (var frame in Frames) + Chunk(frames, "icon", frame); + Chunk(body, "LIST", frames.ToArray()); + + var file = new MemoryStream(); + file.Write("RIFF"u8); + Span size = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(size, (uint)body.Length); + file.Write(size); + body.Position = 0; + body.CopyTo(file); + return file.ToArray(); + } + + private static byte[] Numbers(uint[] numbers) + { + var data = new byte[numbers.Length * 4]; + for (var i = 0; i < numbers.Length; i++) + BinaryPrimitives.WriteUInt32LittleEndian(data.AsSpan(i * 4), numbers[i]); + return data; + } + + private static void Chunk(Stream stream, string id, byte[] data) + { + stream.Write(Encoding.ASCII.GetBytes(id)); + Span size = stackalloc byte[4]; + BinaryPrimitives.WriteUInt32LittleEndian(size, (uint)data.Length); + stream.Write(size); + stream.Write(data); + if (data.Length % 2 == 1) + stream.WriteByte(0); + } +} diff --git a/tests/Filee.Engines.Tests/AniTests.cs b/tests/Filee.Engines.Tests/AniTests.cs new file mode 100644 index 000000000..2eb163bea --- /dev/null +++ b/tests/Filee.Engines.Tests/AniTests.cs @@ -0,0 +1,261 @@ +// Windows animated cursors (.ani, issue #1365): every step with its time to the animated targets, the first picture to +// the others, and to CUR the first frame with its hotspot. The files are built here (AniBuilder). + +using System.Buffers.Binary; +using Filee.Core.Conversion; +using Filee.Core.Formats; +using Filee.Core.Presets; +using Filee.Engines.Magick; +using ImageMagick; + +namespace Filee.Engines.Tests; + +public class AniTests(EngineFixture fx) : IClassFixture +{ + private string Save(byte[] data, string name = "busy.ani") + { + var path = Path.Combine(fx.NewFolder(), name); + File.WriteAllBytes(path, data); + return path; + } + + private static AniBuilder ThreeColours() => + new AniBuilder().Frame(MagickColors.Red).Frame(MagickColors.Lime).Frame(MagickColors.Blue); + + private async Task ConvertAsync(string ani, string target, Preset? preset = null) + { + preset ??= new Preset(); + preset.TargetFormat = target; + var job = await fx.ConvertAsync([ani], preset); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey}: {f.ErrorDetail}"))); + return Assert.Single(job.Outputs); + } + + private static IMagickColor Centre(IMagickImage image) + { + using var pixels = image.GetPixels(); + return pixels.GetPixel((int)image.Width / 2, (int)image.Height / 2)!.ToColor()!; + } + + private static (int X, int Y) CursorHotspot(byte[] cur, int entry = 0) => + (BinaryPrimitives.ReadUInt16LittleEndian(cur.AsSpan(6 + entry * 16 + 4)), + BinaryPrimitives.ReadUInt16LittleEndian(cur.AsSpan(6 + entry * 16 + 6))); + + [Theory] + [InlineData("busy.ani")] + [InlineData("WAIT.ANI")] + public void An_ani_is_detected_by_its_extension_and_read_only(string name) + { + var format = FormatRegistry.Detect(name); + Assert.Equal("ani", format!.Id); + Assert.Equal(FormatCategory.Image, format.Category); + Assert.False(format.Writable); + var magick = new MagickImageConverter(); + Assert.True(magick.CanRead("ani")); + Assert.False(magick.CanWrite("ani")); + Assert.Contains(magick.Edges, e => e.From == "ani" && e.To == "gif"); + } + + [Fact] + public async Task Without_seq_or_rate_every_frame_plays_in_order_at_the_default_time_forever() + { + // 6 jiffies = 1/10 s = 10 hundredths. + var gif = await ConvertAsync(Save(ThreeColours().Build()), "gif"); + using var frames = new MagickImageCollection(gif); + Assert.Equal(3, frames.Count); + Assert.Equal([MagickColors.Red, MagickColors.Lime, MagickColors.Blue], frames.Select(f => Centre(f))); + Assert.All(frames, f => Assert.Equal(10u, f.AnimationDelay)); + Assert.Equal(0u, frames[0].AnimationIterations); // loops forever + // The cursor's transparent corner stays transparent. + using var pixels = frames[1].GetPixels(); + Assert.Equal(0, pixels.GetPixel(0, 0)!.ToColor()!.A); + } + + [Fact] + public async Task Seq_repeats_frames_and_rate_gives_each_step_its_time() + { + var ani = new AniBuilder().Frame(MagickColors.Red).Frame(MagickColors.Blue); + ani.Sequence = [0, 1, 0, 0, 1]; + ani.Rates = [6, 12, 30, 3, 60]; + var gif = await ConvertAsync(Save(ani.Build()), "gif"); + using var frames = new MagickImageCollection(gif); + Assert.Equal(5, frames.Count); + Assert.Equal([MagickColors.Red, MagickColors.Blue, MagickColors.Red, MagickColors.Red, MagickColors.Blue], frames.Select(f => Centre(f))); + Assert.Equal([10u, 20u, 50u, 5u, 100u], frames.Select(f => f.AnimationDelay)); + } + + [Fact] + public async Task A_short_rate_chunk_leaves_the_last_steps_at_the_default_time() + { + var ani = ThreeColours(); + ani.Rates = [30]; + ani.DefaultJiffies = 12; + var gif = await ConvertAsync(Save(ani.Build()), "gif"); + using var frames = new MagickImageCollection(gif); + Assert.Equal([50u, 20u, 20u], frames.Select(f => f.AnimationDelay)); + } + + [Theory] + [InlineData("webp")] + [InlineData("apng")] + public async Task Other_animated_targets_keep_the_animation(string target) + { + var output = await ConvertAsync(Save(ThreeColours().Build()), target); + using var frames = target == "apng" ? Apng.Read(output) : new MagickImageCollection(output); + Assert.Equal(3, frames.Count); + Assert.Equal(MagickColors.Blue, Centre(frames[2])); + } + + [Theory] + [InlineData("png")] + [InlineData("jpg")] + [InlineData("ico")] + public async Task Still_targets_get_the_first_step(string target) + { + var ani = ThreeColours(); + ani.Sequence = [1, 0, 2]; // the first step shows the second frame + var output = await ConvertAsync(Save(ani.Build()), target); + using var image = new MagickImageCollection(output); + var centre = Centre(image.OrderByDescending(i => i.Width).First()); + Assert.True(centre.R < 8 && centre.G > 247 && centre.B < 8, centre.ToString()); // lime, give or take JPEG + } + + [Fact] + public async Task A_frame_with_several_sizes_gives_its_largest() + { + var ani = new AniBuilder(); + ani.Frames.Add(AniBuilder.Cursor((MagickColors.Red, 16, 0, 0), (MagickColors.Blue, 48, 0, 0), (MagickColors.Lime, 32, 0, 0))); + ani.Frames.Add(AniBuilder.Cursor((MagickColors.Yellow, 16, 0, 0), (MagickColors.Purple, 48, 0, 0))); + var gif = await ConvertAsync(Save(ani.Build()), "gif"); + using var frames = new MagickImageCollection(gif); + Assert.Equal(2, frames.Count); + Assert.All(frames, f => Assert.Equal((48u, 48u), (f.Width, f.Height))); + Assert.Equal([MagickColors.Blue, MagickColors.Purple], frames.Select(f => Centre(f))); + } + + [Fact] + public async Task Frames_of_different_sizes_play_on_one_canvas() + { + var gif = await ConvertAsync(Save(new AniBuilder().Frame(MagickColors.Red, 32).Frame(MagickColors.Blue, 48).Build()), "gif"); + using var frames = new MagickImageCollection(gif); + Assert.All(frames, f => Assert.Equal((48u, 48u), (f.Width, f.Height))); + using var pixels = frames[0].GetPixels(); + Assert.Equal(MagickColors.Red, pixels.GetPixel(16, 16)!.ToColor()); + Assert.Equal(0, pixels.GetPixel(40, 40)!.ToColor()!.A); // outside the smaller frame: transparent + } + + [Fact] + public async Task To_cur_the_first_frame_is_kept_as_it_is_with_its_hotspot() + { + var first = AniBuilder.Cursor((MagickColors.Red, 32, 7, 21), (MagickColors.Red, 16, 3, 10)); + var ani = new AniBuilder(); + ani.Frames.Add(first); + ani.Frame(MagickColors.Blue); + var cur = await ConvertAsync(Save(ani.Build()), "cur"); + Assert.Equal(first, File.ReadAllBytes(cur)); // every size, the hotspots with them + } + + [Fact] + public async Task To_cur_a_resized_or_turned_picture_keeps_its_hotspot_where_it_was() + { + var ani = new AniBuilder(); + ani.Frames.Add(AniBuilder.Cursor((MagickColors.Red, 32, 10, 20))); + ani.Frame(MagickColors.Blue); + var path = Save(ani.Build()); + + var half = await ConvertAsync(path, "cur", new Preset { Image = { Resize = ResizeMode.Percent, ResizePercent = 50 } }); + var data = File.ReadAllBytes(half); + Assert.Equal(2, BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(2))); // a cursor, not an icon named .cur + using (var image = new MagickImage(half)) + Assert.Equal((16u, 16u), (image.Width, image.Height)); + Assert.Equal((5, 10), CursorHotspot(data)); + + // Turned right, the point 10 px from the left and 20 px from the top of a 32 px cursor is 11 px from the left + // and 10 px from the top. + var turned = await ConvertAsync(path, "cur", new Preset { Image = { Rotation = ImageRotation.Right90 } }); + Assert.Equal((11, 10), CursorHotspot(File.ReadAllBytes(turned))); + } + + [Theory] + [InlineData(ImageRotation.None, ImageFlip.None, 64, 64, 21, 41)] + [InlineData(ImageRotation.Left90, ImageFlip.None, 32, 32, 20, 21)] + [InlineData(ImageRotation.Rotate180, ImageFlip.None, 32, 32, 21, 11)] + [InlineData(ImageRotation.None, ImageFlip.Horizontal, 32, 32, 21, 20)] + [InlineData(ImageRotation.None, ImageFlip.Vertical, 32, 32, 10, 11)] + public void The_hotspot_follows_the_picture(ImageRotation rotation, ImageFlip flip, int width, int height, int x, int y) => + Assert.Equal((x, y), Ani.Moved((10, 20, 32, 32), new ImageOptions { Rotation = rotation, Flip = flip }, (uint)width, (uint)height)); + + [Fact] + public void Jiffies_become_hundredths_of_a_second() + { + Assert.Equal(0u, Ani.Delay(0)); + Assert.Equal(2u, Ani.Delay(1)); // 1.67 + Assert.Equal(100u, Ani.Delay(60)); + Assert.Equal(65_535u, Ani.Delay(uint.MaxValue)); + } + + public static TheoryData Broken => new() + { + { "cut short", "cut short" }, + { "not riff", "not a Windows animated cursor" }, + { "no header", "no header" }, + { "no frames", "no frames" }, + { "raw bitmaps", "raw bitmaps" }, + { "seq past the frames", "damaged" }, + { "damaged frame", "damaged" }, + { "huge frame", "too large" }, + }; + + [Theory] + [MemberData(nameof(Broken))] + public async Task A_broken_file_is_refused_with_a_clear_message(string kind, string message) + { + var good = ThreeColours().Build(); + byte[] data; + switch (kind) + { + case "cut short": + data = good[..(good.Length - 100)]; + break; + case "not riff": + data = "This is a text file renamed to .ani"u8.ToArray(); + break; + case "no header": + data = [.. "RIFF"u8, 4, 0, 0, 0, .. "ACON"u8]; + break; + case "no frames": + data = new AniBuilder().Build(); + break; + case "raw bitmaps": + var raw = ThreeColours(); + raw.IconFrames = false; + data = raw.Build(); + break; + case "seq past the frames": + var seq = ThreeColours(); + seq.Sequence = [0, 1, 3]; + data = seq.Build(); + break; + case "damaged frame": + var damaged = new AniBuilder(); + damaged.Frames.Add([0, 0, 2, 0, 1, 0, 32, 32, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 22, 0, 0, 0]); + data = damaged.Build(); + break; + default: + // A frame whose directory says it holds a 4096 px picture. + var cursor = AniBuilder.Cursor((MagickColors.Red, 32, 0, 0)); + var offset = BinaryPrimitives.ReadUInt32LittleEndian(cursor.AsSpan(6 + 12)); + BinaryPrimitives.WriteInt32LittleEndian(cursor.AsSpan((int)offset + 4), 4096); + BinaryPrimitives.WriteInt32LittleEndian(cursor.AsSpan((int)offset + 8), 8192); + var huge = new AniBuilder(); + huge.Frames.Add(cursor); + data = huge.Build(); + break; + } + + var job = await fx.ConvertAsync([Save(data)], new Preset { TargetFormat = "gif" }); + var file = Assert.Single(job.Files); + Assert.Equal(FileState.Failed, file.State); + Assert.Contains(message, file.ErrorDetail); + } +} From b895c94bc0261f18902152f2d961582c21362762 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 14:45:07 +0000 Subject: [PATCH 2/4] fix(image): write every .cur as a real cursor with a hotspot ImageMagick writes "CUR" as an icon: header type 1, and each directory entry holds colour planes and bits per pixel where Windows reads the hotspot, so a PNG -> CUR from Filee clicked at (1, 32). CursorFile now makes every .cur Filee writes (ImageEncoder.Write, Merge images) a type-2 cursor clicking at the top left corner. From a CUR or ANI source the hotspot follows the picture through a resize, turn or mirror. The ANI-only patch (Ani.KeepHotspot, Ani.Moved) moves into CursorFile. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5pr2HpsMpvTujh7kW6 --- docs/ENGINES.md | 11 ++- src/Filee.Engines/Magick/Ani.cs | 54 ------------ src/Filee.Engines/Magick/CursorFile.cs | 87 +++++++++++++++++++ src/Filee.Engines/Magick/ImageEncoder.cs | 3 + src/Filee.Engines/Magick/ImageJoiner.cs | 2 + .../Magick/MagickImageConverter.cs | 12 ++- tests/Filee.Engines.Tests/AniTests.cs | 2 +- tests/Filee.Engines.Tests/CursorFileTests.cs | 67 ++++++++++++++ 8 files changed, 176 insertions(+), 62 deletions(-) create mode 100644 src/Filee.Engines/Magick/CursorFile.cs create mode 100644 tests/Filee.Engines.Tests/CursorFileTests.cs diff --git a/docs/ENGINES.md b/docs/ENGINES.md index e22d39bce..75757152f 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -263,7 +263,10 @@ in it also builds the package (artifact `windows-arm64-package`). - **More raster formats** through ImageMagick's own coders, each checked against `MagickNET.SupportedFormats` and converted there and back in `ImageFormatTests`: QOI, MNG (animated), DDS (DXT block compression, which is lossy; of a texture with mipmaps the largest image is read; uncompressed 32-bit pixels stored R, G, B, A, which ImageMagick - reads with red and blue swapped, are swapped back when converting and in the crop tool), PCX, CUR (at most 256 × 256, larger pictures are scaled down), XPM, + reads with red and blue swapped, are swapped back when converting and in the crop tool), PCX, CUR (at most 256 × 256, larger pictures are scaled down; ImageMagick writes it as an icon, type 1, whose + directory holds colour planes and bits per pixel where Windows reads the hotspot, so `CursorFile` makes every .cur a + real cursor, type 2, clicking at the top left corner, or, from a CUR or ANI, where the source's hotspot is once the + picture is resized, turned or mirrored), XPM, XBM and WBMP (1-bit), PAM, PFM, SGI (`.sgi`, `.rgb`, `.rgba`, `.bw`, always read as SGI; ImageMagick would take `.rgb` for headerless pixels), Sun Raster (`.ras`, `.sun`) and MIFF. XBM, WBMP, PFM, PCX and Sun Raster are written without transparency (flattened onto the preset's background): PFM has none, and the alpha planes ImageMagick can @@ -350,9 +353,9 @@ in it also builds the package (artifact `windows-arm64-package`). (jiffies of 1/60 s, rounded to hundredths), looping forever; other targets get the first step. To **CUR** the first frame is written as stored, every size and its hotspot; a preset that changes the picture converts it, and the hotspot follows it when it is resized, turned or mirrored (trimmed or set on a background, it goes to the top left - corner). ImageMagick writes a CUR as an icon (type 1, no hotspot): that file is made a cursor afterwards. Files are - read in memory with their sizes checked (at most 64 MB, 1,024 frames, 4,096 steps, 1,024 px a side); frames stored - as raw bitmaps (no `AF_ICON` flag, which Windows' own loaders hardly use) and damaged files give a clear error. + corner; see CUR above). Files are read in memory with their sizes checked (at most 64 MB, 1,024 frames, 4,096 + steps, 1,024 px a side); frames stored as raw bitmaps (no `AF_ICON` flag, which Windows' own loaders hardly use) and + damaged files give a clear error. - **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there). - **Camera RAW** (DNG, CR2, CR3, NEF, ARW, …, and `.raw`) is decoded by LibRaw with the camera's white balance, diff --git a/src/Filee.Engines/Magick/Ani.cs b/src/Filee.Engines/Magick/Ani.cs index 704272793..8d9829542 100644 --- a/src/Filee.Engines/Magick/Ani.cs +++ b/src/Filee.Engines/Magick/Ani.cs @@ -142,60 +142,6 @@ public static bool KeptAsItIs(ImageOptions options) => return [path]; } - /// - /// Makes the .cur ImageMagick wrote from the ANI's first picture a cursor with that picture's hotspot, where it is - /// after the preset turned, mirrored or resized the picture. ImageMagick's CUR writer writes an icon (type 1), whose - /// directory holds colour planes and bits per pixel where a cursor's holds its hotspot. When the hotspot isn't known - /// (the frame is an .ico, or the picture was trimmed or set on a background) it is the top left corner. - /// - public static void KeepHotspot(string sourcePath, IEnumerable written, ImageOptions options) - { - var hotspot = options.TrimTransparent || options.AddsBackdrop ? null : Hotspot(sourcePath); - foreach (var path in written) - { - var data = File.ReadAllBytes(path); - if (data.Length < 22 || BinaryPrimitives.ReadUInt16LittleEndian(data) != 0 - || BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(2)) is not (1 or 2)) - continue; - BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(2), 2); - var count = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(4)); - for (var i = 0; i < count && 6 + i * 16 + 16 <= data.Length; i++) - { - var entry = data.AsSpan(6 + i * 16); - var (x, y) = hotspot is { } known ? Moved(known, options, Side(entry[0]), Side(entry[1])) : (0, 0); - BinaryPrimitives.WriteUInt16LittleEndian(entry[4..], (ushort)x); - BinaryPrimitives.WriteUInt16LittleEndian(entry[6..], (ushort)y); - } - File.WriteAllBytes(path, data); - } - } - - private static uint Side(byte stored) => stored == 0 ? 256u : stored; - - /// - /// Where the hotspot of a .Width × Height picture is once it is turned and mirrored as - /// say () and scaled to × . - /// - internal static (int X, int Y) Moved((int X, int Y, uint Width, uint Height) hotspot, ImageOptions options, uint width, uint height) - { - var (x, y, w, h) = (hotspot.X, hotspot.Y, (int)hotspot.Width, (int)hotspot.Height); - (x, y, w, h) = options.Rotation switch - { - ImageRotation.Right90 => (h - 1 - y, x, h, w), - ImageRotation.Left90 => (y, w - 1 - x, h, w), - ImageRotation.Rotate180 => (w - 1 - x, h - 1 - y, w, h), - _ => (x, y, w, h), - }; - if (options.Flip == ImageFlip.Horizontal) - x = w - 1 - x; - else if (options.Flip == ImageFlip.Vertical) - y = h - 1 - y; - // The centre of the hotspot's pixel is scaled, so it stays on the same part of the picture. - var scaledX = (int)Math.Floor((x + 0.5) * width / w); - var scaledY = (int)Math.Floor((y + 0.5) * height / h); - return (Math.Clamp(scaledX, 0, (int)width - 1), Math.Clamp(scaledY, 0, (int)height - 1)); - } - private static (uint Width, uint Height) Canvas(List<(MagickImage Image, Entry Hotspot)> pictures) => (pictures.Max(p => p.Image.Width), pictures.Max(p => p.Image.Height)); diff --git a/src/Filee.Engines/Magick/CursorFile.cs b/src/Filee.Engines/Magick/CursorFile.cs new file mode 100644 index 000000000..f4bdd91aa --- /dev/null +++ b/src/Filee.Engines/Magick/CursorFile.cs @@ -0,0 +1,87 @@ +// Cursors (.cur) as Filee writes them. ImageMagick's CUR writer writes an icon: the header says type 1, and each size's +// directory entry holds colour planes and bits per pixel where a cursor holds its hotspot (the point that clicks). Windows +// reads those fields as the hotspot, so a "CUR" straight from ImageMagick clicks at (1, 32). Every .cur Filee writes is +// made a real cursor here (type 2), with its hotspot at the top left corner, or, from a cursor (CUR, ANI), where the +// source's hotspot is once the preset resized, turned or mirrored the picture. + +using System.Buffers.Binary; +using Filee.Core.Presets; + +namespace Filee.Engines.Magick; + +internal static class CursorFile +{ + /// + /// Makes the .ico or .cur at a cursor: type 2, every size with its hotspot. The hotspot is + /// that of a .Width × Height source turned and scaled as says, or the + /// top left corner when there is none. A file that isn't an icon or a cursor is left alone. + /// + public static void Write(string path, (int X, int Y, uint Width, uint Height)? hotspot = null, ImageOptions? options = null) + { + var data = File.ReadAllBytes(path); + if (data.Length < 22 || BinaryPrimitives.ReadUInt16LittleEndian(data) != 0 + || BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(2)) is not (1 or 2)) + return; + BinaryPrimitives.WriteUInt16LittleEndian(data.AsSpan(2), 2); + var count = BinaryPrimitives.ReadUInt16LittleEndian(data.AsSpan(4)); + for (var i = 0; i < count && 6 + i * 16 + 16 <= data.Length; i++) + { + var entry = data.AsSpan(6 + i * 16); + var (x, y) = hotspot is { } known ? Moved(known, options ?? new ImageOptions(), Side(entry[0]), Side(entry[1])) : (0, 0); + BinaryPrimitives.WriteUInt16LittleEndian(entry[4..], (ushort)x); + BinaryPrimitives.WriteUInt16LittleEndian(entry[6..], (ushort)y); + } + File.WriteAllBytes(path, data); + } + + /// + /// The hotspot of a .cur file's largest size (the one converted, see ) and + /// that size; null for a file that isn't a cursor (an .ico has no hotspot). + /// + public static (int X, int Y, uint Width, uint Height)? Hotspot(string path) + { + Span header = stackalloc byte[6]; + using var stream = File.OpenRead(path); + if (stream.ReadAtLeast(header, 6, throwOnEndOfStream: false) < 6 || BinaryPrimitives.ReadUInt16LittleEndian(header) != 0 + || BinaryPrimitives.ReadUInt16LittleEndian(header[2..]) != 2) + return null; + var count = BinaryPrimitives.ReadUInt16LittleEndian(header[4..]); + var entries = new byte[count * 16]; + if (count == 0 || stream.ReadAtLeast(entries, entries.Length, throwOnEndOfStream: false) < entries.Length) + return null; + var largest = Enumerable.Range(0, count).MaxBy(i => (long)Side(entries[i * 16]) * Side(entries[i * 16 + 1])); + var entry = entries.AsSpan(largest * 16); + return (BinaryPrimitives.ReadUInt16LittleEndian(entry[4..]), BinaryPrimitives.ReadUInt16LittleEndian(entry[6..]), + Side(entry[0]), Side(entry[1])); + } + + /// The hotspot the preset can carry over: not when the picture is trimmed or set on a background. + public static bool HotspotFollows(ImageOptions options) => !options.TrimTransparent && !options.AddsBackdrop; + + /// A side as a directory entry stores it: 0 means 256. + private static uint Side(byte stored) => stored == 0 ? 256u : stored; + + /// + /// Where the hotspot of a .Width × Height picture is once it is turned and mirrored as + /// say () and scaled to × . + /// + internal static (int X, int Y) Moved((int X, int Y, uint Width, uint Height) hotspot, ImageOptions options, uint width, uint height) + { + var (x, y, w, h) = (hotspot.X, hotspot.Y, (int)hotspot.Width, (int)hotspot.Height); + (x, y, w, h) = options.Rotation switch + { + ImageRotation.Right90 => (h - 1 - y, x, h, w), + ImageRotation.Left90 => (y, w - 1 - x, h, w), + ImageRotation.Rotate180 => (w - 1 - x, h - 1 - y, w, h), + _ => (x, y, w, h), + }; + if (options.Flip == ImageFlip.Horizontal) + x = w - 1 - x; + else if (options.Flip == ImageFlip.Vertical) + y = h - 1 - y; + // The centre of the hotspot's pixel is scaled, so it stays on the same part of the picture. + var scaledX = (int)Math.Floor((x + 0.5) * width / w); + var scaledY = (int)Math.Floor((y + 0.5) * height / h); + return (Math.Clamp(scaledX, 0, (int)width - 1), Math.Clamp(scaledY, 0, (int)height - 1)); + } +} diff --git a/src/Filee.Engines/Magick/ImageEncoder.cs b/src/Filee.Engines/Magick/ImageEncoder.cs index 5dae72608..8e2f534c5 100644 --- a/src/Filee.Engines/Magick/ImageEncoder.cs +++ b/src/Filee.Engines/Magick/ImageEncoder.cs @@ -502,6 +502,9 @@ public static List Write(IReadOnlyList> frames, strin // Animations and multi-page files written above aren't fitted: the queue refuses them when they're too large. if (options is { MaxSizeMb: > 0 } or { MinSizeMb: > 0 }) FitToSize(frames[i], path, format, SizeLimit.Bytes(options.MaxSizeMb), SizeLimit.MinimumBytes(options.MinSizeMb), options.MaxSizeKeepsPixels); + // ImageMagick writes a CUR as an icon: made a cursor, its hotspot at the top left (CursorFile). + if (targetFormat == "cur") + CursorFile.Write(path); written.Add(path); } return written; diff --git a/src/Filee.Engines/Magick/ImageJoiner.cs b/src/Filee.Engines/Magick/ImageJoiner.cs index 1594b4693..28f37d063 100644 --- a/src/Filee.Engines/Magick/ImageJoiner.cs +++ b/src/Filee.Engines/Magick/ImageJoiner.cs @@ -121,6 +121,8 @@ internal static void Join(IReadOnlyList<(string Path, string Format)> inputs, st if (options.MaxSizeMb > 0 || options.MinSizeMb > 0) ImageEncoder.FitToSize(merged, outputPath, ImageEncoder.OutputFormat(format, rest).Format, SizeLimit.Bytes(options.MaxSizeMb), SizeLimit.MinimumBytes(options.MinSizeMb), options.MaxSizeKeepsPixels); + if (format == "cur") + CursorFile.Write(outputPath); progress?.Report(1); } finally diff --git a/src/Filee.Engines/Magick/MagickImageConverter.cs b/src/Filee.Engines/Magick/MagickImageConverter.cs index 87167a61f..43b136ed0 100644 --- a/src/Filee.Engines/Magick/MagickImageConverter.cs +++ b/src/Filee.Engines/Magick/MagickImageConverter.cs @@ -88,7 +88,7 @@ public Task> ConvertAsync(ConversionStep step, IProgress>(async () => { // An animated cursor as CUR is its first frame as stored, every size and the hotspot, unless the preset - // changes it; then the hotspot follows the picture into the converted cursor. + // changes it; then the hotspot follows the picture into the converted cursor (below). if (step.From == "ani" && step.To == "cur" && Ani.KeptAsItIs(step.Preset.Image) && Ani.SaveFirstCursor(step, progress) is { } cursor) return cursor; // FITS → TIFF keeps the file's depth (16 or 32 bits), which the Q8 build can't: Filee writes it. @@ -102,8 +102,14 @@ public Task> ConvertAsync(ConversionStep step, IProgress - Assert.Equal((x, y), Ani.Moved((10, 20, 32, 32), new ImageOptions { Rotation = rotation, Flip = flip }, (uint)width, (uint)height)); + Assert.Equal((x, y), CursorFile.Moved((10, 20, 32, 32), new ImageOptions { Rotation = rotation, Flip = flip }, (uint)width, (uint)height)); [Fact] public void Jiffies_become_hundredths_of_a_second() diff --git a/tests/Filee.Engines.Tests/CursorFileTests.cs b/tests/Filee.Engines.Tests/CursorFileTests.cs new file mode 100644 index 000000000..9749be5fd --- /dev/null +++ b/tests/Filee.Engines.Tests/CursorFileTests.cs @@ -0,0 +1,67 @@ +// Every .cur Filee writes is a real cursor (type 2) with a hotspot: ImageMagick writes "CUR" as an icon, whose directory +// holds colour planes and bits per pixel where Windows reads the hotspot. From a cursor, the hotspot follows the picture. + +using System.Buffers.Binary; +using Filee.Core.Conversion; +using Filee.Core.Presets; +using ImageMagick; + +namespace Filee.Engines.Tests; + +public class CursorFileTests(EngineFixture fx) : IClassFixture +{ + private async Task ToCurAsync(string source, Preset? preset = null) + { + preset ??= new Preset(); + preset.TargetFormat = "cur"; + var job = await fx.ConvertAsync([source], preset); + Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => $"{f.ErrorKey}: {f.ErrorDetail}"))); + return File.ReadAllBytes(Assert.Single(job.Outputs)); + } + + private static ushort Type(byte[] cur) => BinaryPrimitives.ReadUInt16LittleEndian(cur.AsSpan(2)); + + private static (int X, int Y) Hotspot(byte[] cur, int entry = 0) => + (BinaryPrimitives.ReadUInt16LittleEndian(cur.AsSpan(6 + entry * 16 + 4)), + BinaryPrimitives.ReadUInt16LittleEndian(cur.AsSpan(6 + entry * 16 + 6))); + + private string SaveCursor() + { + var path = Path.Combine(fx.NewFolder(), "pointer.cur"); + File.WriteAllBytes(path, AniBuilder.Cursor((MagickColors.Red, 32, 10, 20))); + return path; + } + + [Fact] + public async Task A_picture_becomes_a_real_cursor_clicking_at_its_top_left_corner() + { + var source = EngineFixture.MakeImage(fx.NewFolder(), "a.png", MagickFormat.Png, 64, 64); + var cur = await ToCurAsync(source); + Assert.Equal(2, Type(cur)); // not an icon (1) named .cur + var count = BinaryPrimitives.ReadUInt16LittleEndian(cur.AsSpan(4)); + Assert.All(Enumerable.Range(0, count), i => Assert.Equal((0, 0), Hotspot(cur, i))); + using var image = new MagickImage(cur, new MagickReadSettings { Format = MagickFormat.Cur }); + Assert.Equal((64u, 64u), (image.Width, image.Height)); + } + + [Fact] + public async Task A_cursor_keeps_its_hotspot_when_it_is_resized_or_mirrored() + { + var source = SaveCursor(); + var half = await ToCurAsync(source, new Preset { Image = { Resize = ResizeMode.Percent, ResizePercent = 50 } }); + Assert.Equal(2, Type(half)); + Assert.Equal((5, 10), Hotspot(half)); + + var mirrored = await ToCurAsync(source, new Preset { Image = { Flip = ImageFlip.Horizontal } }); + Assert.Equal((21, 20), Hotspot(mirrored)); + } + + [Fact] + public async Task A_trimmed_cursor_clicks_at_its_top_left_corner() + { + // Where the hotspot ends up once the transparent border is cut isn't known. + var cur = await ToCurAsync(SaveCursor(), new Preset { Image = { TrimTransparent = true } }); + Assert.Equal(2, Type(cur)); + Assert.Equal((0, 0), Hotspot(cur)); + } +} From 3d12ba1a2dbd029673f6fb5b214be21f5da8e067 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:01:06 +0000 Subject: [PATCH 3/4] fix(image): check an ANI's pixel budget before decoding Review of #1390: Read and Hotspot decoded every frame before comparing with the pixel budget, and a frame's directory could list any number of sizes, all pointing at one well-compressed 1024 px PNG, which ImageMagick would decode each time. Each frame's directory is now read and checked when the frame is parsed: at most 32 sizes per frame (real cursors have a handful; Bibata's largest have 14), and, before anything is decoded, the sum of width x height over every size of every frame, and the steps times the largest canvas, are both held to the same limit (1,024 steps of a 256 px cursor). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5pr2HpsMpvTujh7kW6 --- src/Filee.Engines/Magick/Ani.cs | 44 +++++++++++++++++-------- tests/Filee.Engines.Tests/AniTests.cs | 46 +++++++++++++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/src/Filee.Engines/Magick/Ani.cs b/src/Filee.Engines/Magick/Ani.cs index 8d9829542..55f1ec4d2 100644 --- a/src/Filee.Engines/Magick/Ani.cs +++ b/src/Filee.Engines/Magick/Ani.cs @@ -27,7 +27,13 @@ internal static class Ani /// The widest or tallest picture a frame may hold. A cursor stores at most 256 px. private const int MaxSide = 1024; - /// All steps together: 1,024 steps of a 256 px cursor. + /// Sizes in one frame. Real cursors have a handful; a cursor theme's largest have about fifteen. + private const int MaxSizesPerFrame = 32; + + /// + /// The most pixels a file may make: all steps together, and all sizes of all frames together (ImageMagick decodes + /// every size of a frame). 1,024 steps of a 256 px cursor. + /// private const long MaxAnimationPixels = 1024L * 256 * 256; /// The longest step a GIF stores (65,535 hundredths of a second). @@ -35,14 +41,17 @@ internal static class Ani private const uint IconFlag = 1; // AF_ICON: the frames are .ico/.cur files, not raw bitmaps - /// One frame: a whole .cur or .ico file. - private sealed record Frame(byte[] Data, bool IsCursor); + /// One frame: a whole .cur or .ico file, and its sizes as its directory lists them. + private sealed record Frame(byte[] Data, bool IsCursor, List Sizes); /// A step of the animation: the frame it shows and for how long, in jiffies (1/60 s). private readonly record struct Step(int Frame, uint Jiffies); - /// One size of a frame as its directory lists it: the cursor's hotspot (0, 0 in an .ico). - private readonly record struct Entry(int HotspotX, int HotspotY); + /// + /// One size of a frame as its directory lists it: the cursor's hotspot (0, 0 in an .ico) and the size of the picture + /// it points to. + /// + private readonly record struct Entry(int HotspotX, int HotspotY, long Width, long Height); /// /// The animation: one image per step, each as large as the largest frame (smaller frames at the top left), with the @@ -132,7 +141,6 @@ public static bool KeptAsItIs(ImageOptions options) => var frame = frames[steps[0].Frame]; if (!frame.IsCursor) return null; - Entries(frame.Data); // checked as Read would progress?.Report(0.5); var path = step.Output.Allocate("cur"); if (path is null) @@ -153,7 +161,7 @@ private static (uint Width, uint Height) Canvas(List<(MagickImage Image, Entry H { foreach (var frame in frames) { - var entries = Entries(frame.Data); + var entries = frame.Sizes; using var sizes = new MagickImageCollection(); try { @@ -182,14 +190,17 @@ private static (uint Width, uint Height) Canvas(List<(MagickImage Image, Entry H } /// - /// The directory of a frame (a .cur or .ico file): each size's hotspot, after checking that the sizes it points to - /// are in the frame and are not larger than a cursor can be. + /// The directory of a frame (a .cur or .ico file): each size's hotspot and picture size, after checking that the + /// frame has no more sizes than a cursor does, and that the pictures they point to are in the frame and are not larger + /// than a cursor can be. /// private static List Entries(byte[] icon) { var count = BinaryPrimitives.ReadUInt16LittleEndian(icon.AsSpan(4)); if (count == 0 || 6 + count * 16 > icon.Length) throw Damaged(); + if (count > MaxSizesPerFrame) + throw new InvalidDataException($"A frame of this animated cursor has more than {MaxSizesPerFrame} sizes, which no cursor has."); var entries = new List(count); for (var i = 0; i < count; i++) { @@ -208,7 +219,8 @@ private static List Entries(byte[] icon) : throw Damaged(); if (width > MaxSide || height > MaxSide) throw new InvalidDataException("A frame of this animated cursor is too large to be a cursor."); - entries.Add(new Entry(BinaryPrimitives.ReadUInt16LittleEndian(entry[4..]), BinaryPrimitives.ReadUInt16LittleEndian(entry[6..]))); + entries.Add(new Entry(BinaryPrimitives.ReadUInt16LittleEndian(entry[4..]), BinaryPrimitives.ReadUInt16LittleEndian(entry[6..]), + width, height)); } return entries; } @@ -292,6 +304,12 @@ private static (List Frames, List Steps) Parse(string path) throw Damaged(); steps.Add(new Step((int)frame, rates is not null && i < rates.Length ? rates[i] : defaultJiffies.Value)); } + // Before anything is decoded: ImageMagick decodes every size of every frame (several sizes can point at the same + // well-compressed picture), and each step is a copy of its frame's largest size on the largest frame's canvas. + var decoded = frames.Sum(f => f.Sizes.Sum(s => s.Width * s.Height)); + var canvas = frames.Max(f => f.Sizes.Max(s => s.Width)) * frames.Max(f => f.Sizes.Max(s => s.Height)); + if (decoded > MaxAnimationPixels || steps.Count * canvas > MaxAnimationPixels) + throw new InvalidDataException("This animated cursor has too many steps or too many or too large pictures to convert."); return (frames, steps); } @@ -329,15 +347,15 @@ private static uint[] Numbers(byte[] data, Part body) return numbers; } - /// A frame: a .cur (type 2) or .ico (type 1) file. + /// A frame: a .cur (type 2) or .ico (type 1) file, with its directory read and checked. private static Frame ReadFrame(byte[] icon) { if (icon.Length < 6 || BinaryPrimitives.ReadUInt16LittleEndian(icon) != 0) throw Damaged(); return BinaryPrimitives.ReadUInt16LittleEndian(icon.AsSpan(2)) switch { - 1 => new Frame(icon, false), - 2 => new Frame(icon, true), + 1 => new Frame(icon, false, Entries(icon)), + 2 => new Frame(icon, true, Entries(icon)), _ => throw Damaged(), }; } diff --git a/tests/Filee.Engines.Tests/AniTests.cs b/tests/Filee.Engines.Tests/AniTests.cs index 4a65fee0b..49efce2e0 100644 --- a/tests/Filee.Engines.Tests/AniTests.cs +++ b/tests/Filee.Engines.Tests/AniTests.cs @@ -194,6 +194,28 @@ public void Jiffies_become_hundredths_of_a_second() Assert.Equal(65_535u, Ani.Delay(uint.MaxValue)); } + /// A PNG of one colour, px square: a few hundred bytes even at 1024 px. + private static byte[] Png(uint side) + { + using var image = new MagickImage(MagickColors.Red, side, side); + return image.ToByteArray(MagickFormat.Png); + } + + /// A .cur whose directory lists sizes, all pointing at the same . + private static byte[] SharedSizes(byte[] png, int count) + { + var cur = new byte[6 + count * 16 + png.Length]; + BinaryPrimitives.WriteUInt16LittleEndian(cur.AsSpan(2), 2); + BinaryPrimitives.WriteUInt16LittleEndian(cur.AsSpan(4), (ushort)count); + for (var i = 0; i < count; i++) + { + BinaryPrimitives.WriteUInt32LittleEndian(cur.AsSpan(6 + i * 16 + 8), (uint)png.Length); + BinaryPrimitives.WriteUInt32LittleEndian(cur.AsSpan(6 + i * 16 + 12), (uint)(6 + count * 16)); + } + png.CopyTo(cur, 6 + count * 16); + return cur; + } + public static TheoryData Broken => new() { { "cut short", "cut short" }, @@ -204,6 +226,9 @@ public void Jiffies_become_hundredths_of_a_second() { "seq past the frames", "damaged" }, { "damaged frame", "damaged" }, { "huge frame", "too large" }, + { "too many sizes", "more than 32 sizes" }, + { "sizes over the budget", "too many or too large pictures" }, + { "steps over the budget", "too many steps" }, }; [Theory] @@ -241,6 +266,27 @@ public async Task A_broken_file_is_refused_with_a_clear_message(string kind, str damaged.Frames.Add([0, 0, 2, 0, 1, 0, 32, 32, 0, 0, 0, 0, 0, 0, 0xff, 0xff, 0, 0, 22, 0, 0, 0]); data = damaged.Build(); break; + case "too many sizes": + var many = new AniBuilder(); + many.Frames.Add(SharedSizes(Png(32), 33)); + data = many.Build(); + break; + case "sizes over the budget": + // A small file whose frames list sizes that all point at one well-compressed 1024 px PNG: 3 × 32 of them + // would make 100 million pixels. Refused before ImageMagick decodes any. + var shared = new AniBuilder(); + var frame = SharedSizes(Png(1024), 32); + shared.Frames.AddRange([frame, frame, frame]); + data = shared.Build(); + Assert.True(data.Length < 64 * 1024, $"{data.Length} bytes"); + break; + case "steps over the budget": + // 4,096 steps of one 256 px frame: each step is a copy of it. + var steps = new AniBuilder(); + steps.Frames.Add(SharedSizes(Png(256), 1)); + steps.Sequence = new uint[4096]; + data = steps.Build(); + break; default: // A frame whose directory says it holds a 4096 px picture. var cursor = AniBuilder.Cursor((MagickColors.Red, 32, 0, 0)); From 302b38d6ba560af6c6cdce574beb7e75ec710db3 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 11 Nov 2026 18:01:19 +0000 Subject: [PATCH 4/4] docs(engines): note the ANI limits checked before decoding Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPxh5pr2HpsMpvTujh7kW6 --- docs/ENGINES.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/ENGINES.md b/docs/ENGINES.md index 75757152f..bdf4cf283 100644 --- a/docs/ENGINES.md +++ b/docs/ENGINES.md @@ -354,7 +354,8 @@ in it also builds the package (artifact `windows-arm64-package`). frame is written as stored, every size and its hotspot; a preset that changes the picture converts it, and the hotspot follows it when it is resized, turned or mirrored (trimmed or set on a background, it goes to the top left corner; see CUR above). Files are read in memory with their sizes checked (at most 64 MB, 1,024 frames, 4,096 - steps, 1,024 px a side); frames stored as raw bitmaps (no `AF_ICON` flag, which Windows' own loaders hardly use) and + steps, 32 sizes a frame, 1,024 px a side, and a pixel budget for all sizes and steps together, checked before + ImageMagick decodes anything); frames stored as raw bitmaps (no `AF_ICON` flag, which Windows' own loaders hardly use) and damaged files give a clear error. - **Live / Motion Photos from a video** (JPG and HEIC targets) are FFmpeg's: the clip and the photo's frame come from it, ImageMagick or the HEIC writer only encode that frame (see *Making Live / Motion Photos* there).