Windowsエクスプローラーで独自ファイル形式のサムネイルを表示する

独自アプリケーションを作っていると、専用ファイル形式にもWindowsエクスプローラー上でサムネイルを表示したくなることがあります。 やり方を調べて実装してみたので、方法を解説する。

ここでは、アプリケーション名をmyApp、保存ファイルの拡張子を.myAppDataとして解説していきます。 結論としては、保存ファイルにプレビュー画像を埋め込み、WindowsのThumbnail Providerを登録する感じです。

サムネイルプレビューを生成する方法は主に2つ

独自形式のサムネイルを作る方法は、大きく分けて2つあります。

  1. 動的な方法:ファイルを読み込んで、その場で画像を作る
  2. 静的な方法:ファイル内のプレビュー画像を読み出す

どちらの場合も、表示するにはThumbnail Providerを実装する必要があります。 また、前者の動的な方法(プレビュー画像を埋め込まず、その場でレンダリング)は、軽いデータならなんとかなると思いますが、3D CADのようなデータの場合は、BREP読み込み、テッセレーション、オフスクリーンレンダリングなどを含むとっても重い処理になります。 ですので、本記事では、後者の静的な方法で、プレビューを埋め込んでそれを表示する方法を採用します。というかこれ一択なのでは。。。

ということで、後者の方法で、

  1. サムネイルの生成
  2. サムネイルの独自フォーマットへの埋め込み
  3. Thumbnail Providerの実装

という流れで解説していきましょう。

サムネイル画像を作る

保存時に適当にレンダリングして、512×512程度のPNG画像を作っておきます。

画像形式は、PNGでもJPEGでもBMPでもWebPでも良いと思いますが、PNGでいいんじゃないでしょうか。 libpngとか使わなくても、Windows標準のWindows Imaging Componentで読み込めるのでPNGが扱いやすいです。

画像サイズは、512×512ピクセル程度あれば十分かなと思います。

適当で良いとはいえ、いくつか注意点はあります。特に3D CADのようなアプリケーションでは注意が必要です。

最後に表示していた画面をそのまま使うのは避けよう

ユーザーが最後に見ていたビューを、そのままプレビューとして保存すると、微妙なサムネイルになることがあります。

たとえば次のような状態だと、ちょっと残念感があります。

  • 一部へ極端にズームしている
  • 断面表示になっている
  • 選択中の面が強調表示されている
  • グリッドや座標軸が表示されている
  • 編集中の一時形状が残っている

サムネイル画像生成に失敗してもファイル保存を止めない

サムネイル画像は、あくまで補助データなので、失敗してもユーザに多めに見てもらえると思います。 PNGの生成やエンコードに失敗しても、独自ファイル型式 .myAppData 保存処理を停止する必要はないはずです。サムネイル画像生成に失敗した場合も他のモデルデータの保存を継続し、ファイル書き出しを完了させるようにしましょー。

サムネイル画像がない場合は、エクスプローラーに通常のファイルアイコンを表示させれば問題なし。 でも、画像が入っていないこともある、という前提でプレビュー機能作る点だけ注意。

こうしておきたい

ですので、サムネイル保存時は、専用のカメラ設定でレンダリングされた画像を保存するのがよいです。

  • モデル全体が収まるようにフィット。
  • カメラ視点は、アイソメで。
  • 背景は白か黒で、ワイヤーフレームだけでなく軽いシェーディングや輪郭線を付ける

ファイル内のプレビュー画像を読み出す

2つ目は、myAppでファイルを保存するときにPNG画像を作り、.myAppDataの中へ埋め込んでおく方法です。

sample.myAppData
├─ ファイルヘッダー
├─ ドキュメント情報
├─ 3Dモデルデータ
└─ Preview PNG

Thumbnail Providerは、3Dモデル全体を読み込まず、埋め込まれているPNGだけを取り出します。

sample.myAppData
Preview PNGだけを読む
要求されたサイズへ縮小する
Windowsへ返す

独自形式を設計できるなら、基本的にはこちらを選ぶのがよいです。

プレビュー画像の保存方法

実際のファイルでは、プレビュー画像をチャンクとして管理しておくと扱いやすくなります。

MyAppData Header
Chunk Table
Document Chunk
Geometry Chunk
Preview Chunk

たとえば、プレビュー情報は次のような構造にできます。

struct PreviewHeader
{
    std::uint32_t version;
    std::uint32_t imageFormat;
    std::uint32_t width;
    std::uint32_t height;
    std::uint64_t dataSize;
};

プレビューを持たない古いファイルも考慮し、ヘッダーやチャンクテーブルからプレビューの有無を判定できるようにしておきます。

3Dアプリでありがちな失敗

サムネイル生成のために外部参照を全部読む

アセンブリのプレビューを作るために、未ロードの参照部品まですべて読み込むと、保存処理が重くなります。

すでに画面に表示されているシーンや表示用メッシュから画像を作る方が簡単です。

サムネイルを作るためだけに、抑制された部品や未解決の参照ファイルまで読む必要はありません。

