技術記事

Delphi の MRC 圧縮: スキャン PDF を JPEG + CCITT へ

losLab PDF Library for Delphi は、Mixed Raster Content によるスキャンページの圧縮を TPDFlib.CompressPageAsMRC で提供します。このメソッドはページをレンダリングし、フル解像度の 1 ビット前景マスクを 2 値化し、残りを JPEG 背景へダウンサンプルして、ページを 2 枚の画像で組み立て直します。/DCTDecode の背景と、現在のフィル色で塗られる /CCITTFaxDecode のステンシルマスクです

この機能が存在する理由は、文書アーカイブに必ず山ほどあるあのページです。請求書や契約書の 300 DPI カラースキャンが、1 枚の巨大な JPEG として格納されているやつです。文字が読める品質でエンコードすればファイルは大きくなるし、品質を下げれば文字にはリンギングのハロが伸びてセリフはにじみます。JPEG がいちばん苦手なのは白い紙の上のシャープな黒いエッジだからです。Mixed Raster Content は、1 つのコーデックに 2 つの仕事を頼むのをやめます

単一 JPEG はスキャン文字をなぜぼやけさせ、しかも大きくするのか

単一の JPEG がスキャン文字を苦手とするのは、DCT コーデックがビットを滑らかなグラデーションに注ぎ込み、硬いエッジを量子化が真っ先に捨てる高周波係数で近似するからです。スキャン上の文字は、ほぼ例外なく硬いエッジの塊です。そのエッジをきれいに保とうとすれば、ページ全体に高品質設定を強いることになり、紙の質感やロゴ、コーヒーの染みまで高品質を与えることになります。どれも必要としていなかったのに

MRC の発想は、各領域が実際に何であるかでページを分割することです。インクは 2 値画像になり、CCITT のようなファクスコーデックが黒と白のピクセルのランを可逆かつ極めてコンパクトに記録します。それ以外のすべて、紙の色調、写真、色付きの背景は、数分の一の解像度で格納できる連続階調画像になります。背景を 300 DPI で読む人はいないからです。2 値レイヤーがフル解像度で文字のシャープさを保ち、背景レイヤーが低コストで色を保ちます

Delphi の PDFlibPas CompressPageAsMRC が作るレイヤー分割と単一 JPEG スキャンの比較。DCT コーデックは硬い文字エッジをにじませページ全体に高品質を強いる一方、インクは 300 DPI の可逆 1 ビット CCITT ステンシルになり、紙の色調は 75 DPI の DeviceRGB JPEG 背景になる
Mixed Raster Content は 1 つのコーデックに 2 つの仕事を頼みません。2 値レイヤーがフル解像度で文字をシャープに保ち、背景が数分の一のコストで色を保つため、品質 50 から 70 は通常見た目に分かりません

CompressPageAsMRC はページをどう 2 層に分割するのか

TPDFlib.CompressPageAsMRC はページごとに 1 本の固定パイプラインを実行します。要求された DPI でページを 24 ビットビットマップにレンダリングし、Rec. 601 の重み付き輝度(1000 分率で 299/587/114)で各ピクセルを Threshold と突き合わせて分類し、結果を同じ解像度の 1 ビットマスクに書き込みます。並行して、同じレンダリング結果を BGScale でブロック平均により縮小します。BGScale が 4 なら、4×4 ピクセルの各ブロックが 1 つの背景ピクセルになります

Delphi 向け PDFlibPas における CompressPageAsMRC のデータフロー。1 回の 300 DPI 24 ビットレンダリングが Rec. 601 輝度で 2 値化されフル解像度の CCITTFaxDecode K 1 ImageMask ステンシルになり、BGScale 4 のブロック平均で DCTDecode DeviceRGB 背景になり、マスクを上にして両画像が元のページ演算子を置き換える
両レイヤーとも 1 回のレンダリングから派生します。DPI がしきい値処理を生き残る線の細かさを決め、BGScale は背景の細部を犠牲にサイズを稼ぎ(写真なら 2、平文テキストなら 8)、再構築されたページは現在のフィル色でステンシルを描きます

