技術記事

HotXLSでGIF、BMP、JPEGヘッダのジオメトリを読む

HotXLSは、描画ジオメトリが書かれる前に、画像ファイル自身のヘッダからピクチャの実際のピクセルジオメトリを読み出します。lxImage.pasのGetImageSizeはファイル拡張子でGetJpegSize、GetGifSize、GetBmpSize、GetPngSize、GetWmfEmfSizeへディスパッチし、0.75の係数でピクセルをポイントへ変換し、本当に判別できないときは-1を返します

その最後の節が、最も時間をかけて正しくした部分です。失敗できないサイズリーダは嘘をつくサイズリーダであり、画像ジオメトリについての嘘は挿入時には表面化しません。数週間後に、誰かがワークブックを開いて会社のロゴが潰れた矩形になっているときに表面化します

ピクチャはなぜ描画に入る前に実際のジオメトリを必要とするのか

スプレッドシート形式が格納するのはエクステントであって、画像ではないからです。AddPictureやImages.AddFromFileがピクチャを配置するとき、描画パートはシェイプに明示的な幅と高さを記録します。DrawingMLではそれはa:ext要素のcxとcy属性で、ECMA-376 Part 1 §20.4によりEnglish Metric Unitsです。そのレコードのどこにも「画像の自然なサイズを使え」とは書かれていません。だから自然なサイズはどこかから来なければならず、それが存在する唯一の場所は画像バイトです。3倍間違えれば、ワークブックを開くすべてのアプリケーションで、永遠に、ピクチャは3倍に引き伸ばされます。それらのピクセル寸法からEMUへの単位変換は別の関心事で、EMU単位とスケーリングの記事で扱われます。この記事はその前段、ピクセル数がそもそも得られる段階の話です。リーダが失敗したときのフォールバックは100×100で、その数値は意図的に、明らかに間違っています。もっともらしいサイズへの推測ではなく、「誰もこのファイルを読めなかった」を意味するマーカーであり、もっともらしく見える間違った数値よりはるかに優れています。ユーザーが即座に報告するからです

HotXLSのGetImageSizeがファイル拡張子でGetJpegSize、GetGifSize、GetBmpSize、GetPngSize、GetWmfEmfSizeの各リーダへディスパッチし、3つの結果を報告する図。ヘッダから真のジオメトリを読めたときは1、何も読めず100×100のフォールバックが適用されるときは-1、メタファイルがBSEレコードからエクステントを取るときは7です
GetImageSizeは3つの結果を区別します。ヘッダから読まれたジオメトリは1、明らかに間違った100×100のフォールバックがユーザーに報告される読めないファイルは-1、エクステントがBSEレコードにあるメタファイルは7です

GIF:決してパースされなかったディスクリプタ

GetGifSizeは新しいもので、それが存在する前は、ワークシートに挿入されるすべてのGIFがちょうど100×100で出ていました。GIF分岐がまったくなく、拡張子がGetImageSizeのデフォルト分岐に落ちて-1を返していたからです。これはセットの中で最も安くパースできるヘッダであり、その省略は少し気まずく、修正もとても簡単です。GIF89a仕様は論理画面ディスクリプタを6バイトのシグネチャの直後に置き、§18はその最初の2フィールドを論理画面の幅と高さとして定義します。それぞれリトルエンディアンで格納された符号なし16ビット値です。ファイルの言葉で言えば、幅はバイト6と7、高さはバイト8と9で、単一の10バイト読み取りがシグネチャ検査と両方の寸法をカバーします

function GetGifSize(const AFileName: WideString; var AWidth, AHeight: Double): Integer;
var
  f: TWFileStream;
  Header: array [0.. 9] of Byte;
  wWidth, wHeight: Word;
begin
  Result:= - 1;
  AWidth:= 100;
  AHeight:= 100;
  f:= TWFileStream.Create(AFileName, fmOpenRead);
  try
    // シグネチャ"GIF8"、続いて論理画面ディスクリプタ。幅と
    // 高さはオフセット6と8のリトルエンディアン語
    if (f.Read(Header[0], SizeOf(Header))= SizeOf(Header))and
      (Header[0]= Ord('G'))and (Header[1]= Ord('I'))and
      (Header[2]= Ord('F'))and (Header[3]= Ord('8')) then
    begin
      wWidth:= Header[6]or (Header[7] shl 8);
      wHeight:= Header[8]or (Header[9] shl 8);
      if (wWidth> 0)and (wHeight> 0) then
      begin
        AWidth:= wWidth* 0.75;
        AHeight:= wHeight* 0.75;
        Result:= 1;
      end;
    end;
  finally
    f.Free;
  end;
end;

その形に注目してください。失敗値が先に代入され、成功経路だけがそれらを上書きします。lxImage.pasのすべてのリーダがその順序に従います。「これを読めなかった」を、各早期終了で覚えておくべきものではなくデフォルトにするからです。GIF8だけを検査し、完全なGIF87aやGIF89a文字列を検査しないのは意図的です。ディスクリプタのレイアウトは両リビジョンで同一で、ジオメトリが読めるファイルを拒否する利点はありません