透明背景と細い線だけで済ませる

透明背景に細い黒線だけを描くと、Windowsのテーマや背景色によってはかなり見づらくなります。

ワイヤーフレームだけではなく、軽いシェーディングや輪郭線を付けておいた方が形状を判別しやすいです。

白背景と黒背景のどちらでも、それなりに見える画像を意識した方がよいです。

実装の分け方

実装は、次のように分けます。

myApp.exe
    └─ .myAppData保存時にPreview PNGを生成

MyAppDataPreviewReader.dll
    └─ ヘッダーとPreview Chunkだけを読む

MyAppThumbnailProvider.dll
    ├─ IInitializeWithStream
    ├─ IThumbnailProvider
    └─ WICでPNGをHBITMAPへ変換

MyAppDataPreviewReader.dllは、通常のドキュメント読み込みライブラリとは分けます。

モデリングカーネルやレンダラーには依存させず、ファイルヘッダーとPreview Chunkだけを読めるようにします。

MyAppDataPreviewReader.dllのサンプル

次は、1ファイルでビルドできる最小構成のサンプルです。

説明を簡単にするため、.myAppDataの先頭に次のヘッダーがあり、その後ろにPNGデータが格納されているものとします。

MyAppDataFileHeader
任意のドキュメントデータ
Preview PNG

実際の製品では、固定ヘッダーではなくチャンクテーブル形式に置き換えてください。

MyAppDataPreviewReader.cpp

#include <Windows.h>
#include <objidl.h>

#include <cstddef>
#include <cstdint>
#include <limits>
#include <new>
#include <vector>

namespace
{
constexpr std::uint32_t kMyAppDataMagic = 0x5041444D;
// リトルエンディアン上で "MDAP" に相当するサンプル値です。

constexpr std::uint32_t kSupportedFileVersion = 1;
constexpr std::uint64_t kMaximumPreviewSize =
    32ULL * 1024ULL * 1024ULL;

#pragma pack(push, 1)
struct MyAppDataFileHeader
{
    std::uint32_t magic;
    std::uint32_t fileVersion;
    std::uint64_t previewOffset;
    std::uint64_t previewSize;
};
#pragma pack(pop)

bool IsValidRange(
    std::uint64_t offset,
    std::uint64_t size,
    std::uint64_t streamSize)
{
    if (offset > streamSize)
    {
        return false;
    }

    return size <= streamSize - offset;
}

HRESULT ReadExact(
    IStream* stream,
    void* destination,
    ULONG size)
{
    if (stream == nullptr || destination == nullptr)
    {
        return E_POINTER;
    }

    ULONG totalRead = 0;

    while (totalRead < size)
    {
        ULONG bytesRead = 0;

        const HRESULT hr = stream->Read(
            static_cast<std::byte*>(destination) + totalRead,
            size - totalRead,
            &bytesRead);

        if (FAILED(hr))
        {
            return hr;
        }

        if (bytesRead == 0)
        {
            return HRESULT_FROM_WIN32(ERROR_HANDLE_EOF);
        }

        totalRead += bytesRead;
    }

    return S_OK;
}

HRESULT SeekAbsolute(
    IStream* stream,
    std::uint64_t offset)
{
    if (stream == nullptr)
    {
        return E_POINTER;
    }

    if (offset >
        static_cast<std::uint64_t>(
            std::numeric_limits<LONGLONG>::max()))
    {
        return HRESULT_FROM_WIN32(ERROR_ARITHMETIC_OVERFLOW);
    }

    LARGE_INTEGER position{};
    position.QuadPart = static_cast<LONGLONG>(offset);

    return stream->Seek(position, STREAM_SEEK_SET, nullptr);
}

HRESULT GetStreamSize(
    IStream* stream,
    std::uint64_t* streamSize)
{
    if (stream == nullptr || streamSize == nullptr)
    {
        return E_POINTER;
    }

    STATSTG stat{};
    const HRESULT hr = stream->Stat(
        &stat,
        STATFLAG_NONAME);

    if (FAILED(hr))
    {
        return hr;
    }

    *streamSize =
        static_cast<std::uint64_t>(stat.cbSize.QuadPart);

    return S_OK;
}

bool HasPngSignature(
    const std::vector<std::byte>& data)
{
    static constexpr unsigned char kPngSignature[] =
    {
        0x89, 0x50, 0x4E, 0x47,
        0x0D, 0x0A, 0x1A, 0x0A
    };

    if (data.size() < sizeof(kPngSignature))
    {
        return false;
    }

    for (std::size_t index = 0;
         index < sizeof(kPngSignature);
         ++index)
    {
        if (std::to_integer<unsigned char>(data[index]) !=
            kPngSignature[index])
        {
            return false;
        }
    }

    return true;
}
}

