🖼️

NativeAOTでSusieプラグイン(32bit)を作成する

に公開

Magick.NETという多数の画像フォーマットに対応したライブラリがあり、これをベースにSusieプラグインを作成したら便利ではないかと思ったので、試してみることにした。
https://github.com/dlemstra/Magick.NET

クラスライブラリでプロジェクトを作成

開発はVisualStudio2022 Communityを使用する。
フレームワークは.NET9以上を選択。(x86のNativeAOTに必要)
プロジェクト作成

構成マネージャでx86構成を作成

構成マネージャ1
構成マネージャ2
構成マネージャ3

プロジェクトをネイティブAOTビルド用の設定にする

プロジェクトファイル(.csproj)を開いて、PublishAot、IsAotCompatible、AllowUnsafeBlocksを追加し、Trueに設定。ついでにバージョン関係も追加。

<Project Sdk="Microsoft.NET.Sdk">

  <PropertyGroup>
    <TargetFramework>net9.0-windows</TargetFramework>
    <ImplicitUsings>enable</ImplicitUsings>
    <Nullable>enable</Nullable>
    <Platforms>AnyCPU;x86</Platforms>
    <PublishAot>True</PublishAot>
    <IsAotCompatible>True</IsAotCompatible>
    <AllowUnsafeBlocks>True</AllowUnsafeBlocks>
    <FileVersion>1.0.0</FileVersion>
    <AssemblyVersion>1.0.0</AssemblyVersion>
  </PropertyGroup>

</Project>

https://learn.microsoft.com/ja-jp/dotnet/core/deploying/native-aot/?tabs=windows%2Cnet8

NuGetでMagick.NETをインストール

32bit用なので、Magick.NET-Q8-x86を選択。
NuGet

プラグインに必要な関数を実装する

【必須or実装した方がよいもの】
GetPluginInfo
IsSupported
GetPictureInfo
GetPicture
【実装しなくても大丈夫そう】
GetPreview(実装しないなら-1を返す)
ConfigurationDlg(実装しないなら-1を返す)

Susie Plug-in Specification Rev4+α on Win32

IsSupportedは、手抜き実装なら1固定で返すだけでいいかも…?
以下は、とりあえず実装したソース。(少し長いです)

ソース
using ImageMagick;
using System.Buffers;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;

namespace IfMagick;

public class Program
{
    [DllImport("kernel32", CharSet = CharSet.Unicode)]
    static extern IntPtr GetModuleHandle(string fileName);

    [DllImport("kernel32", CharSet = CharSet.Unicode)]
    static extern IntPtr LoadLibrary(string fileName);

    [DllImport("kernel32", CharSet = CharSet.Unicode)]
    static extern bool GetModuleFileName(IntPtr hModule, out char fileName, int size);

    [DllImport("kernel32")]
    static extern IntPtr LocalAlloc(uint uFlags, UIntPtr uBytes);

    [StructLayout(LayoutKind.Sequential)]
    public struct BITMAPINFOHEADER
    {
        public uint biSize;
        public int biWidth;
        public int biHeight;
        public ushort biPlanes;
        public ushort biBitCount;
        public uint biCompression;
        public uint biSizeImage;
        public int biXPelsPerMeter;
        public int biYPelsPerMeter;
        public uint biClrUsed;
        public uint biClrImportant;

        public static BITMAPINFOHEADER CreateBMP32(int width, int height) =>
            new BITMAPINFOHEADER() {
                biSize = (uint)Marshal.SizeOf<BITMAPINFOHEADER>(),
                biWidth = width,
                biHeight = -height,
                biPlanes = 1,
                biBitCount = 32,
                biCompression = BI_RGB,
                biSizeImage = (uint)(width * Math.Abs(height) * 4),
            };
    }

    [StructLayout(LayoutKind.Sequential, Pack = 1)]
    public struct PictureInfo
    {
        public int left;
        public int top;
        public int width;             /* 画像の幅(pixel) */
        public int height;            /* 画像の高さ(pixel) */
        public short x_density;         /* 画素の水平方向密度 */
        public short y_density;         /* 画素の垂直方向密度 */
        public short colorDepth;       /* 画素当たりのbit数 */
        public IntPtr hInfo;           /* 画像内のテキスト情報 */
    }

    private unsafe class UnsafeWriter<T> : IBufferWriter<T> where T : unmanaged
    {
        private void* _pointer;
        private int _position;
        private int _length;
        private int Remaining => _length - _position;

        public UnsafeWriter(void* buffer, int length)
        {
            _pointer = buffer;
            _position = 0;
            _length = length;
        }

        public void Advance(int count)
            => _position += count;

        public Memory<T> GetMemory(int sizeHint = 0)
            => new Memory<T>(GetSpan(sizeHint).ToArray());