HotXLSが読むGIFヘッダのバイトマップ。バイト0〜5はGIF8シグネチャとバージョンを運び、バイト6と7はリトルエンディアンの論理画面幅、バイト8と9は高さを運ぶため、GetGifSizeの単一の10バイト読み取りが、0.75のポイント変換の前にシグネチャ検査と両方の寸法をカバーします
GIF論理画面ディスクリプタは、GetGifSizeが必要とするすべてを10バイトに詰め込みます。6バイトのシグネチャに続き、オフセット6と7にリトルエンディアンの幅、8と9に高さがあり、GIF87aとGIF89aは同一のレイアウトを共有します

BMPヘッダサイズ12は実際に何を変えるのか

GetBmpSizeはオフセット14の4バイトをDIBヘッダサイズとして読み、その値で分岐します。ヘッダサイズ12のBMPは、他のすべてのBMPと完全に異なるフィールド幅で寸法を格納するからです。[MS-WMF] §2.2.2はそのファミリーを並べます。BITMAPCOREHEADERは12バイトで16ビットの幅と高さを持ち、BITMAPINFOHEADER、BITMAPV4HEADER、BITMAPV5HEADERはすべて32ビット符号付きのbiWidthとbiHeightで始まります。ファイルが2バイトしかないところで4バイトを読んでも、大きく失敗はしません。プレーン数とビット深度を数値の上位半分として取り込み、数十万ピクセル幅のビットマップを報告します。同じヘッダの2つ目の罠はbiHeightの符号です。負の値は負の高さを意味せず、DIBの行がボトムアップではなくトップダウンで格納されていることを意味します。これはジオメトリとは無関係なピクセル順序の声明です

  f.Seek(14, soFromBeginning);
  if f.Read(HeaderSize, 4)= 4 then
  begin
    if HeaderSize= 12 then
    begin
      // BITMAPCOREHEADERは16ビットの寸法を格納する
      if (f.Read(wWidth, 2)= 2)and (f.Read(wHeight, 2)= 2) then
      begin
        lWidth:= wWidth;
        lHeight:= wHeight;
        Result:= 1;
      end;
    end
    else if (f.Read(lWidth, 4)= 4)and (f.Read(lHeight, 4)= 4) then
      Result:= 1;
  end;
  // ...
  // 負のbiHeightはトップダウンDIBを示すだけ。絶対値がサイズ
  if (Result= 1)and (lWidth> 0)and (lHeight<> 0) then
  begin
    AWidth:= lWidth* 0.75;
    AHeight:= Abs(lHeight)* 0.75;
  end
  else
    Result:= - 1;

最後のガードは注意深く読む価値があります。高さは> 0ではなく<> 0に対して検査されます。負が合法でゼロが非法だからです。Absは変換の時点でのみ適用されます。幅には同等のケースがありません。負のbiWidthは形式で定義されておらず、破損ファイルとして扱われ-1へ降格されます。代わりに生の負値を上に渡すと、描画レコードに負のエクステントが生まれます。それは、ワークブックを開くアプリケーションに応じて、クランプされるか、無視されるか、反転したシェイプとして描画されます

JPEG:実際にフレームヘッダなのはどのマーカーか

GetJpegSizeはマーカー集合[$C0..$C3, $C5..$C7, $C9..$CB, $CD..$CF]を受け入れ、その範囲の3つの隙間こそが要点のすべてです。ITU-T T.81 §B.2.2はフレームヘッダをSOFnと定義し、フレームヘッダこそ、行数と1行あたりのサンプル数が住む場所です。ありふれたフレーム型だけでなくすべてのフレーム型にです。同じ数値範囲に落ちるがフレームヘッダではないマーカーは、$C4(DHT、ハフマンテーブルの定義)、$C8(JPG、予約)、$CC(DAC、算術符号化コンディショニングの定義)です。ベースライン、拡張シーケンシャル、プログレッシブ、つまり$C0、$C1、$C2だけを認識するリーダは、カメラやスクリーンショットツールが生むほとんどすべてのJPEGで機能し、そしてドキュメントワークフローにとって重要なものでは静かに失敗します。ロスレスJPEG($C3)、算術符号化の亜種($C9から$CB)、階層型フレーム型($CDから$CF)はすべて、フレームヘッダをスキャンし過ぎてファイルの終端を走り抜け、フォールバックに着地します。医療画像とスキャン文書のパイプラインはまさにそれらのファイルを生みます