// DLL外部へ公開するC形式の関数です。
extern "C" __declspec(dllexport)
HRESULT __stdcall MyAppReadPreviewPng(
    IStream* stream,
    std::vector<std::byte>* pngData)
{
    if (stream == nullptr || pngData == nullptr)
    {
        return E_POINTER;
    }

    pngData->clear();

    try
    {
        std::uint64_t streamSize = 0;

        HRESULT hr = GetStreamSize(
            stream,
            &streamSize);

        if (FAILED(hr))
        {
            return hr;
        }

        if (streamSize < sizeof(MyAppDataFileHeader))
        {
            return HRESULT_FROM_WIN32(ERROR_BAD_FORMAT);
        }

        hr = SeekAbsolute(stream, 0);

        if (FAILED(hr))
        {
            return hr;
        }

        MyAppDataFileHeader header{};

        hr = ReadExact(
            stream,
            &header,
            static_cast<ULONG>(sizeof(header)));

        if (FAILED(hr))
        {
            return hr;
        }

        if (header.magic != kMyAppDataMagic)
        {
            return HRESULT_FROM_WIN32(ERROR_BAD_FORMAT);
        }

        if (header.fileVersion >
            kSupportedFileVersion)
        {
            return HRESULT_FROM_WIN32(
                ERROR_OLD_WIN_VERSION);
        }

        if (header.previewSize == 0)
        {
            return HRESULT_FROM_WIN32(
                ERROR_FILE_NOT_FOUND);
        }

        if (header.previewSize > kMaximumPreviewSize)
        {
            return HRESULT_FROM_WIN32(
                ERROR_FILE_TOO_LARGE);
        }

        if (!IsValidRange(
                header.previewOffset,
                header.previewSize,
                streamSize))
        {
            return HRESULT_FROM_WIN32(
                ERROR_BAD_FORMAT);
        }

        if (header.previewSize >
            static_cast<std::uint64_t>(
                std::numeric_limits<std::size_t>::max()))
        {
            return E_OUTOFMEMORY;
        }

        hr = SeekAbsolute(
            stream,
            header.previewOffset);

        if (FAILED(hr))
        {
            return hr;
        }

        pngData->resize(
            static_cast<std::size_t>(
                header.previewSize));

        hr = ReadExact(
            stream,
            pngData->data(),
            static_cast<ULONG>(
                header.previewSize));

        if (FAILED(hr))
        {
            pngData->clear();
            return hr;
        }

        if (!HasPngSignature(*pngData))
        {
            pngData->clear();

            return HRESULT_FROM_WIN32(
                ERROR_BAD_FORMAT);
        }

        return S_OK;
    }
    catch (const std::bad_alloc&)
    {
        pngData->clear();
        return E_OUTOFMEMORY;
    }
    catch (...)
    {
        pngData->clear();
        return E_FAIL;
    }
}

このサンプルでは、説明を簡単にするため、DLLの公開関数にstd::vectorを使っています。

同じコンパイラーとランタイム設定でビルドしたDLL間でのみ利用する前提です。公開SDKとして配布する場合は、呼び出し側が用意したバッファーへ書き込むC ABIにした方が安全です。

Thumbnail Providerの1ファイルサンプル

次は、MyAppThumbnailProvider.cppだけで構成するサンプルです。

以下を1つのDLLプロジェクトへ追加する想定です。

このコードには、次の処理をまとめています。

  • IInitializeWithStream
  • IThumbnailProvider
  • PNGの読み出し
  • WICによるPNGデコード
  • HBITMAPの生成
  • COMクラスファクトリー
  • DllGetClassObject
  • DllCanUnloadNow

実際の製品では、PNG読み出し部分を先ほどのMyAppDataPreviewReader.dllへ切り出します。

MyAppThumbnailProvider.cpp

#include <Windows.h>
#include <Shlwapi.h>
#include <objbase.h>
#include <objidl.h>
#include <propsys.h>
#include <thumbcache.h>
#include <wincodec.h>
#include <wrl/client.h>

#include <algorithm>
#include <atomic>
#include <cstddef>
#include <cstdint>
#include <limits>
#include <new>
#include <vector>

#pragma comment(lib, "Ole32.lib")
#pragma comment(lib, "Windowscodecs.lib")
#pragma comment(lib, "Shlwapi.lib")

using Microsoft::WRL::ComPtr;

// 実際の製品では新しいGUIDを生成してください。
// {12345678-1234-1234-1234-1234567890AB}
const CLSID CLSID_MyAppThumbnailProvider =
{
    0x12345678,
    0x1234,
    0x1234,
    {
        0x12, 0x34, 0x12, 0x34,
        0x56, 0x78, 0x90, 0xAB
    }
};