続いてページコンテンツが置き換えられます。元の演算子は取り除かれ、ダウンサンプル済みの背景は /DeviceRGB の /DCTDecode 画像として埋め込まれ(ISO 32000-1 §7.4.8)、マスクはライブラリの CCITT エンコーダで /CCITTFaxDecode 画像(§7.4.6)としてエンコードされ /ImageMask true が付き、両者がマスクを上にしてページボックス全体に描かれます。シグネチャは短いものです

function CompressPageAsMRC(Page: Integer; DPI: Double;
  BGScale, JPEGQuality, Threshold, Options: Integer): Integer;
  • Page は 1 起点で、PageCount の範囲内でなければなりません
  • DPI はソースラスタのレンダリング解像度で、50 から 1200 です
  • BGScale は背景のダウンサンプル係数で、1 から 8 です
  • JPEGQuality は背景 JPEG の品質で、1 から 100 です
  • Threshold は輝度の切り分けで、1 から 255 です。この値以上のピクセルは紙、暗いピクセルはインクです
  • Options は予約済みで、0 を指定します

スキャンファイルへの典型的なバッチ処理は次のようになります。各ページの直前で SetFillColor を呼ぶのは、次のセクションで説明する理由からです

uses
  SysUtils, PDFlibrary;

procedure CompressScannedFile(const SrcFile, DstFile: string);
var
  Lib: TPDFlib;
  P, Replaced: Integer;
begin
  Lib := TPDFlib.Create;
  try
    if Lib.LoadFromFile(SrcFile, '') <> 1 then
      raise Exception.CreateFmt('Cannot open %s', [SrcFile]);
    for P := 1 to Lib.PageCount do
    begin
      Lib.SetFillColor(0, 0, 0);  // ステンシルレイヤーのインク色
      // 300 DPI マスク、75 DPI 背景、JPEG 品質 60、輝度 160 で切り分け
      Replaced := Lib.CompressPageAsMRC(P, 300, 4, 60, 160, 0);
      if Replaced = 0 then
        raise Exception.CreateFmt('MRC failed on page %d', [P]);
    end;
    if Lib.SaveToFile(DstFile) <> 1 then
      raise Exception.CreateFmt('Cannot save %s', [DstFile]);
  finally
    Lib.Free;
  end;
end;

前景が現在のフィル色で塗られる理由

前景レイヤーはステンシルマスクであり、ステンシルマスクには固有の色がありません。ISO 32000-1 §8.9.6.2 は /ImageMask true の画像を 1 ビットの形状として定義します。サンプル値はペンキの塗られる場所を示し、ペンキはグラフィックス状態で現在の非ストローク色なら何でもよい、という具合です。レイヤーが安上がりなのはまさにこのためで、CompressPageAsMRC に色パラメータがないのも同じ理由です

TPDFlib.CompressPageAsMRC は SetFillColor で設定された色でマスクを塗るため、呼び出し順序が重要です。塗り手はフィル色の出力を遅延させます。SetFillColor は色を記録して変更済みマークを付け、ステンシル描画はその変更がまだ保留の間にだけ、自分の q/Q ブロックの中へ rg 演算子を書き込みます。間に塗りつぶし図形を描くと、保留中の変更はそちらの図形に吸い取られます。上のバッチのように各ページの直前で色を設定すれば、推測は不要になります。黒が普通の選択で、出力をコピー機らしく見せたくない青インクのフォームなら濃い紺も使えます

Delphi の PDFlibPas MRC ステンシルレイヤーにおけるフィル色遅延出力のシーケンス。SetFillColor が色を保留中の変更として記録し、ImageMask ステンシル描画は変更が保留の間に自身の q と Q ブロックの中へ rg 演算子を書き込み、割り込んだ塗りつぶし図形が代わりに保留中の変更を消費する
ステンシルマスクは色を持ちません。すべてのインクピクセルが 1 つのフィル色を取るため、赤い印、青い署名、黒い文字がひとまとまりになります。だからこそ SetFillColor は各ページの直前に呼ぶ必要があります
// 青いボールペンのフォーム:インクレイヤーを濃い紺で描く
Lib.SetFillColor(0.08, 0.12, 0.35);
Replaced := Lib.CompressPageAsMRC(PageNo, 300, 4, 55, 170, 0);

