Engineering

PDFの表紙サムネイル生成をMediaBoxからCropBoxに揃え直した

  • iOS
  • PDFKit
  • Bookil
  • Swift
  • CoreGraphics

書籍リーダー Bookil の開発中ブランチで、ライブラリ一覧の表紙サムネイルが特定の PDF だけ横長の見開きで生成される挙動を直しています。原因はサムネイル生成が PDF の MediaBox を見ていたこと、印刷入稿由来の表紙は MediaBox が「表紙+背+裏表紙」の見開き原稿になっていたことです。この記事では、ビューアーの表示と差が出た理由、CropBox に切り替えた判断、CGContext で crop≠media な fixture を生成する回帰テストの設計を順に扱います。

なお、本記事は PDF レンダリングシステムを書き直している話の続編です。土台となる設計判断は次の記事にまとめています。

ブログBookilで漫画の見開きをページ送りで読ませたくて描画を作り直しているiOS の PDFKit は「ページ送り」と「見開き表示」を同時に成立させられません。書籍リーダー Bookil で見開きの読み心地を作りたかった結果、描画層を自前で持つ方針に振って今もその開発を進めています。PDFKit の制約と、判断の軸を扱います。

書き直しの過程で、これまで PDFKit に隠れていた MediaBox / CropBox の扱いが表面に出てきたため、サムネイル生成系も合わせて作り直しています。現状ストア配信前の開発中ブランチでの話で、次のリリースに乗せる予定です。

症状

  • ライブラリ一覧で、ある PDF だけ表紙サムネイルが横長の見開きになる
  • 同じ PDF を新しい描画層で開くと単ページで正常に表示される。サムネイルだけがおかしい
  • 再現したのは商業出版の技術書 PDF。手元で持っている自炊 PDF では起きない

ビューアーの描画は正しいので、サムネイル生成のパスだけが何かを見ていない、という当たりは早い段階で付きました。

原因は MediaBox と CropBox の差

再現する PDF を PDFKit で開き、1ページ目の bounds(for: .mediaBox)bounds(for: .cropBox) を出力したところ、それぞれ次のような寸法でした。

  • 1ページ目の MediaBox — 約 1700 × 841 pt の横長
  • 1ページ目の CropBox — 約 420 × 595 pt の単ページ
  • 2ページ目以降は MediaBox と CropBox がほぼ一致

「原稿データは見開きで持ち、表示すべき領域は CropBox で単ページに切り出す」という、印刷入稿用にカバー原稿を作ったときによく出る構造です。表紙・背・裏表紙が1枚の原稿に並んでいて、印刷時はそのまま、画面表示時は CropBox で表表紙だけを切り抜いて見せる前提になっています。

flowchart LR
    PDF[入稿 PDF<br/>1 ページ目]
    PDF -->|MediaBox 基準| Thumb[サムネイル生成<br/>見開き全体を描画]
    PDF -->|CropBox 基準| Viewer[ビューアー描画<br/>表表紙だけ描画]
    Thumb --> Bug[一覧で横長表紙]
    Viewer --> OK[本文表示は正常]

書き直し中の描画層は CropBox を基準にしていたため、画面では表表紙だけが切り抜かれて見えます。一方、サムネイル生成は古い実装の流れで MediaBox を基準にして1ページ目を1枚の画像にしていたため、見開き原稿がそのまま縮小されて一覧に並んでいたわけです。閲覧側とサムネイル側で基準ボックスが食い違っていたのが直接の原因でした。

CropBox に揃える

修正方針はシンプルで、サムネイル生成側もページボックスを CropBox に揃えるだけです。CGPDFPage.getBoxRect(.cropBox) でページサイズを取り、CGContext.drawPDFPage(_:) を呼ぶ前に getDrawingTransform(.cropBox, rect:rotate:preserveAspectRatio:) で変換行列を組み立てて、その rect に向けて描画します。書き直し中の本文描画と同じ基準にすることで、ビューアーとサムネイルが同じ領域を見るようになります。

変更の本体は getBoxRectgetDrawingTransform の引数を .mediaBox から .cropBox に差し替えるだけです。差分にすると次のような形になります。

