diff --git a/README.md b/README.md
index f2be1ae6f..8beb1a036 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) 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 e4d44bb70..c2223f90e 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 |
@@ -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
@@ -344,6 +347,16 @@ 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; see CUR above). Files are read in memory with their sizes checked (at most 64 MB, 1,024 frames, 4,096
+ 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).
- **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 e34c092ec..fdc4f7b9b 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", "zxp", "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", "xm", "it"
+ "stl", "obj", "3mf", "ply", "fbx", "geojson", "kml", "kmz", "gpx", "shp", "fit", "osm", "xm", "it", "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", "zxp", "cab", "cpio", "deb", "rpm"
], ["developer"]),
diff --git a/src/Filee.Core/Formats/FormatRegistry.cs b/src/Filee.Core/Formats/FormatRegistry.cs
index ef52ed94b..abf5aa432 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 073790bc2..d29daa5ff 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 = 84;
+ public const int CurrentSchemaVersion = 85;
public int SchemaVersion { get; set; } = CurrentSchemaVersion;
diff --git a/src/Filee.Core/Settings/UserDataStore.cs b/src/Filee.Core/Settings/UserDataStore.cs
index 77ee0bb94..b878b85cf 100644
--- a/src/Filee.Core/Settings/UserDataStore.cs
+++ b/src/Filee.Core/Settings/UserDataStore.cs
@@ -1128,6 +1128,9 @@ public static bool ApplyToLibrary(int from, List presets, List p.Id == id)) { folders.PresetIds.Add(id); changed = true; }
}
+ // v85: Windows animated cursors (.ani, #1365) learn the images donut; custom claims stay put.
+ if (from < 85)
+ changed |= LearnExtensions(profiles, out _, ["ani"]);
return changed;
}
diff --git a/src/Filee.Engines/Magick/Ani.cs b/src/Filee.Engines/Magick/Ani.cs
new file mode 100644
index 000000000..55f1ec4d2
--- /dev/null
+++ b/src/Filee.Engines/Magick/Ani.cs
@@ -0,0 +1,368 @@
+// A Windows animated cursor (.ani, issue #1365): a RIFF file of type ACON. Its "anih" header gives the number of frames
+// and steps and the default time of a step, in jiffies (1/60 s). The frames are in a "LIST fram", one "icon" chunk each,
+// and each is a whole .cur or .ico file, which may hold several sizes. An optional "seq " chunk says which frame each step
+// shows (a frame can come back), an optional "rate" chunk how long each step lasts. ImageMagick doesn't read ANI but
+// reads CUR and ICO: Filee takes the frames out here, has ImageMagick decode each (its largest size) and builds the
+// animation step by step. Files come from the Internet: everything is read from a bounded copy in memory, sizes and
+// counts are checked, and a damaged file is refused with a clear message.
+
+using System.Buffers.Binary;
+using Filee.Core.Conversion;
+using Filee.Core.Presets;
+using ImageMagick;
+
+namespace Filee.Engines.Magick;
+
+internal static class Ani
+{
+ /// Real 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;
+
+ /// 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).
+ 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, 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) 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
+ /// 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;
+ 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];
+ }
+
+ 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 = frame.Sizes;
+ 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 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++)
+ {
+ 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..]),
+ width, height));
+ }
+ 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));
+ }
+ // 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);
+ }
+
+ /// 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, 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, Entries(icon)),
+ 2 => new Frame(icon, true, Entries(icon)),
+ _ => 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/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 1640d3914..8e2f534c5 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.
///
@@ -500,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/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..43b136ed0 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 (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.
if (step.From == "fits" && step.To == "tiff")
return FitsRendering.WriteTiff(step, progress, cancellationToken);
@@ -97,7 +101,16 @@ 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..9559dcf64
--- /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_85_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(84, presets, profiles));
+ Assert.Contains("ani", images.Extensions);
+ Assert.False(SettingsMigrations.ApplyToLibrary(84, presets, profiles));
+
+ images.Extensions.Remove("ani");
+ profiles.Insert(0, new ToolbarProfile { Id = "my-cursors", Extensions = ["ANI"] });
+ Assert.False(SettingsMigrations.ApplyToLibrary(84, 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 6a0da3a90..21cf1d304 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", "zxp"
] },
@@ -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", "zxp"
] },
@@ -145,7 +145,7 @@ public void Grade_counts_match_the_published_table()
{
[FormatGrade.Everyday] = 19,
[FormatGrade.General] = 97,
- [FormatGrade.Specialist] = 118,
+ [FormatGrade.Specialist] = 119,
[FormatGrade.Legacy] = 52,
},
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..49efce2e0
--- /dev/null
+++ b/tests/Filee.Engines.Tests/AniTests.cs
@@ -0,0 +1,307 @@
+// 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), 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()
+ {
+ 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));
+ }
+
+ /// 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" },
+ { "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" },
+ { "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]
+ [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;
+ 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));
+ 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);
+ }
+}
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));
+ }
+}