namespace
{
std::atomic<long> gModuleReferenceCount = 0;

constexpr std::uint32_t kMyAppDataMagic = 0x5041444D;
constexpr std::uint64_t kMaximumPreviewSize =
    32ULL * 1024ULL * 1024ULL;

#pragma pack(push, 1)
struct MyAppDataFileHeader
{
    std::uint32_t magic;
    std::uint32_t fileVersion;
    std::uint64_t previewOffset;
    std::uint64_t previewSize;
};
#pragma pack(pop)

bool IsValidRange(
    std::uint64_t offset,
    std::uint64_t size,
    std::uint64_t streamSize)
{
    if (offset > streamSize)
    {
        return false;
    }

    return size <= streamSize - offset;
}

HRESULT SeekAbsolute(
    IStream* stream,
    std::uint64_t offset)
{
    if (stream == nullptr)
    {
        return E_POINTER;
    }

    if (offset >
        static_cast<std::uint64_t>(
            std::numeric_limits<LONGLONG>::max()))
    {
        return HRESULT_FROM_WIN32(
            ERROR_ARITHMETIC_OVERFLOW);
    }

    LARGE_INTEGER position{};
    position.QuadPart = static_cast<LONGLONG>(offset);

    return stream->Seek(
        position,
        STREAM_SEEK_SET,
        nullptr);
}

HRESULT ReadExact(
    IStream* stream,
    void* destination,
    ULONG size)
{
    if (stream == nullptr || destination == nullptr)
    {
        return E_POINTER;
    }

    ULONG totalRead = 0;

    while (totalRead < size)
    {
        ULONG bytesRead = 0;

        const HRESULT hr = stream->Read(
            static_cast<std::byte*>(destination) +
                totalRead,
            size - totalRead,
            &bytesRead);

        if (FAILED(hr))
        {
            return hr;
        }

        if (bytesRead == 0)
        {
            return HRESULT_FROM_WIN32(
                ERROR_HANDLE_EOF);
        }

        totalRead += bytesRead;
    }

    return S_OK;
}

HRESULT ReadPreviewPng(
    IStream* stream,
    std::vector<std::byte>& pngData)
{
    pngData.clear();

    STATSTG stat{};
    HRESULT hr = stream->Stat(
        &stat,
        STATFLAG_NONAME);

    if (FAILED(hr))
    {
        return hr;
    }

    const std::uint64_t streamSize =
        static_cast<std::uint64_t>(
            stat.cbSize.QuadPart);

    if (streamSize < sizeof(MyAppDataFileHeader))
    {
        return HRESULT_FROM_WIN32(
            ERROR_BAD_FORMAT);
    }

    hr = SeekAbsolute(stream, 0);

    if (FAILED(hr))
    {
        return hr;
    }

    MyAppDataFileHeader header{};

    hr = ReadExact(
        stream,
        &header,
        static_cast<ULONG>(sizeof(header)));

    if (FAILED(hr))
    {
        return hr;
    }

    if (header.magic != kMyAppDataMagic)
    {
        return HRESULT_FROM_WIN32(
            ERROR_BAD_FORMAT);
    }

    if (header.previewSize == 0)
    {
        return HRESULT_FROM_WIN32(
            ERROR_FILE_NOT_FOUND);
    }

    if (header.previewSize > kMaximumPreviewSize)
    {
        return HRESULT_FROM_WIN32(
            ERROR_FILE_TOO_LARGE);
    }

    if (!IsValidRange(
            header.previewOffset,
            header.previewSize,
            streamSize))
    {
        return HRESULT_FROM_WIN32(
            ERROR_BAD_FORMAT);
    }

    if (header.previewSize >
        static_cast<std::uint64_t>(
            std::numeric_limits<ULONG>::max()))
    {
        return HRESULT_FROM_WIN32(
            ERROR_FILE_TOO_LARGE);
    }

    hr = SeekAbsolute(
        stream,
        header.previewOffset);

    if (FAILED(hr))
    {
        return hr;
    }

    pngData.resize(
        static_cast<std::size_t>(
            header.previewSize));

    return ReadExact(
        stream,
        pngData.data(),
        static_cast<ULONG>(
            header.previewSize));
}

HRESULT CreateWicStreamFromMemory(
    IWICImagingFactory* factory,
    const std::vector<std::byte>& pngData,
    IWICStream** stream)
{
    if (factory == nullptr || stream == nullptr)
    {
        return E_POINTER;
    }

    *stream = nullptr;

    if (pngData.empty() ||
        pngData.size() >
            static_cast<std::size_t>(
                std::numeric_limits<DWORD>::max()))
    {
        return HRESULT_FROM_WIN32(
            ERROR_BAD_FORMAT);
    }

    ComPtr<IWICStream> wicStream;

    HRESULT hr = factory->CreateStream(
        &wicStream);

    if (FAILED(hr))
    {
        return hr;
    }

    hr = wicStream->InitializeFromMemory(
        reinterpret_cast<BYTE*>(
            const_cast<std::byte*>(
                pngData.data())),
        static_cast<DWORD>(
            pngData.size()));

    if (FAILED(hr))
    {
        return hr;
    }

    *stream = wicStream.Detach();
    return S_OK;
}

HRESULT CreateHBitmapFromWicSource(
    IWICBitmapSource* source,
    HBITMAP* bitmap)
{
    if (source == nullptr || bitmap == nullptr)
    {
        return E_POINTER;
    }

    *bitmap = nullptr;

    UINT width = 0;
    UINT height = 0;

    HRESULT hr = source->GetSize(
        &width,
        &height);

    if (FAILED(hr))
    {
        return hr;
    }

    if (width == 0 || height == 0)
    {
        return HRESULT_FROM_WIN32(
            ERROR_BAD_FORMAT);
    }

    const std::uint64_t stride64 =
        static_cast<std::uint64_t>(width) * 4;

    const std::uint64_t bufferSize64 =
        stride64 * height;

    if (stride64 >
            std::numeric_limits<UINT>::max() ||
        bufferSize64 >
            std::numeric_limits<UINT>::max())
    {
        return HRESULT_FROM_WIN32(
            ERROR_FILE_TOO_LARGE);
    }

    BITMAPINFO bitmapInfo{};
    bitmapInfo.bmiHeader.biSize =
        sizeof(BITMAPINFOHEADER);
    bitmapInfo.bmiHeader.biWidth =
        static_cast<LONG>(width);
    bitmapInfo.bmiHeader.biHeight =
        -static_cast<LONG>(height);
    bitmapInfo.bmiHeader.biPlanes = 1;
    bitmapInfo.bmiHeader.biBitCount = 32;
    bitmapInfo.bmiHeader.biCompression = BI_RGB;

    void* pixels = nullptr;

    HBITMAP result = CreateDIBSection(
        nullptr,
        &bitmapInfo,
        DIB_RGB_COLORS,
        &pixels,
        nullptr,
        0);

    if (result == nullptr || pixels == nullptr)
    {
        return HRESULT_FROM_WIN32(
            GetLastError());
    }

    hr = source->CopyPixels(
        nullptr,
        static_cast<UINT>(stride64),
        static_cast<UINT>(bufferSize64),
        static_cast<BYTE*>(pixels));

    if (FAILED(hr))
    {
        DeleteObject(result);
        return hr;
    }

    *bitmap = result;
    return S_OK;
}

HRESULT DecodePngToBitmap(
    const std::vector<std::byte>& pngData,
    UINT requestedSize,
    HBITMAP* bitmap)
{
    if (bitmap == nullptr)
    {
        return E_POINTER;
    }

    *bitmap = nullptr;

    if (requestedSize == 0)
    {
        return E_INVALIDARG;
    }

    ComPtr<IWICImagingFactory> factory;

    HRESULT hr = CoCreateInstance(
        CLSID_WICImagingFactory,
        nullptr,
        CLSCTX_INPROC_SERVER,
        IID_PPV_ARGS(&factory));

    if (FAILED(hr))
    {
        return hr;
    }

    ComPtr<IWICStream> stream;

    hr = CreateWicStreamFromMemory(
        factory.Get(),
        pngData,
        &stream);

    if (FAILED(hr))
    {
        return hr;
    }

    ComPtr<IWICBitmapDecoder> decoder;

    hr = factory->CreateDecoderFromStream(
        stream.Get(),
        nullptr,
        WICDecodeMetadataCacheOnLoad,
        &decoder);

    if (FAILED(hr))
    {
        return hr;
    }

    ComPtr<IWICBitmapFrameDecode> frame;

    hr = decoder->GetFrame(0, &frame);

    if (FAILED(hr))
    {
        return hr;
    }

    UINT sourceWidth = 0;
    UINT sourceHeight = 0;

    hr = frame->GetSize(
        &sourceWidth,
        &sourceHeight);

    if (FAILED(hr))
    {
        return hr;
    }

    if (sourceWidth == 0 || sourceHeight == 0)
    {
        return HRESULT_FROM_WIN32(
            ERROR_BAD_FORMAT);
    }

    UINT targetWidth = requestedSize;
    UINT targetHeight = requestedSize;

    if (sourceWidth >= sourceHeight)
    {
        targetHeight = std::max(
            1U,
            static_cast<UINT>(
                static_cast<std::uint64_t>(
                    sourceHeight) *
                requestedSize /
                sourceWidth));
    }
    else
    {
        targetWidth = std::max(
            1U,
            static_cast<UINT>(
                static_cast<std::uint64_t>(
                    sourceWidth) *
                requestedSize /
                sourceHeight));
    }

    ComPtr<IWICBitmapScaler> scaler;

    hr = factory->CreateBitmapScaler(
        &scaler);

    if (FAILED(hr))
    {
        return hr;
    }

    hr = scaler->Initialize(
        frame.Get(),
        targetWidth,
        targetHeight,
        WICBitmapInterpolationModeFant);

    if (FAILED(hr))
    {
        return hr;
    }

    ComPtr<IWICFormatConverter> converter;

    hr = factory->CreateFormatConverter(
        &converter);

    if (FAILED(hr))
    {
        return hr;
    }

    hr = converter->Initialize(
        scaler.Get(),
        GUID_WICPixelFormat32bppPBGRA,
        WICBitmapDitherTypeNone,
        nullptr,
        0.0,
        WICBitmapPaletteTypeCustom);

    if (FAILED(hr))
    {
        return hr;
    }

    return CreateHBitmapFromWicSource(
        converter.Get(),
        bitmap);
}

class MyAppThumbnailProvider final
    : public IInitializeWithStream
    , public IThumbnailProvider
{
public:
    MyAppThumbnailProvider()
    {
        ++gModuleReferenceCount;
    }

    HRESULT STDMETHODCALLTYPE QueryInterface(
        REFIID riid,
        void** object) override
    {
        if (object == nullptr)
        {
            return E_POINTER;
        }

        *object = nullptr;

        if (riid == IID_IUnknown ||
            riid == IID_IInitializeWithStream)
        {
            *object =
                static_cast<IInitializeWithStream*>(
                    this);
        }
        else if (riid == IID_IThumbnailProvider)
        {
            *object =
                static_cast<IThumbnailProvider*>(
                    this);
        }
        else
        {
            return E_NOINTERFACE;
        }

        AddRef();
        return S_OK;
    }

    ULONG STDMETHODCALLTYPE AddRef() override
    {
        return static_cast<ULONG>(
            ++referenceCount_);
    }

    ULONG STDMETHODCALLTYPE Release() override
    {
        const ULONG result =
            static_cast<ULONG>(
                --referenceCount_);

        if (result == 0)
        {
            delete this;
        }

        return result;
    }

    HRESULT STDMETHODCALLTYPE Initialize(
        IStream* stream,
        DWORD) override
    {
        if (stream == nullptr)
        {
            return E_INVALIDARG;
        }

        if (stream_)
        {
            return HRESULT_FROM_WIN32(
                ERROR_ALREADY_INITIALIZED);
        }

        stream_ = stream;
        return S_OK;
    }

    HRESULT STDMETHODCALLTYPE GetThumbnail(
        UINT requestedSize,
        HBITMAP* bitmap,
        WTS_ALPHATYPE* alphaType) override
    {
        if (bitmap == nullptr ||
            alphaType == nullptr)
        {
            return E_POINTER;
        }

        *bitmap = nullptr;
        *alphaType = WTSAT_UNKNOWN;

        if (!stream_)
        {
            return E_UNEXPECTED;
        }

        try
        {
            std::vector<std::byte> pngData;

            HRESULT hr = ReadPreviewPng(
                stream_.Get(),
                pngData);

            if (FAILED(hr))
            {
                return hr;
            }

            hr = DecodePngToBitmap(
                pngData,
                requestedSize,
                bitmap);

            if (FAILED(hr))
            {
                return hr;
            }

            *alphaType = WTSAT_ARGB;
            return S_OK;
        }
        catch (const std::bad_alloc&)
        {
            return E_OUTOFMEMORY;
        }
        catch (...)
        {
            return E_FAIL;
        }
    }

private:
    ~MyAppThumbnailProvider() override
    {
        --gModuleReferenceCount;
    }

    std::atomic<ULONG> referenceCount_{1};
    ComPtr<IStream> stream_;
};

class MyAppClassFactory final
    : public IClassFactory
{
public:
    MyAppClassFactory()
    {
        ++gModuleReferenceCount;
    }

    HRESULT STDMETHODCALLTYPE QueryInterface(
        REFIID riid,
        void** object) override
    {
        if (object == nullptr)
        {
            return E_POINTER;
        }

        *object = nullptr;

        if (riid == IID_IUnknown ||
            riid == IID_IClassFactory)
        {
            *object =
                static_cast<IClassFactory*>(this);

            AddRef();
            return S_OK;
        }

        return E_NOINTERFACE;
    }

    ULONG STDMETHODCALLTYPE AddRef() override
    {
        return static_cast<ULONG>(
            ++referenceCount_);
    }

    ULONG STDMETHODCALLTYPE Release() override
    {
        const ULONG result =
            static_cast<ULONG>(
                --referenceCount_);

        if (result == 0)
        {
            delete this;
        }

        return result;
    }

    HRESULT STDMETHODCALLTYPE CreateInstance(
        IUnknown* outer,
        REFIID riid,
        void** object) override
    {
        if (outer != nullptr)
        {
            return CLASS_E_NOAGGREGATION;
        }

        if (object == nullptr)
        {
            return E_POINTER;
        }

        *object = nullptr;

        auto* provider =
            new (std::nothrow)
                MyAppThumbnailProvider();

        if (provider == nullptr)
        {
            return E_OUTOFMEMORY;
        }

        const HRESULT hr =
            provider->QueryInterface(
                riid,
                object);

        provider->Release();
        return hr;
    }

    HRESULT STDMETHODCALLTYPE LockServer(
        BOOL lock) override
    {
        if (lock)
        {
            ++gModuleReferenceCount;
        }
        else
        {
            --gModuleReferenceCount;
        }

        return S_OK;
    }

private:
    ~MyAppClassFactory() override
    {
        --gModuleReferenceCount;
    }

    std::atomic<ULONG> referenceCount_{1};
};
}

