【WPF】読み込めないJpegファイルを読み込む
WPF(.net 4.6.2)で画像ビューワーを作っていて、開こうとすると ArgumentException をスローして開けない(読み込めない)Jpegファイルに遭遇しました。
件のJpegファイルを他のアプリで開くと問題なく表示できたので、ファイルが壊れているのではなく、どうもWPF側の問題、仕様のようです。
ただ、究極的な話をすると自前でJpegファイルを読み込んで、デコードしてやれば表示できないということはないはずなので、ファイルが壊れていない限り表示は出来るはずです。
前置き
Jpegファイルはセグメントと呼ばれる複数の情報ブロックで構成されています。
その中で Applicationセグメントという種類のブロックがあるんですが、このセグメントは互換性を維持するための情報や、Jpegファイルを保存したアプリケーションやカメラの情報を保持しています。
いってしまえばこの Applicationセグメントは画像データを補完するメタデータです。
Jpegファイルは壊れていないものとして、画像に直接関係しているセグメントを除外していくと、この Applicationセグメントがあやしいです。
Applicationセグメントは画像データを補完すると書きましたが、必須ではないので、読み込めない Jpegファイルから Applicationセグメントブロックを全て省いたデータを用意できれば目標が達成できそうです。
ちなみに Applicationセグメントは複数種類(APP0 ~ APP15)存在します。
一時期、撮影場所がバレると SNS などで話題になった Exif もこの Applicationセグメント(APP1)です。
仕様
実装
プロジェクト名は「ImageViewer」として、
<!-- MainWindow.xaml --> <!-- xmlnsとかは省略 --> <Window> <Grid AllowDrop="True" DragOver="Grid_DragOver" Drop="Grid_Drop"> <ScrollViewer VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Auto"> <Image x:Name="ImageView"/> </ScrollViewer> </Grid> </Window>
// MainWindow.xaml.cs // usingは省略 namespace ImageViewer { public partial class MainWindow : Window { // コンストラクタ public MainWindow() { InitializeComponent(); } // ドラッグオーバーリスナー private void Grid_DragOver(object sender, DragEventArgs e) { if(e.Data.GetDataPresent(DataFormats.FileDrop)) { e.Effects = DragDropEffects.Copy; } else { e.Effects = DragDropEffects.None; } } // ドロップリスナー private void Grid_Drop(object sender, DragEventArgs e) { if(e.Data.GetData(DataFormats.FileDrop) is string[] files) { BitmapImage bmp = CreateBitmap(files[0]); // 読み込んだビットマップをイメージコントロールへ設定 if(bmp != null) { ImageView.Source = bmp; ImageView.Width = bmp.PixelWidth; ImageView.Height = bmp.PixelHeight; } } } // ビットマップイメージ作成 private BitmapImage CreateBitmap(string path) { BitmapImage result = null; // 処理を実装 return result; } // Jpegファイルの読込みと Applicationセグメントの除外 private byte[] LoadSubAppsegmentJpegFile(string path) { byte[] result = null; // 処理を実装 return result; } } }
ファイルをドロップしたら表示するだけのシンプルなアプリです。
CreateBitmapを実装
// ビットマップイメージ作成 private BitmapImage CreateBitmap(string path) { BitmapImage result = null; try { byte[] buff = LoadSubAppsegmentJpegFile(path); if(buff == null) { buff = File.ReadAllBytes(path); } using (var Stream = new MemoryStream(buff)) { result = new BitmapImage(); result.BeginInit(); result.StreamSource = Stream; result.CreateOptions = BitmapCreateOptions.None; result.CacheOption = BitmapCacheOption.OnLoad; result.EndInit(); if (result.CanFreeze) { result.Freeze(); } } } catch { throw; } return result; }
LoadSubAppsegmentJpegFileメソッド からトリミングされたバッファを受け取り、それを元に MemoryStream を作成し BitmapImage へ渡しています。
LoadSubAppsegmentJpegFileメソッド が null を返す場合は、渡したファイルパスが Jpegファイルではない場合なので File.ReadAllBytesメソッド で全てを読み込み BitmapImage へ渡します。
この段階で元々読み込めていたJpegファイルを含む画像ファイルをドロップしてやると表示することができます。
一点注意事項として、直接 MemoryStream を BitmapImage へ渡すのはメモリコストの面で非効率です。
大きなサイズのファイルを大量に読み込む場合などはクリティカルな問題ですが、今回はファイル一つなので直接渡す実装にしています。
LoadSubAppsegmentJpegFileを実装
// Jpegファイルの読込みと Applicationセグメントの除外 private byte[] LoadSubAppsegmentJpegFile(string path) { if (string.IsNullOrEmpty(path)) { throw new ArgumentNullException("path is Null or Empty"); } if (!File.Exists(path)) { throw new FileNotFoundException(path + " Not Found"); } long resultSize = 0; byte[] result = null; // Key : Applicationセグメントのポジション // Value : Applicationセグメントのサイズ Dictionary<long, long> segmentList = null; try { using (Stream stream = File.OpenRead(path)) { // Appセグメントの位置とサイズを取得 segmentList = MakeAppsegmentDictionary(stream); if (segmentList != null) { // Appセグメントを除くサイズを計測 resultSize = stream.Length; foreach (var segment in segmentList) { resultSize -= segment.Value; } result = new byte[resultSize]; stream.Seek(0, SeekOrigin.Begin); long current = 0; int dat = 0; while (true) { // Appセグメント位置に来たらサイズ分カーソルを進める if (segmentList.ContainsKey(stream.Position)) { stream.Seek(segmentList[stream.Position], SeekOrigin.Current); } else { // 1バイトづつ読み込む dat = stream.ReadByte(); // 終端に到達したら処理を終了 if (dat == -1) { break; } result[current] = (byte)dat; current++; } } } } } catch { throw; } return result; } // ストリームを走査して全てのApplicationセグメントの位置とサイズを返す private Dictionary<long, long> MakeAppsegmentDictionary(Stream stream) { long current = stream.Position; stream.Seek(0, SeekOrigin.Begin); const int MarkerExt = 0xFF; const int MarkerNon = 0x00; const int MarkerRestart0 = 0xD0; const int MarkerRestart7 = 0xD7; const int SegmentStartOfImage = 0xFFD8; const int SegmentEndOfImage = 0xFFD9; const int SegmentStartOfScan = 0xFFDA; const int SegmentApp0 = 0xFFE0; const int SegmentApp15 = 0xFFEF; int mark, size; bool isImageData = false; mark = Read2ByteData(stream); Dictionary<long, long> result = null; // ストリームの先頭2バイトがJpeg識別子である if (mark == SegmentStartOfImage) { result = new Dictionary<long, long>(); while (true) { if (isImageData) { int h = 0; int l = 0; while (true) { h = stream.ReadByte(); if (h == -1) { throw new EndOfStreamException("ファイルが壊れている可能性があります。"); } else if (h == MarkerExt) { l = stream.ReadByte(); if (l == -1) { throw new EndOfStreamException("ファイルが壊れている可能性があります。"); } else if (l != MarkerNon && !(l >= MarkerRestart0 && l <= MarkerRestart7)) { break; } else if (l == MarkerExt) { stream.Seek(-1, SeekOrigin.Current); } } } stream.Seek(-2, SeekOrigin.Current); isImageData = false; } else { mark = Read2ByteData(stream); if (mark == SegmentEndOfImage) { break; } size = Read2ByteData(stream); if (mark >= SegmentApp0 && mark <= SegmentApp15) { result.Add(stream.Position - 4, size + 2); } else if (mark == SegmentStartOfScan) { isImageData = true; } stream.Seek(size - 2, SeekOrigin.Current); } } } stream.Seek(current, SeekOrigin.Begin); return result; } // ストリームから2バイト読み込む private int Read2ByteData(Stream stream) { int h = stream.ReadByte(); int l = stream.ReadByte(); if(h == -1 || l == -1) { throw new EndOfStreamException("ファイルが壊れている可能性があります。"); } return (h << 8 | l); }
今回のキモとなるメソッドです。
LoadSubAppsegmentJpegFileメソッドの目的は Applicationセグメントの除外ですので、ファイルストリームを走査して Applicationセグメントの位置とサイズを知る必要があります。
そこで、以下の二つのメソッドを追加します。
- MakeAppsegmentDictionaryメソッド
- Read2ByteDataメソッド
- このメソッドはセグメントマーカー、セグメントサイズを取得するためのユーティリティメソッドです。
- 渡されたストリームのカーソル位置から2バイト読込み結合して返します。
LoadSubAppsegmentJpegFileメソッドではMakeAppsegmentDictionaryメソッドから返ってきた辞書を元に最終的なバッファーのサイズを計算して、辞書と照らし合わせながらストリームのデータをコピーしていく作業を行います。
以上のコードで目標は達成できました。