WPFでzipファイル内の画像を連続表示するビューアの雛形

コンピュータ

画像ファイルをアーカイブしたzipファイルをドラッグアンドドロップし、
リストボックス内でzipファイルの順番の変更・削除を行うことで表示順番を入れ替えが出来ます。

複数のzipファイルを任意の順番で、画像ファイルを連続表示するプログラムになっています。

ソースコード

ファイル名:ImgView04.csproj

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

  <PropertyGroup>
    <OutputType>WinExe</OutputType>
    <TargetFramework>net8.0-windows</TargetFramework>
    <Nullable>enable</Nullable>
    <ImplicitUsings>enable</ImplicitUsings>
    <UseWPF>true</UseWPF>
  </PropertyGroup>

</Project>

ファイル名:MainWindow.xaml.cs

using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Text;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Data;
using System.Windows.Documents;
using System.Windows.Input;
using System.Windows.Media;
using System.Windows.Media.Imaging;
using System.Windows.Navigation;
using System.Windows.Shapes;


using NxLib.Helper;

namespace ImgView04;

public partial class MainWindow : Window
{
    // 表示用リスト
    ObservableCollection<string> _list = [];

    class ItemData
    {
        public string Path { get; set; } = "";
        public string Entry { get; set; } = "";
    }
    List<ItemData> _items = [];
    int _index = -1;


    // コンストラクタ
    public MainWindow()
    {
        InitializeComponent();

        // ListBox バインド
        listbox1.ItemsSource = _list;
        // D&D
        Wiring.AcceptFiles(this, async files =>
        {
            System.Diagnostics.Debug.Print(files[0]);

            await ZipImageCache.PreloadAllImagesAsync(files[0]);

            _list.Add(files[0]);
        }, ".zip", ".cbz");

        // Esc
        Wiring.Hotkey(this, Key.Escape, ModifierKeys.None,
            () =>
            {
                stackPanel1.Visibility = Visibility.Visible;
            });
        // F11 Fullscreen
        Wiring.Hotkey(this, Key.F11, ModifierKeys.None,
            () =>
            {
                Win.ToggleFullscreen(this);
            });
        // 画像をクリックで次の画像
        Wiring.OnLeftClick(image1, _ =>
        {
            if (_items.Count < 0) return;

            _index++;
            if (_index >= _items.Count)
            {
                stackPanel1.Visibility = Visibility.Visible;
                _index = -1;
                Win.ToggleFullscreen(this);
                return;
            }

            image1.Source = ZipImageLoader.LoadImageFromEntry(_items[_index].Path, _items[_index].Entry);
        });
    }

    // 削除ボタン
    private void Button_Delete(object? sender, RoutedEventArgs e)
    {
        // 選択中のインデックスを取得
        var index = listbox1.SelectedIndex;
        if (index == -1) return; // 未選択

        // コレクションからインデクス指定で要素を削除
        _list.RemoveAt(index);
    }
    // 上ボタン
    private void Button_Up(object? sender, RoutedEventArgs e)
    {
        var index = listbox1.SelectedIndex;
        if (index < 1) return; // 移動不可

        // 要素の移動
        _list.Move(index, index-1);        
    }
    // 下ボタン
    private void Button_Down(object? sender, RoutedEventArgs e)
    {
        var index = listbox1.SelectedIndex;
        if (index < 0) return; // 未選択
        if (index >= (_list.Count-1)) return; // 移動不可

        // 要素の移動
        _list.Move(index, index+1);
    }

    // 開始ボタン
    private void Button_Start(object? sender, RoutedEventArgs e)
    {
        stackPanel1.Visibility = Visibility.Collapsed;

        _items.Clear();

        foreach(var x in _list)
        {
            Debug.Print(x);
            var xx = ZipImageLoader.GetImageEntries(x);
            foreach (var y in xx)
            {
                _items.Add(new ItemData() { Path = x, Entry = y });
            }
        }

        if (_items.Count < 0) return;
        _index = 0;

        image1.Source = ZipImageLoader.LoadImageFromEntry(_items[_index].Path, _items[_index].Entry);

        Win.ToggleFullscreen(this);
    }

    // リストボックスの選択
    private void Listbox1_SelectionChanged(object? sender, RoutedEventArgs e)
    {
        var index = listbox1.SelectedIndex;
        if (index == -1) return; // 未選択

        var path = _list[index];

        //Debug.Print(path);
        image1.Source = ZipImageLoader.LoadFirstImageFromZip(path);
        //image1.Source = ZipImageCache.LoadFirstImage(path);
    }
}

ファイル名:ZipImageLoader.cs

using System.IO;
using System.IO.Compression;
using System.Windows.Media.Imaging;

namespace ImgView04;
public static class ZipImageLoader
{
    /// <summary>
    /// ZIPファイルから最初の画像を読み込む
    /// </summary>
    public static BitmapSource? LoadFirstImageFromZip(string zipPath)
    {
        // ZIP内の画像一覧を取得
        var entries = GetImageEntries(zipPath);
        if (entries.Count == 0)
            return null;

        // 最初の画像エントリを読み込み
        return LoadImageFromEntry(zipPath, entries[0]);
    }

    /// <summary>
    /// ZIP内の画像ファイルエントリ一覧を取得する
    /// </summary>
    public static List<string> GetImageEntries(string zipPath)
    {
        using var zip = ZipFile.OpenRead(zipPath);

        return zip.Entries
            .Where(e => !string.IsNullOrEmpty(e.Name))
            .Where(IsImageEntry)
            .OrderBy(e => e.FullName, StringComparer.OrdinalIgnoreCase)
            .Select(e => e.FullName)
            .ToList();
    }