extern "C"
BOOL WINAPI DllMain(
    HINSTANCE,
    DWORD,
    void*)
{
    return TRUE;
}

extern "C"
HRESULT __stdcall DllCanUnloadNow()
{
    return gModuleReferenceCount == 0
        ? S_OK
        : S_FALSE;
}

extern "C"
HRESULT __stdcall DllGetClassObject(
    REFCLSID clsid,
    REFIID riid,
    void** object)
{
    if (object == nullptr)
    {
        return E_POINTER;
    }

    *object = nullptr;

    if (clsid != CLSID_MyAppThumbnailProvider)
    {
        return CLASS_E_CLASSNOTAVAILABLE;
    }

    auto* factory =
        new (std::nothrow)
            MyAppClassFactory();

    if (factory == nullptr)
    {
        return E_OUTOFMEMORY;
    }

    const HRESULT hr =
        factory->QueryInterface(
            riid,
            object);

    factory->Release();
    return hr;
}

DLLのエクスポートには、.defファイルを追加します。

MyAppThumbnailProvider.def

LIBRARY "MyAppThumbnailProvider"

EXPORTS
    DllCanUnloadNow PRIVATE
    DllGetClassObject PRIVATE

このサンプルはShell Extensionの最小構成を示すものです。製品では、ファイル形式のバージョン確認、PNGシグネチャの検証、ログ、コード署名なども追加します。