        public Span<T> GetSpan(int sizeHint = 0)
        {
            if (sizeHint < 0 || Remaining <= 0)
                return Span<T>.Empty;

            var prevPosition = _position;
            if (sizeHint == 0 || sizeHint >= Remaining)
            {
                return new Span<T>(_pointer, _length).Slice(_position);
            }

            return new Span<T>(_pointer, _length).Slice(_position, sizeHint);
        }
    }

    public enum ImageSource : int
    {
        Disk = 0,
        Memory = 1,
    }

    public enum PluginInfoNo : int
    {
        APIVersion = 0,
        PluginName,
        Extentions,
        PluginNameDialog,
    }

    public enum DlgFuncNo : int
    {
        About = 0,
        Config,
    }

    private static bool _enableLog = false;
    private static readonly string _dllDirThis = "";
    private static readonly string _dllPathThis = "";
    private static readonly string _dllPathMagickImpl = "";
    private static readonly string _logPath = "";
    private static readonly string _version = Assembly.GetExecutingAssembly()
                                                .GetCustomAttribute<AssemblyFileVersionAttribute>()!
                                                .Version;

    private const int BI_RGB = 0;
    private const string MagickImpl = "Magick.Native-Q8-x86.dll";

    static Program()
    {
        var handle = GetModuleHandle($"{nameof(IfMagick)}.spi");
        WriteLog($"handle:0x{handle:X16}");
        if (handle == IntPtr.Zero)
            return;

        Span<char> buf = stackalloc char[260];
        buf.Fill('\0');
        if (!GetModuleFileName(handle, out buf[0], buf.Length))
            return;

        _dllPathThis = new string(buf).Trim('\0');
        _dllDirThis = Path.GetDirectoryName(_dllPathThis)!;
        _dllPathMagickImpl = Path.Combine(_dllDirThis, MagickImpl);
        _logPath = Path.Combine(_dllDirThis, $"{nameof(IfMagick)}.log");
        WriteLog($"_dllPathThis:{_dllPathThis}");
        WriteLog($"_dllDirThis:{_dllDirThis}");
        WriteLog($"_dllPathMagickImpl:{_dllPathMagickImpl}");
        LoadLibrary(_dllPathMagickImpl);
    }

    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)], EntryPoint = "GetPluginInfo")]
    public static unsafe int GetPluginInfo(PluginInfoNo infoNo, IntPtr buf, int buflen)
    {
        WriteLog($"GetPluginInfo infoNo:{infoNo} buf:0x{buf:X16} buflen:{buflen:X16}");
        int result = 0;
        try
        {
            string info;
            switch (infoNo)
            {
                case PluginInfoNo.APIVersion:
                    info = "00IN";
                    break;

                case PluginInfoNo.PluginName:
                case PluginInfoNo.PluginNameDialog:
                    info = $"{nameof(IfMagick)} Plug-in ver{_version}";
                    break;

                case PluginInfoNo.Extentions:
                    info = "*.ai;*.avif;*.bmp;*.dds;*.dib;*.cur;*.ico;*.gif;*.jpg;*.jpeg;*.png;*.psd;*.raw;*.svg;*.tif;*.tiff;*.webp";
                    break;

                default:
                    return result;
            }
            if (buflen - 1 < info.Length)
            {
                info = info.Substring(0, buflen - 1);
            }
            info += "\0";
            WriteLog($"info:{info}");

            var pDst = (byte*)buf;
            var src = System.Text.Encoding.ASCII.GetBytes(info);
            Unsafe.CopyBlockUnaligned(ref pDst[0], ref src[0], (uint)src.Length);
            result = src.Length;
            return result;
        }
        finally
        {
            WriteLog($"return {result}");
        }
    }

    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)], EntryPoint = "IsSupported")]
    public static int IsSupported(IntPtr pFileName, IntPtr handleOrPtr)
    {
        var fileName = "(null)";
        if (pFileName != IntPtr.Zero)
            fileName = Marshal.PtrToStringAnsi(pFileName);

        WriteLog($"IsSupported fileName:{fileName} handleOrPtr:0x{handleOrPtr:X16}");
        var result = 1;
        WriteLog($"return {result}");
        return result;
    }

    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)], EntryPoint = "GetPictureInfo")]
    public static unsafe int GetPictureInfo(IntPtr fileNameOrPtr, int offsetOrLength, ImageSource target, PictureInfo* lpInfo)
    {
        WriteLog($"GetPictureInfo fileNameOrPtr:0x{fileNameOrPtr:X16} offsetOrLength:{offsetOrLength} target:{target}");
        int result = 0;
        try
        {
            using (var image = LoadMagickImage(fileNameOrPtr, offsetOrLength, target))
            {
                lpInfo->left = 0;
                lpInfo->top = 0;
                lpInfo->width = (int)image.Width;
                lpInfo->height = (int)image.Height;
                lpInfo->x_density = (short)image.Density.X;
                lpInfo->y_density = (short)image.Density.Y;
                lpInfo->colorDepth = (short)image.DetermineBitDepth();
            }
        }
        catch (Exception e)
        {
            WriteLog(e.ToString());
            result = -1;
        }
        WriteLog($"return {result}");
        return result;
    }

    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)], EntryPoint = "GetPicture")]
    public static unsafe int GetPicture(IntPtr fileNameOrPtr, int offsetOrLength, ImageSource target, BITMAPINFOHEADER** phBIHeader, byte** ppImage, IntPtr lpPrgressCallback, int lData)
    {
        WriteLog($"GetPicture fileNameOrPtr:0x{fileNameOrPtr:X16} offsetOrLength:{offsetOrLength} target:{target}");
        int result = 0;
        try
        {
            using (var image = LoadMagickImage(fileNameOrPtr, offsetOrLength, target))
            {
                const uint LMEM_FIXED = 0;
                var biHeader = BITMAPINFOHEADER.CreateBMP32((int)image.Width, (int)image.Height);
                *phBIHeader = (BITMAPINFOHEADER*)LocalAlloc(LMEM_FIXED, (nuint)Marshal.SizeOf<BITMAPINFOHEADER>());
                **phBIHeader = biHeader;
                *ppImage = (byte*)LocalAlloc(LMEM_FIXED, biHeader.biSizeImage);
                var writer = new UnsafeWriter<byte>(*ppImage, (int)biHeader.biSizeImage);
                image.Write(writer, MagickFormat.Bgra);
            }
        }
        catch (Exception e)
        {
            WriteLog(e.ToString());
            result = -1;
        }

        WriteLog($"return {result}");
        return result;
    }

    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)], EntryPoint = "GetPreview")]
    public static unsafe int GetPreview(IntPtr fileNameOrPtr, int offsetOrLength, ImageSource target, BITMAPINFOHEADER* pHBInfo, IntPtr pImage, IntPtr lpPrgressCallback, int lData)
    {
        WriteLog($"GetPreview fileNameOrPtr:0x{fileNameOrPtr:X16} offsetOrLength:{offsetOrLength} target:{target}");
        int result = -1;
        WriteLog($"return {result}");
        return result;
    }

    [UnmanagedCallersOnly(CallConvs = [typeof(CallConvStdcall)], EntryPoint = "ConfigurationDlg")]
    public static int ConfigurationDlg(IntPtr hwnd, DlgFuncNo funcNo)
    {
        WriteLog($"ConfigurationDlg hwnd:0x{hwnd:X16} funcNo:{funcNo}");
        int result = -1;
        WriteLog($"return {result}");
        return result;
    }

    private static unsafe MagickImage LoadMagickImage(IntPtr fileNameOrPtr, int offsetOrLength, ImageSource target)
    {
        if (target == ImageSource.Disk)
        {
            var fileName = Marshal.PtrToStringAnsi(fileNameOrPtr)!;
            WriteLog($"fileName:{fileName}");
            return new MagickImage(fileName);
        }
        else
        {
            return new MagickImage(new ReadOnlySpan<byte>((void*)fileNameOrPtr, offsetOrLength));
        }
    }

    private static void WriteLog(string message)
    {
        if (!_enableLog || _logPath == "")
            return;

        File.AppendAllText(_logPath, message + Environment.NewLine);
    }
}

Magick.NETは、もっと多くの画像形式に対応しているけれど、自分の使いそうな拡張子だけ入れている。真面目に実装するなら、ダイアログも実装して設定ファイル化しておくと良さそう。

ビルドする

IDE上で直接ネイティブDLLは出力出来なさそうなので、コマンドラインで出力する。(発行でEXEでは出力出来るのだが、何故かDLLだとアカンと言われる)
コマンド長いのでバッチファイルを作成しておくのを推奨。

dotnet publish /p:NativeLib=Shared --use-current-runtime -r win-x86

ビルドされたファイル

サイズはまあまあ大きいけれど、C#でネイティブDLL作れるのはいい感じ。

Dependenciesで、エクスポートされた関数を確認してみたが、きちんとエクスポートされているようだ。

アプリで動作確認

自分はMassiGraとLeeyesの二つの画像ビューワーを愛用しているので、とりあえずこの二つで動けばOKとする。作成したDLLの拡張子をspiに変更し、Magick.Native-Q8-x86.dll と共にプラグインフォルダにコピーする。

MassiGra

Leeyes

画像が無事に表示された。いくつかのプラグインをやめて、これ一つに集約できそうだ。

Discussion