    /// <summary>
    /// ZIPファイル内の特定の画像エントリをBitmapSourceとして読み込む
    /// </summary>
    public static BitmapSource? LoadImageFromEntry(string zipPath, string entryName)
    {
        using var zip = ZipFile.OpenRead(zipPath);
        var entry = zip.GetEntry(entryName);
        if (entry == null)
            return null;

        using var entryStream = entry.Open();
        using var mem = new MemoryStream();
        entryStream.CopyTo(mem);
        mem.Position = 0;

        var bmp = new BitmapImage();
        bmp.BeginInit();
        bmp.CacheOption = BitmapCacheOption.OnLoad;
        bmp.StreamSource = mem;
        bmp.EndInit();
        bmp.Freeze();
        return bmp;
    }

    /// <summary>
    /// 指定したZipArchiveEntryが画像かどうか判定する
    /// </summary>
    private static bool IsImageEntry(ZipArchiveEntry e)
    {
        string ext = Path.GetExtension(e.Name).ToLowerInvariant();
        return ext is ".png" or ".jpg" or ".jpeg" or ".bmp" or ".gif" or ".webp";
    }

}

ファイル名:Helpers\Win.cs

// Window関連
using System.Windows;

namespace NxLib.Helper;

public static class Win
{
    // ウィンドウをフルスクリーン化/元に戻す
    public static void ToggleFullscreen(Window w)
    {
        if (w.WindowState == WindowState.Normal)
        {
            w.WindowStyle = WindowStyle.None;
            w.WindowState = WindowState.Maximized;
        }
        else
        {
            w.WindowStyle = WindowStyle.SingleBorderWindow;
            w.WindowState = WindowState.Normal;
        }
    }
}

ファイル名:Helpers\Wiring.cs

using System.Windows;
using System.Windows.Input;
using System.Windows.Media;

namespace NxLib.Helper;
public static class Wiring
{
    // D&D: 指定拡張子だけ受け付ける(exts 省略可)
    public static void AcceptFiles(FrameworkElement el, Action<string[]> onFiles, params string[] exts)
    {
        el.AllowDrop = true;
        el.Drop += (_, e) =>
        {
            if (!e.Data.GetDataPresent(DataFormats.FileDrop)) return;

            var files = (string[])e.Data.GetData(DataFormats.FileDrop)!;

            if (exts is { Length: > 0 })
                files = files
                    .Where(f => exts.Any(x => f.EndsWith(x, StringComparison.OrdinalIgnoreCase)))
                    .ToArray();

            if (files.Length > 0)
                onFiles(files);
        };
    }
    // ホットキー登録
    public static void Hotkey(Window w, Key key, ModifierKeys mods, Action action, Func<bool>? canExecute = null)
    {
        var cmd = new RoutedUICommand();
        ExecutedRoutedEventHandler exec = (_, __) => action();
        CanExecuteRoutedEventHandler can = (_, e) => e.CanExecute = canExecute?.Invoke() ?? true;

        var cb = new CommandBinding(cmd, exec, can);
        var kb = new KeyBinding(cmd, key, mods);

        w.CommandBindings.Add(cb);
        w.InputBindings.Add(kb);
    }
    
    // 左クリックで発火
    public static T OnLeftClick<T>(this T el, Action<Point> onClick, bool consume = true)
    where T : FrameworkElement
    {
        el.MouseLeftButtonUp += (_, e) =>
        {
            onClick(e.GetPosition(el));
            if (consume) e.Handled = true;
        };
        return el;
    }
}

ファイル名:MainWindow.xaml

<Window x:Class="ImgView04.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:ImgView04"
        mc:Ignorable="d"
        Title="MainWindow" Height="450" Width="800">
    <DockPanel
        x:Name="dockPanel1" >
        <StackPanel
            x:Name="stackPanel1"
            Width="400"
            DockPanel.Dock="Left"
            Margin="10" >
            <ListBox
                x:Name="listbox1"
                SelectionChanged="Listbox1_SelectionChanged" />
            <WrapPanel
                Orientation="Horizontal"
                Margin="10">
                <Button Content="開始" Click="Button_Start" />
                <Button Content="削除" Click="Button_Delete"/>
                <Button Content="上へ" Click="Button_Up" />
                <Button Content="下へ" Click="Button_Down" />
            </WrapPanel>
        </StackPanel>
        <Image
            x:Name="image1"
            Stretch="UniForm"/> 
    </DockPanel>
</Window>

クラス構造

  • MainWindow.xaml … View(UIの見た目)
  • MainWindow.xaml.cs … コードビハインド(アプリケーションロジック)
    • ItemData … 内部クラス、表示画像の順番管理用
  • ZipImageLoader.cs … ZIPファイル関連処理(再利用可)
  • Helpers\Win.cs … ウィンドウ操作関連(再利用可)
  • Helpers\Wiring.cs … イベント処理関連(再利用可)

処理フロー

  1. ユーザーがZIPファイルをドロップ
  2. ZipImageLoader.LoadFirstImageFromZip() を呼び出し
  3. MemoryStreamBitmapSource に変換
  4. Image.Source に表示
  5. クリックで次の画像を読み込み

実行操作

・起動する

・ZIPファイルをD&Dするとリストボックスに追加される。

・開始ボタンを押すと画像の連続表示開始。フルスクリーン切り替え
・画像クリックで次の画像へ
・最後の画像まで到達すると通常ウィンドウに戻る。

感想

応答性は難がありますが、UIの機能は一通り実装出来ました。

見開き表示対応や応答性の向上としてzipファイルから画像ファイルの読み込みをプリフェッチ(先読み)する機能が欲しいところです。

そのうち対応したいと考えています。

コメント