// SOFnマーカーはどれもフレーム寸法を運ぶ。$C4(DHT)、$C8(JPG)
// $CC(DAC)は範囲を共有するがフレームヘッダではない
if Seg in [$C0.. $C3, $C5.. $C7, $C9.. $CB, $CD.. $CF] then
begin
  ReadLen:= f.Read(Dummy[0], 3);
  ReadLen:= f.Read(wHeight, 2);
  wHeight:= swapByte(wHeight);
  ReadLen:= f.Read(wWidth, 2);
  wWidth:= swapByte(wWidth);
  found:= True;
  Break;
end

その7行には2つの詳細があります。スキップされる3バイトは、セグメント長ワードとサンプル精度バイトで、読み取りは行数に着地します。JPEGは幅より先に高さを格納します。これはほとんどの形式の逆で、容易に置き換えを起こします。そして値はswapByteを通ります。JPEGが全体を通してビッグエンディアンだからで、GetPngSizeが32ビットのIHDR値をlswapByteに通すのと同じ理由です。セグメントウォークには独自のガードがあります。マーカーがパラメータレスでないとき、リーダは2バイトの長さを取り、バイトスワップし、値が2未満ならシークを拒否します。自分のフィールドより短い長さは後方シークになり、スキャンループ内の後方シークは、他人が制御するファイルでの無限ループです

HotXLSがGetJpegSizeでフレームヘッダとして受け入れるJPEGマーカーの図。SOFn範囲はC0からCFで、DHTのC4、JPGのC8、DACのCCに隙間があり、フレームヘッダのレイアウトでは2バイト長と精度バイトがスキップされ、高さが幅より先に読まれ、両方の値がビッグエンディアン順にswapByteを通ります
SOFnマーカー範囲とその3つの隙間。DHT、JPG、DACは数値範囲を共有しますがフレーム寸法を持たず、完全な範囲を受け入れることで、ロスレスと階層型JPEGが自分のジオメトリを報告できます

マイナス1を返すことが、もっともらしい数値を返すより勝る

5つのリーダすべてに通じる共通点は、GetImageSizeが2つではなく3つの結果を区別することです。1はジオメトリがファイルから読まれたことを意味します。-1は読まれなかったことで、呼び出し側は自分のデフォルトを使うべきです。7はGetWmfEmfSizeが返し、まったく別のことを意味します。WMFやEMFが、ファイルヘッダの固定オフセットではなくワークブック自身のピクチャレコードにエクステントを運ぶため、AddPictureにメタファイルのBSEレコードからサイズを取るように伝えます。メタファイル処理全般は境界付きEMF・WMFデコーダの記事で扱われます。GetJpegSizeは、なぜその区別に推論ではなく明示的なフラグが必要かを示します。スキャンループは3つの方法で終了できます。フレームヘッダを見つけたか、ファイルの終端に達したか、破損したセグメント長で諦めたかです。そして最初の方法だけがwWidthとwHeightを代入します。foundブールがなければ、他の2つの経路はローカルがたまたま保持していたものを変換し、結果を成功として上に渡します。未初期化のローカルはしばしばゼロで、ゼロはここで最悪の答えです。下流のすべての妥当性検査を生き延び、誰も選択も削除もできない見えないピクチャを生みます

var
  W, H: Double;
begin
  case GetImageSize(FileName, W, H) of
    1: Sheet.AddPicture(FileName, Row, Col, W, H);
    7: Sheet.AddPicture(FileName, Row, Col);  // メタファイル:サイズはBSEレコードから
  else
    // WとHは100×100。プレースホルダを受け入れるのではなく方針を決める
    LogWarning('unreadable image geometry: ' + FileName);
  end;
end;

正直な限界はどこにあるか

これらのリーダは意図的にヘッダをパースして止まります。ピクセルをデコードしないため、格納された寸法とそれ以上は報告せず、格納された寸法がすべてではないケースもあります。フレームが論理画面より小さいアニメーションGIFは依然として論理画面を報告します。レイアウトには正しく、ビューアが示すものとは一致しないかもしれません。90度回転を指定するEXIFオリエンテーションタグを運ぶJPEGは、回転前のフレーム寸法を報告するため、電話で撮った縦向き写真が横向きで戻ることがあります。非正方形ピクセルアスペクト比のDIBは生のピクセル数を報告し、0.75係数は全体を通して96 DPIを仮定します。そのどれもヘッダ読み取りのバグではなく、画像をデコードせずには修正できません。このレイヤーの目的はそれではないのです。それらは文書化された境界であり、あなたのパイプラインが電話写真を扱うなら、オリエンテーションタグを自分で読んで、AddPictureを呼ぶ前に値を入れ替えてください。ピクチャ配置、アンカー、描画オブジェクトモデルについての残りはすべて、チャート、画像、図形描画の記事で扱われます

HotXLSは、Excelのインストールを必要とせずに、DelphiとC++BuilderでXLS、XLSX、ODS、CSVをネイティブに読み書きし、ピクチャ挿入はそのすべてでこのヘッダ経路を通ります。形式サポートとライセンスの詳細は、HotXLS Delphiスプレッドシートコンポーネントの製品ページにあります