拡張子とThumbnail Providerの登録

.myAppDataMyApp.DocumentというProgIDへ関連付けます。

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\Software\Classes\.myAppData]
@="MyApp.Document"

[HKEY_LOCAL_MACHINE\Software\Classes\MyApp.Document]
@="myApp Document"

[HKEY_LOCAL_MACHINE\Software\Classes\MyApp.Document\DefaultIcon]
@="C:\\Program Files\\myApp\\myApp.exe,0"

Thumbnail ProviderのShell ExtensionカテゴリGUIDは、次の値です。

{E357FCCD-A995-4576-B01F-234630154E96}

独自Thumbnail ProviderのCLSIDを、ここでは次の値とします。

{12345678-1234-1234-1234-1234567890AB}

Thumbnail Providerを登録します。

[HKEY_LOCAL_MACHINE\Software\Classes\MyApp.Document\ShellEx\{E357FCCD-A995-4576-B01F-234630154E96}]
@="{12345678-1234-1234-1234-1234567890AB}"

[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{12345678-1234-1234-1234-1234567890AB}]
@="myApp Thumbnail Provider"

[HKEY_LOCAL_MACHINE\Software\Classes\CLSID\{12345678-1234-1234-1234-1234567890AB}\InprocServer32]
@="C:\\Program Files\\myApp\\MyAppThumbnailProvider.dll"
"ThreadingModel"="Apartment"