配置はライブラリの座標原点に従います。CompressPageAsMRC は GetOrigin を読み、両画像をページボックスの上端から描きます。原点が左上(SetOrigin(1))なら上座標は 0、既定の左下原点なら上座標は PageHeight です。調整は不要ですが、パイプラインがどちらの原点を使っていてもレイヤーが正しく収まる理由の説明にはなります

ステンシルのセマンティクスがもたらす帰結の 1 つは、はっきり言っておく価値があります。インクと分類されたピクセルはすべて同じ色になるのです。赤い印、青い署名、黒い本文テキストが、前景レイヤー上では 1 つのフィル色にまとまります。元の色のかすかな低解像度の痕跡を保つのは、下に敷かれた背景 JPEG だけです

DPI、BGScale、Threshold、JPEG 品質の選び方

DPI の値は文字のシャープさを決め、オフィススキャンなら 300 が理にかなった既定値です。マスクはレンダリング解像度のまま保たれるため、DPI がしきい値処理を生き残る線の細かさを決めます。元のスキャン解像度より上げても得るものはありません。レンダラが埋め込みスキャン画像をアップサンプルするだけになるからです。もう 1 つの制限はメモリです。US Letter のページは 300 DPI で 2550×3300 ピクセル、マスクと背景を導出する前の 24 ビットピクセルデータとして 25,245,000 バイトになり、1200 DPI の上限では 10200×13200 ピクセル、約 404 MB になります

BGScale は背景の細部とサイズを交換します。300 DPI では係数 4 で 75 DPI の背景、Letter なら 638×825 ピクセルになり、紙の色調とフラットな色面には十分すぎません。読者が実際に見る写真や網掛けイラストのあるページには 2 が見合います。白い紙の平文テキストなら 6 や 8 も取れます。背景は着色以上の何者でもないのですから

Threshold はページの見た目を変えるパラメータです。上げると中間グレーのピクセルがより多くインクと分類され、細い線や鉛筆書きは太くなりますが、スキャナノイズや紙の影も斑点として引き込みます。下げると紙はきれいになり、文字は細く、ときに途切れます。白地の黒文字なら 128 から 170 あたりが現実的な出発点です。このトレードオフはDelphi で PDF ページを 1 ビットモノクロにレンダリングする記事で論じたものと同じで、あちらも同じ輝度しきい値が RenderPageToMonochromeFileEx を駆動します。あのメソッドと違って CompressPageAsMRC にはデスペックル処理がないため、ノイズの多いスキャンにはやや低めのしきい値が向いています

JPEGQuality は単一画像のスキャンほど重要ではありません。背景がもはや文字エッジを一切担わないからです。品質 50 から 70 は、シャープなマスクの背後にある 75 DPI 背景では通常見分けがつきません

戻り値が語るもの、語らないもの

TPDFlib.CompressPageAsMRC は置き換えたページコンテンツのバイトサイズを返し、失敗時には 0 を返します。成功の合図であって削減量の目盛ではありません。典型的なスキャンページでは、元のコンテンツストリームは 1 枚の画像を配置するのに足る演算子だけの数十バイトなので、ページがメガ単位で縮んでも小さな正数が普通の結果です。元のコンテンツストリームが空だったページは、再構築されたにもかかわらず 0 を報告します

失敗時の振る舞いはアトミックではなく、設計で吸収すべきはそこです。引数チェック、レンダリング、JPEG エンコードはページに触れる前に走るため、範囲外の BGScale、101 の JPEGQuality、レンダリング失敗ではページは元のままです。しかし元のコンテンツがクリアされた後は、レイヤーの埋め込みや描画の失敗も 0 を返し、その時点でページは部分的に再構築済みの可能性があります。0 はすべて、保存ではなくメモリ上の文書を破棄する理由として扱い、結果をソースファイルに上書きしてはいけません

