---
title: "PDFの表紙サムネイル生成をMediaBoxからCropBoxに揃え直した"
description: "書籍リーダー Bookil の開発中ブランチで、一部の PDF だけ表紙サムネイルが見開き（横長）になっていた問題を直しています。原因は印刷入稿由来の PDF が持つ MediaBox と CropBox の差をサムネイル生成側が考慮していなかったこと。修正の判断と、crop≠media な fixture をコードで生成して回帰テストにする方法を扱います。"
category: "Apple"
tags: ["iOS","PDFKit","Bookil","Swift","CoreGraphics"]
publishedAt: "2026-06-26"
lastmod: "2026-07-29"
---

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

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

::card[https://labee.jp/posts/bookil-custom-pdf-renderer]

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

## 症状

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

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

## 原因は MediaBox と CropBox の差

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

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

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

![印刷入稿由来の表紙PDFの1ページ目は、MediaBoxが約1700×841ptの横長で表紙・背・裏表紙が並んだ見開き原稿になっており、その内側に約420×595ptの単ページぶんのCropBoxが表表紙の範囲として置かれている。サムネイル生成はMediaBoxを基準にしていたため見開き全体を1枚に縮小し、一覧に横長の表紙が並ぶ。ビューアー描画はCropBoxを基準にしているため表表紙だけを切り抜き、単ページで正しく表示される](/images/posts/bookil-pdf-cropbox-thumbnail/mediabox-vs-cropbox.svg)

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

## CropBox に揃える

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

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

```diff
-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` を落としています。サムネイル生成も例外ではなく、`CGPDFDocument` と `CGPDFPage` だけで完結する作りに揃えました。ページのレンダリングは `CGContext.drawPDFPage(_:)` で十分で、`PDFView` も `PDFDocument` も触らない構造です。

```swift
@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` を作れば、`MediaBox` と `CropBox` を別寸法に設定した1ページ PDF が手元に揃います。

```swift
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 設定が反映されません。エラーは出ません。

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

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

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

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

`beginPDFPage` の `pageInfo` 辞書は値の型に `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 するガードを置きました。

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

![kCGPDFContextCropBoxにCGRectをそのまま渡してもエラーは出ず、CropBoxが設定されないままfixtureがcrop==mediaで書き出される。CropBox基準の描画はMediaBoxにフォールバックする仕様なので、サムネイルがCropBox寸法で描画されるという本体のアサーションが、差を作れていないまま通る。テスト冒頭にcropBoxとmediaBoxが異なることを表明するガードを置くと、この連鎖を途中で止められる](/images/posts/bookil-pdf-cropbox-thumbnail/false-green.svg)

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

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

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

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

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

::card[https://apps.apple.com/app/bookil/id6475175265]