-let pageBox = page.getBoxRect(.mediaBox)
+let pageBox = page.getBoxRect(.cropBox)
 // …
 let transform = page.getDrawingTransform(
-  .mediaBox,
+  .cropBox,
   rect: CGRect(origin: .zero, size: pixelSize),
   rotate: 0,
   preserveAspectRatio: true
 )
 context.concatenate(transform)
 context.drawPDFPage(page)

切り替えにあたって気にしたのは、CropBox を持たない PDF の挙動です。PDF 仕様(ISO 32000-1)では、CropBox はオプショナルなページボックスで、未定義のときは MediaBox にフォールバックするよう規定されています。CoreGraphics と PDFKit の実装もこの仕様に従っていて、CGPDFPage.getBoxRect(.cropBox) は CropBox が未定義なら MediaBox 相当の rect を返します。手元の自炊 PDF(CropBox 未定義のものが大半)で挙動が変わらないことを確認してから開発ブランチに入れました。

修正自体は数行ですが、ビューアーの基準と揃えるという原則のほうが大事で、CropBox / MediaBox の選択は「ページのどのボックスを正と見なすか」という設計の話に近い、という整理にしました。

なお、サムネイルは生成済みのものがディスク上にキャッシュされる作りになっています。古い基準で焼かれたサムネイルが残ったまま新しい基準で開くと一覧と本文で見え方が食い違うため、配信前にキャッシュの取り扱いを整理する必要があります。具体は記事末尾の「リリースまでに整理しておきたいこと」で扱います。

サムネイル系の依存を CoreGraphics 側に寄せた

書き直しの過程で、本番ビルドからは PDFKit の import を落としています。サムネイル生成も例外ではなく、CGPDFDocumentCGPDFPage だけで完結する作りに揃えました。ページのレンダリングは CGContext.drawPDFPage(_:) で十分で、PDFViewPDFDocument も触らない構造です。

@preconcurrency import CoreGraphics

func renderThumbnail(page: CGPDFPage, maxPixel: CGFloat) -> CGImage? {
  let cropBox = page.getBoxRect(.cropBox)
  let scale = maxPixel / max(cropBox.width, cropBox.height)
  let pixelSize = CGSize(
    width: cropBox.width * scale,
    height: cropBox.height * scale
  )

  let colorSpace = CGColorSpaceCreateDeviceRGB()
  guard let context = CGContext(
    data: nil,
    width: Int(pixelSize.width),
    height: Int(pixelSize.height),
    bitsPerComponent: 8,
    bytesPerRow: 0,
    space: colorSpace,
    bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue
  ) else { return nil }

  context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
  context.fill(CGRect(origin: .zero, size: pixelSize))

  let transform = page.getDrawingTransform(
    .cropBox,
    rect: CGRect(origin: .zero, size: pixelSize),
    rotate: 0,
    preserveAspectRatio: true
  )
  context.concatenate(transform)
  context.drawPDFPage(page)

  return context.makeImage()
}

PDFKit を落とした副次効果として、ビルドサイズと起動時のリンクコストが少し軽くなりました。本筋は「ビューアーと同じ描画下回りでサムネイルも作る」整理ですが、結果として依存も整理できた格好です。

回帰テストの fixture をどう作るか

ここからは再発防止の話です。MediaBox != CropBox な PDF をテストで固定したいのですが、商業出版の PDF はテストリポジトリに置けません。バイナリ fixture を別途配るのも、テストの読み手にとって「何のデータか」が見えにくく、見通しが悪くなります。

そこで、テスト実行時に CGContext でその場で PDF を組み立てる方式にしました。CGContext(consumer:mediaBox:_:) で PDF 出力先のコンテキストを作り、beginPDFPage(_:) に CropBox を含む pageInfo 辞書を渡して1ページだけ書き出します。書き出した Data から CGPDFDocument を作れば、MediaBoxCropBox を別寸法に設定した1ページ PDF が手元に揃います。