変換が実際にどれだけ削減したかを知るには、文書を測ります。SaveToString の長さを前後で比べるのが最速のチェックで、カテゴリ別の答えが欲しければ、AuditDocumentSpace による PDF ファイルサイズ監査で画像から重量が想定どおりに移動したかを確認できます

function MRCSavings(Lib: TPDFlib): Int64;
var
  Before: Int64;
  P: Integer;
begin
  Before := Length(Lib.SaveToString);
  for P := 1 to Lib.PageCount do
  begin
    Lib.SetFillColor(0, 0, 0);
    if Lib.CompressPageAsMRC(P, 300, 4, 60, 160, 0) = 0 then
      raise Exception.CreateFmt('Page %d failed; discard this document', [P]);
  end;
  Result := Before - Length(Lib.SaveToString);
end;

この 2 層 MRC が ITU-T T.44 に届かないところ

CompressPageAsMRC は意図的に簡略化した MRC を実装しています。ITU-T T.44 の完全なモデルはストライプごとに 3 層を使います。背景画像、インクの色を運ぶ前景画像、そしてピクセルごとに両者を選ぶ 2 値マスクで、1 ページに複数のマスクと前景のペアを置ける余地もあります。PDF の用語で言えば、3 層目は 1 色で塗るステンシルではなく、明示的マスク(§8.9.6.3)付きの低解像度カラー前景画像になります。PDF Library for Delphi は前景カラーレイヤーを省いており、結果がこれほど小さくなる理由であり、色付きインクがフィル色に潰れる理由でもあります

  • このメソッドが向くのは、スキャン紙面上の黒または単色のテキストです。色分けされた文書、ハイライトされたテキスト、色付きの印はインクの色を失います
  • 再構築されたページは純粋なラスタです。元のページ上の実テキスト、ベクターアート、選択可能な文字はピクセル化するため、実行するのはスキャンであって、デジタル生成ページではありません。後者にはフォントサブセットと画像ダウンサンプルが正しい道具です
  • マスクはライブラリの CCITT エンコーダを T.4 2 次元モードで使い、/K 1 付きの /CCITTFaxDecode として書き込まれます。エンコーダ自体は AddImageCCITTFromBitmapStream 経由で Group 4 モードも持っていますが、CompressPageAsMRC はそれを公開しておらず、JBIG2 も使いません
  • 注釈とフォームフィールドはフラット化されず、再構築されたコンテンツの上のオブジェクトとして残ります
  • 背景は /DeviceRGB のため、出力は PDF/X-1a ワークフローには向かず、SetFillColor 自体も PDF/X-1a モードでは拒否されます
  • すでに OCR が必要なスキャンは、元のピクセルがフルの色深度で存在するうちに、MRC の前に認識しておくべきです

FAQ

CompressPageAsMRC はテキストを検索可能なまま保つか

いいえ。CompressPageAsMRC はページコンテンツを 2 枚の画像で置き換えるため、元のページ上のテキスト演算子は、不可視の OCR テキストレイヤーを含めて一緒に取り除かれます。圧縮後のページに OCR を実行するか、圧縮の後にテキストレイヤーを足し直してください

文書の一部のページだけを圧縮できるか

はい。CompressPageAsMRC は 1 回の呼び出しで 1 つの 1 起点ページを処理するため、ページを選べますし、ページごとに BGScale と Threshold を変えることもできます。写真の挿し込みページは係数 2、平文テキストのページは 6、という具合です

Options パラメータには何を渡すべきか

0 を渡します。現行リリースで Options は将来のために予約されており、定義済みのビットはありません。0 以外の値には今日のところ意味がありません

CompressPageAsMRC は losLab PDF Library for Delphi v3.561.0 で登場し、それを組み立てているレンダリング、JPEG、CCITT、ステンシルマスクの部品も、パイプラインの変種が必要ならすべて公開メソッドとして使えます。ライブラリ本体、C++Builder サポート、完全な API リファレンスは、PDF Library for Delphi 製品ページにあります