スキャナが出力したPDFを、出力デバイスに合わせて変換しよう


 この記事はF# Advent Calender 2012の11日目(10個目)の記事です。ひとつ前の記事はyukitos22さんのTypeProvider関連の丁寧な記事で、これを書き終わったら手を動かして試してみたいところです。


 さて、主催者様の「実用的な」というお題を僕はそのまま字面通りとってしまい、「仕事以外に、F#で実用的なプログラムを書いたっけ・・・」などと回想した結果ひねり出したのがこのネタです。
 いささか「実用」というよりは「オレ用」という感じもしなくはないのですが・・・

 僕が富士通の名作ドキュメントスキャナS1500を購入したのは、ひとえにオライリーの「プログラミング F#」や技評の「実践F#関数型プログラミング入門」を持ち歩くのが辛かったからである、と言っていいほど僕の中でF#とS1500のつながりは実は深く、S1500でスキャンしたデータをSONY Readerで持ち歩くための変換プログラムがF#で書かれたこともまた必然と言っていいと思います。


 そんなわけで、このプログラムは1年前に書いたもので、今回の記事のためにチョコチョコ直しましたが色々とアレなところがある点についてはご容赦ください。

 ちなみにPDFの操作は独自実装といいたいところですが、残念ながらiTextSharpを使用しています。開発環境はVS2008です。まだVS2012買ってないんですすみません。
 実行にはiTextSharpが必要なため、ここからダウンロードしてください。最新の5.3.3で動作確認しています。

機能と使い方

  • 指定したPDFファイル(S-1500が出力したもの)から、JPEGファイルを取り出して特定のディレクトリに保存する(上のテキストボックスに元PDFのパスを入れて「PDF解体」)。出力先は「c:\temp」下になります。
  • 保存したページを画面に表示し、トリミング範囲を指定する。トリミング範囲は、左のページの左上、右下をクリックしてオレンジ色の枠を動かす。ドラッグでなく2か所クリック。右のページも同じトリミングで枠が出る。ページを送って、他のページで文字が枠からはみ出ないことを確認する。
  • 画面サイズ(右と下のテキストボックスで指定)を指定するその際、画面いっぱいになるよう画像を縮小するか、比を変えずに天地左右に空白を入れるかも指定できる。
  • 最後にガンマ補正のパラメータを左下のスライダーで指定し、「PDF作成」ボタンで少々待てば出力先に新しいPDFが作成される。