HKEY_CLASSES_ROOTへ直接書き込む例もよく見かけますが、インストーラーでは実体となる次の場所へ登録した方が分かりやすいです。

HKEY_LOCAL_MACHINE\Software\Classes

ユーザー単位でインストールする場合は、次の場所を使います。

HKEY_CURRENT_USER\Software\Classes

実際には、プロジェクト専用のCLSIDを新しく生成して使います。

インストーラーを作るときの考慮点

Thumbnail Providerは、普通のアプリケーションDLLとは少し扱いが違います。

64-bit DLLをインストールする

64-bit版Windowsのエクスプローラーで使用するThumbnail Providerは、64-bit DLLとしてビルドします。

myApp.exeが32-bitでも、64-bit版エクスプローラーに読み込ませるThumbnail Providerは64-bitでなければなりません。

32-bitアプリケーションのファイルダイアログなどでもサムネイルを使いたい場合は、32-bit版と64-bit版の両方が必要になることがあります。

ただし、最初から両方を用意すると管理が複雑になります。対象環境が通常の64-bit Windowsだけなら、まずは64-bit版だけで十分です。

DLLと依存ライブラリを同じ場所へ配置する

たとえば、次のように配置します。

C:\Program Files\myApp\
├─ myApp.exe
├─ MyAppThumbnailProvider.dll
└─ MyAppDataPreviewReader.dll

Thumbnail Providerが別のDLLへ依存している場合、そのDLLもエクスプローラーから読み込める場所に必要です。

Visual C++ランタイムへ動的リンクしている場合は、対応するVisual C++ Redistributableもインストールします。

Shell Extensionだけは、C/C++ランタイムを静的リンクして依存関係を減らす方法もあります。ただし、製品全体の更新方針やセキュリティ対応を考えたうえで選ぶ必要があります。

COM登録とファイル関連付けを分けて考える

インストーラーでは、少なくとも次の3種類を登録します。

.myAppData
    → MyApp.Documentへの関連付け

MyApp.Document
    → アイコンや表示名

MyApp.Document\ShellEx
    → Thumbnail ProviderのCLSID

さらに、CLSIDからThumbnail Provider DLLのパスを登録します。

CLSID
    → InprocServer32
    → MyAppThumbnailProvider.dll