private func makeDocument(mediaBox: CGRect, cropBox: CGRect?) -> CGPDFDocument? {
  let data = NSMutableData()
  guard let consumer = CGDataConsumer(data: data as CFMutableData) else { return nil }
  var mediaBox = mediaBox
  guard let context = CGContext(consumer: consumer, mediaBox: &mediaBox, nil) else {
    return nil
  }

  var pageInfo: [CFString: Any] = [:]
  if var cropBox {
    pageInfo[kCGPDFContextCropBox] = NSData(
      bytes: &cropBox, length: MemoryLayout<CGRect>.size)
  }
  context.beginPDFPage(pageInfo as CFDictionary)
  context.setFillColor(CGColor(red: 1, green: 1, blue: 1, alpha: 1))
  context.fill(mediaBox)
  context.endPDFPage()
  context.closePDF()

  guard let provider = CGDataProvider(data: data as CFData) else { return nil }
  return CGPDFDocument(provider)
}

CropBox は CGRect ではなく CFData で渡す

ここで一度はまりました。pageInfo[kCGPDFContextCropBox] に CGRect をそのまま入れても、生成された PDF の CropBox は MediaBox と同値のままで、CropBox 設定が反映されません。エラーは出ません。

最初に書いてしまったのは次の形でした。

// NG: CGRect を直接渡しても黙って無視される
var cropBox = CGRect(x: 0, y: 0, width: 420, height: 595)
pageInfo[kCGPDFContextCropBox] = cropBox

正しくは、CGRect のバイトを NSData(bytes:length:) で包んで CFData として渡します。

// OK: CGRect を CFData として渡す
var cropBox = CGRect(x: 0, y: 0, width: 420, height: 595)
pageInfo[kCGPDFContextCropBox] = NSData(
  bytes: &cropBox,
  length: MemoryLayout<CGRect>.size
)

beginPDFPagepageInfo 辞書は値の型に CFData(rect 系のボックス指定)と CFString(タイトル等)を混在で要求していて、CropBox / MediaBox / BleedBox 等の rect 指定は CFData バイト列で受け取る規約になっています。Apple Developer Forums でも「kCGPDFContextCropBox に CGRect を入れても効かない」という相談が散発的に出ていて、CFData で渡す形に直すと動く、という回答パターンが共通しています。

このことを知らずに値を渡すと、テストは MediaBox = CropBox の PDF を相手にしていることになり、CropBox 基準の描画ロジックを実装しても fixture 側で差を作れていない、という誤陽性ならぬ誤陰性が起こります。

fixture が意図通りに作れたかをテスト冒頭でガードする

CFData 要件は将来の OS バージョンで挙動が変わる可能性もゼロではなく、CI が何かのきっかけで CropBox を持たない PDF を相手にしたとき、CropBox 基準の描画ロジックは MediaBox にフォールバックします。テスト本体のアサーション(「サムネイルが CropBox 寸法で描画される」)が偶然パスしてしまうのが、テストとしては最悪の壊れ方です。

そこで、テスト本体の前に「fixture が crop≠media で書き出されたこと」を assert するガードを置きました。

let page = try #require(document.page(at: 1))
#expect(page.getBoxRect(.cropBox) != page.getBoxRect(.mediaBox))

このガードが落ちれば fixture 側の問題、本体のアサーションが落ちれば実装側の問題、と切り分けが効きます。CropBox の指定が黙って無視される構造である以上、fixture の構築自体に表明を入れておくのが筋でした。

加えて、CropBox 未定義の PDF が引き続き MediaBox 寸法で描画されることも独立したテストで固定しています。CropBox を持つ PDF と持たない PDF の両方を、生成方式の fixture でカバーすれば、商業出版の PDF をリポジトリに抱える必要がありません。

リリースまでに整理しておきたいこと

現状の開発ブランチでは MediaBox 基準で焼かれた古いキャッシュをどう扱うかが宙に浮いています。今は手元で消して試し直しているだけで、配信時に古いサムネイルが残ったまま新しい描画基準と食い違う状態を放置すると、見開きで生成済みのサムネイルが一覧に残り続けます。サムネイルキャッシュにバージョン情報を持たせて、起動時に差分だけ作り直す導線をリリース前に組み込む方針です。

また、ページボックスは CropBox / MediaBox 以外に TrimBox / BleedBox / ArtBox があり、印刷入稿向けにこれらが独自に設定されている PDF も理論上は存在します。今回の書き直しでは CropBox 固定で運用しますが、配信後に手元の蔵書で問題が出てきたら、表示ボックスの選択ロジック自体を見直す形になります。

App StoreBookil App - App StoreDownload Bookil by Labee LLC on the App Store. See screenshots, ratings and reviews, user tips, and more apps like Bookil.