特筆すべき部分

  • 全ソースがここで公開されている!(UIはC#で、コアはF#で、unsafeなところはまたC#で)
  • だからもう自分の好きなようにいじり放題。機能追加も変更も思いのまま。ただしF#erに限る。
既存の補正ソフトがどれも「帯に短し・・・」と感じられる方は是非!

ソースの見どころ?

 このプログラムはいろんなサンプルになっていると思います。

F#からiTextSharpを呼んでJpegを取り出す

 iTextSharpをF#から呼んで利用するサンプルは、今のところ日本語では見当たらないようなので、ごく部分的ではありますが紹介します。

C#からdelegateをF#側に渡し、コールバックして画面の更新を行う

 C#からF#の関数を呼び出すこと自体には何ら難しいことはありませんが、作業の進捗を画面に表示するため、C#からクロージャを一緒に渡してそれをF#から呼びだす方法を解説します。
 この際、画面の更新はUIスレッドにしかできない、というルールを回避するための方法を例示します。

複数ページの処理をマルチコアで分散して処理

 PDFファイルからイメージを取り出す処理や、複数のイメージにガンマ補正をかけたりする処理は、並行に動かしても何ら差支えがありません。こういう処理をマルチコアに分散して、全体的な処理速度を上げる方法を例示してみます。

F#からC#の関数を読んでunsafeな処理(ガンマ補正)を行う

 F#は僕の知る限りunsafeな処理を自分ではできません。そのため、C#で作成したライブラリに処理を託します。


F#からiTextSharpを呼んでJpegを取り出す

こんな流れで進んでいきます。
 ファイル名をPdfReaderに与えて、PdfReaderオブジェクトを作成
let ExtractOperation (msgcb:msgCallback) (endcb:endCallback) (sourcePdf:string) outputPath =
  createDirectory outputPath
  let pdf = new PdfReader(sourcePdf)
  List.iter (OnePageFrom msgcb outputPath pdf) [1..pdf.NumberOfPages]

 pdfオブジェクトにページ番号を与えて、ページごとのPdfDictionaryオブジェクトを取得
let OnePageFrom (msgcb:msgCallback) outputPath (pdf:PdfReader) (page:int) =
  let pg = pdf.GetPageN(page)
  getImages outputPath pg pdf page

 PdfDictionaryからリソースとxobjを取得
let getImages outputPath (dict:PdfDictionary) (doc:PdfReader) page =
  let res = PdfReader.GetPdfObject(dict.Get(PdfName.RESOURCES)) :?> PdfDictionary
  let xobj = PdfReader.GetPdfObject(res.Get(PdfName.XOBJECT)) :?> PdfDictionary
  getPdfObjects xobj doc
  |> Seq.iter (saveImage outputPath page)

 xobjからキーを列挙し、そのキーごとにxobjからPdfObjectを取り出し、そのサブタイプがPdfName.IMAGEであるものだけを列挙する。
let getImage (doc:PdfReader) (theObj:PdfObject) =
  let tg = PdfReader.GetPdfObject(theObj) :?> PdfDictionary
  let subtype = PdfReader.GetPdfObject(tg.Get(PdfName.SUBTYPE)) :?> PdfName
  if PdfName.IMAGE.Equals(subtype) then
    let xrefIdx = (theObj :?> PRIndirectReference).Number
    let pdfObj = doc.GetPdfObject(xrefIdx)
    let str = pdfObj :?> PdfStream
    let filter = tg.Get(PdfName.FILTER).ToString()
    match filter with
    | "/FlateDecode" -> None
    | _ -> Some(PdfReader.GetStreamBytesRaw(str :?> PRStream))
  else
    None

let getPdfObjects (xobj:PdfDictionary) (doc:PdfReader) =
  seq {
    match xobj with
    | null -> ()
    | xobj ->
      for key in xobj.Keys do
        let theObj = xobj.Get(key)
        if theObj.IsIndirect() then
          yield getImage doc theObj
  }

 取り出すことができれば、それをbyteのストリームに見立てファイルに保存するだけです。
let parms = new System.Drawing.Imaging.EncoderParameters(1)
parms.Param.[0] <- 
  new System.Drawing.Imaging.EncoderParameter(System.Drawing.Imaging.Encoder.Compression, byte 12)

let saveImage outputPath pageNumber (img:byte[] option) =
  match img with
  | None -> ()
  | Some image ->
      use memStream = new System.IO.MemoryStream(image)
      memStream.Position <- 0L
      use img = System.Drawing.Image.FromStream(memStream)
      let path = System.IO.Path.Combine(outputPath, System.String.Format(tempFileFormat, pageNumber, 1))
      match jpegEncoder with
      | None -> ()
      | Some enc -> img.Save(path, enc, parms)

逆にF#からiTextSharpを呼んでPDFを作成し、Jpegごとにページを作って貼り込む

まず、最初に用紙サイズとマージンを指定してDocumentを作成し、それとファイル名を引数にPdfWriterオブジェクトを作成してそれを開きます。
let CreatePdf outputPath aspectRatio numOfPage = 
  let margin = 0.0f
  let document = new Document(new iTextSharp.text.Rectangle(0.0f, 0.0f, 
                                                            PageSize.A4.Width, PageSize.A4.Width * aspectRatio), 
                              margin, margin, margin, margin)
  PdfWriter.GetInstance(document, new System.IO.FileStream(outputPath, System.IO.FileMode.Create)) |> ignore
  document.Open ()
  List.iter (addOnePage document aspectRatio) [1..numOfPage]
  document.Close();
 次に、JpegファイルごとにImageオブジェクトを作成し、位置と大きさを設定してdocumentにAddしたのち、NewPageします。これで1ページできます。これを最後のページまで繰り返すだけです。
let addOnePage (document:Document) aspectRatio pageNum = 
  let path = System.IO.Path.Combine(effectedTempPath, System.String.Format(tempFileFormat, pageNum, 1))
  let jpeg = iTextSharp.text.Image.GetInstance(path)
  jpeg.SetAbsolutePosition(0.0f, 0.0f)
  jpeg.ScaleToFit(PageSize.A4.Width, PageSize.A4.Width * aspectRatio)
  document.Add jpeg |> ignore
  document.NewPage() |> ignore

 意外に簡単ですね。
 確か海外のどっかのサイトに乗っていたC#のサンプルをF#に移植しただけではなかったかと思います。
 ちょっとignoreが目立つのは、僕が手を抜いていると考えてもらっていいです。あくまで「オレ用」ですんで。

C#からdelegateをF#側に渡し、コールバックして画面の更新を行う

 と、ここまで書いて気が付いたのですが、この件については以前にここで書いていました。
 これより細かくここで書くのはちょっと無理な気がするので、こちらを参照してください。これと同じことをこのプログラムはやっています。

 具体的には、進行度をメッセージするメッセージコールバックのdelegateと、終了をメッセージするdelegateをF#の関数に渡して、それをF#の中から随時Invokeしています。

C#側のコード
PDFConvertLib.ExtractImagesFromPDF(
    delegate(string msg){
        var deleg = new MessageDelegate(ExtractImageMsg);
        var ret = this.BeginInvoke(deleg, new object[] {msg});
    },
    delegate() {
        var deleg = new EndDelegate(ExtractImageEndOperation);
        var ret = this.BeginInvoke(deleg, new object[]{});
    },
    filePath.Text, PDFConvertLib.outputTempPath
);

F#側のコード
let OnePageFrom (msgcb:msgCallback) outputPath (pdf:PdfReader) (page:int) =
  let pg = pdf.GetPageN(page)
  getImages outputPath pg pdf page
  sprintf "%A %d" pdf page |>
  msgcb.Invoke                  //   ← ココで呼びだし!

let ExtractOperation (msgcb:msgCallback) (endcb:endCallback) (sourcePdf:string) outputPath =
  async {
    createDirectory outputPath
    let pdf = new PdfReader(sourcePdf)
    [1..numberOfThreads] |>
    List.map (ThreadRoundRobbin (OnePageFrom msgcb outputPath pdf) pdf.NumberOfPages numberOfThreads) |>
    Async.Parallel |>
    Async.RunSynchronously |> ignore
    endcb.Invoke ()                  //   ← ココで呼びだし!
  }

let ExtractImagesFromPDF (msgcb:msgCallback) (endcb:endCallback) (sourcePdf:string) outputPath =
  ExtractOperation msgcb endcb sourcePdf outputPath |>
  Async.Start |> ignore

 呼び出されるコードは、asyncで囲ってAsync.Startしないと別スレッドにならないため、いくら中でコールバックしても実際の画面更新はすべてすっかり終わってからになってしまいます。

複数ページの処理をマルチコアで分散して処理

 C#の方も色々機能強化されて非同期処理がだいぶ得意になっていますが、それでも記述の自由さではF#に一日の長があります。
 上にも書いてあるように、PDFからイメージを取り出して保存したり、取り出した画像にエフェクトをかけたりする作業はページ単位で独立しているため、並列動作が可能です。
 このプログラムは当初シリアルに処理していますが、最後になってマルチコアを有効利用するように書き換えました。それが以下の部分です。

順次処理
let ExtractOperation (msgcb:msgCallback) (endcb:endCallback) (sourcePdf:string) outputPath =
  async {
    createDirectory outputPath
    let pdf = new PdfReader(sourcePdf)
    List.iter (OnePageFrom msgcb outputPath pdf) [1..pdf.NumberOfPages]
    endcb.Invoke ()
  }

並列化
let ThreadRoundRobbin theMethod max cntThread n =
  async {
    List.iter theMethod [n..cntThread..max]
  }

let ExtractOperation (msgcb:msgCallback) (endcb:endCallback) (sourcePdf:string) outputPath =
  async {
    createDirectory outputPath
    let pdf = new PdfReader(sourcePdf)
    [1..numberOfThreads] |>
    List.map (ThreadRoundRobbin (OnePageFrom msgcb outputPath pdf) pdf.NumberOfPages numberOfThreads) |>
    Async.Parallel |>
    Async.RunSynchronously |> ignore
    endcb.Invoke ()
  }

 ThreadRoundRobbinは、ページごとに処理を指定数のスレッドに振り分ける関数です。スレッド数が4なら、最初にスレッドにはページ1,5,9,13...が、二つ目には2,6,10.14...が、三つめには3,7,11,15...が、四つ目には4,8,12,16...が割り振られ、それがAsync.Parallelで並列に動作し、Async.RunSynchronouslyですべての処理の終了を待ってendcb.Invoke(終了通知処理)を呼び出すようになっています。
 今回はこんな方法をとったのですが、何も考えずに全ページをasyncしてもページ数が少なければそれなりに動きます。ただ、あまりページが多いとエラーになるようです。

F#からC#の関数を読んでunsafeな処理(ガンマ補正)を行う

 C#でFastBitmapなるクラスを作り、最初にガンマ値を与えて補正テーブルを作成してから、ビットマップごとにピクセル単位でをかけています。去年どこかで見たソースそのままだったような・・・
 そんなわけなのでここでは掲載しませんが、興味がある方はソースを覗いてみてください。

 と思ったら、.Netの機能でガンマ補正ってかけられるんですね・・・トホホ。しかも1.1からあるみたいだし。去年何を調べたんだ。
 ImageAttributes.SetGamma

終わりに

 という具合でざっくり説明してきましたが「全部C#でかける」とか言わないでこのF#の濃密なソースを楽しんでもらえれば幸いです。
 ソースはとバイナリはこのページ最下部の添付ファイルからダウンロード(S1500PDfConverter.zip)できますが、後日Githubにもあげようと思っています。そしたらみなさんで好きなように機能拡張してください。僕も使いますので。

サポート

 すみません。2013年2月24日以前のソースでは「C:\temp\pdfeffected」ディレクトリをプログラム内部で作っていなかったため、このディレクトリがないとPDF作成時に落ちてしまいます。最新版をダウンロードしなおすか、このディレクトリをあらかじめ作成しておいてください。m(_ _)m


(文責:片山 功士  2012/12/11)

今日: -
昨日: -
トータル: -
最終更新:2013年02月24日 03:30