どれか1つでも欠けると、サムネイルは表示されません。

regsvr32へ頼りすぎない

Shell Extension DLLにDllRegisterServerDllUnregisterServerを実装し、regsvr32で登録する方法もあります。

ただし、MSIやInstallShieldなどでインストーラーを作る場合は、インストーラー側でレジストリ項目を明示的に管理する方が扱いやすいです。

自己登録を使うと、次の問題が起きやすくなります。

  • インストーラーが変更内容を把握しにくい
  • ロールバックが不安定になる
  • 32-bit版と64-bit版を間違えやすい
  • アンインストール時の削除漏れを追いにくい
  • サイレントインストールで問題を切り分けにくい

特別な理由がなければ、自己登録ではなく、インストーラーのレジストリテーブルやレジストリ設定を使う方がよいです。

アンインストール時は登録を先に削除する

アンインストール時は、DLLを削除する前にレジストリ登録を削除します。

順番としては次の形です。

拡張子とShell Extensionの関連付けを削除
COMクラスの登録を削除
Thumbnail Provider DLLを削除

エクスプローラーがDLLをすでに読み込んでいると、その場ではファイルを削除できないことがあります。

その場合は、インストーラーから再起動後の削除を予約します。無理にエクスプローラーを強制終了するより、再起動を要求する方が安全な場合があります。

アップデート時にDLLを上書きできない場合がある

Thumbnail Provider DLLは、エクスプローラーに読み込まれたままになる可能性があります。

そのため、アップデート時に単純な上書きが失敗することがあります。

MSIなどを使う場合は、使用中ファイルとして扱い、必要なら再起動後に置き換えます。

別バージョンのDLLを別名で配置し、レジストリのInprocServer32を新しいDLLへ切り替える方法もあります。

MyAppThumbnailProvider-1.dll
MyAppThumbnailProvider-2.dll

ただし、古いDLLの削除管理が必要になるため、小規模な製品では再起動後の置き換えで十分です。

ファイル関連付けを勝手に上書きしない

.myAppDataが自社専用の拡張子なら、通常は問題ありません。

一方、複数のアプリケーションが扱う可能性のある拡張子では、既存の関連付けを無条件に上書きしないようにします。

Windowsでは、既定のアプリケーション選択をユーザーが管理する仕組みがあるため、インストーラーから強引に既定アプリを変更する設計は避けた方がよいです。

サムネイル表示と、ダブルクリック時に開くアプリケーションの設定は、分けて考える必要があります。

アンインストール時にユーザーデータを消さない

アプリケーションをアンインストールしても、.myAppDataファイル自体は削除しません。

また、別バージョンのmyAppが残っている場合は、ProgIDやThumbnail Providerの登録を単純に削除すると、そのバージョンのサムネイル表示まで壊す可能性があります。

複数バージョンのサイドバイサイドインストールを許可する場合は、次を決めておく必要があります。

  • Thumbnail Providerをバージョン共通にするか
  • バージョンごとにCLSIDを変えるか
  • 最後にインストールしたバージョンを使うか
  • アンインストール時に残存バージョンへ戻すか

通常は、ファイル形式の後方互換性を持つ最新版のThumbnail Providerを共通利用する方が管理しやすいです。

コード署名を行う

Shell Extensionはエクスプローラーから読み込まれるDLLです。

製品として配布する場合は、インストーラーだけでなく、MyAppThumbnailProvider.dllMyAppDataPreviewReader.dllにもコード署名を付けておいた方がよいです。

署名がなくても動作はしますが、セキュリティ製品や企業環境で警告やブロックの対象になりやすくなります。

サムネイルキャッシュはアンインストールで消さなくてよい

Windowsが生成したサムネイルキャッシュは、通常はインストーラーから削除しません。

アンインストール後に古いサムネイルが一時的に残る可能性はありますが、Windows側のキャッシュ管理に任せるのが基本です。

製品のアンインストール処理から、ユーザー全体のサムネイルキャッシュを消すと、ほかのアプリケーションのキャッシュまで削除してしまいます。

最終的な構成

全体としては、次のような構成になります。

myApp.exe
    ↓ 保存時
512×512のPreview PNGを生成
sample.myAppDataへ埋め込む

Windowsエクスプローラー側は次の構成です。

Windows Explorer
MyAppThumbnailProvider.dll
MyAppDataPreviewReader.dll
Preview PNGを読み出す
WICで縮小・変換する
HBITMAPを返す

インストーラーでは、次の項目を設定します。

myApp.exeと関連DLLを配置
.myAppDataをMyApp.Documentへ関連付け
Thumbnail ProviderのCLSIDを登録
ShellExへCLSIDを関連付け
必要なVisual C++ランタイムを配置
アンインストール時に登録を先に削除

一番重要なのは、Thumbnail Providerから3Dモデル全体を読み込まないことです。

独自形式を設計できるなら、保存時にプレビュー画像を埋め込み、Windows側ではその画像だけを取り出す構成が、一番シンプルで安定します。