MacOSX
Xcode
iOS
PDF

時代が追いついてきたので PDF をベクター画像として描画する方法をまとめる

More than 3 years have passed since last update.


ラスタライズ?

Xcode 6になって、『PDF でベクター画像を作って読み込んでおくと便利』といった話がありますが、そもそもこの方法ではプログラム実行時に実際にベクター描画しているわけではなく、あくまでビルド時に PDF から適切な大きさのビットマップ画像を自動生成(ラスタライズ)しているにすぎません。

なので、当然、描画内容を拡大したり回転したりすればジャギーが発生してしまいます。単なるビットマップです。

では、PDF をベクター描画して拡大などにも耐え得るようにするにはどうすればよいか?

実は古い iOS でも通用する方法でこれを実現することが可能です。実際、この内容は iOS 5のときのものほぼそのままになります。


なぜ PDF なのか(余談)

単なる余談ですが、グラフィックスエンジンという観点で OS X と iOS の歴史を振り返ってみます。

iOS は元々 OS X のサブセットという形で生まれ、当初は「OS X iPhone」とも呼ばれていました。その後 iPhone OS という名を経て iOS に落ち着きました。この事からも分かるように、iOS は OS X の流れを汲む OS になります。

OS X のグラフィックスエンジン Quartz は PDF ベースの描画を行います。なので OS X と PDF は相性がよく、Cocoa プログラミングの世界では JPEG や TIFF などのビットマップを描画するのと同じ感覚で PDF も描画する事が出来ます(PDF をより扱いやすくするためのフレームワークも別途存在します)。つまり、PDF は Core Graphics (Quartz 2D)のグラフィックスコンテクストに対して容易く描画する事が可能です。

また、OS X では PDF を描画することと紙に印刷する事は同等と考える事が出来、実際、印刷ダイアログの左下には PDF 書き出し機能があり、ここで行われる事は紙に出力するかデジタルデータに出力するかの違いのみで同じ処理です。

print.png

当然、Core Graphics は iOS にも引き継がれており、少し凝ったグラフィックス表現を扱いたい場合には UIView#drawRect: 内でグラフィックスコンテクストを取得して CG〜 系の関数を使う、という事が多いかと思います。これは OS X の NSView#drawRect: でやっていたこととまるで同じです。

SVG ではなく PDF である理由は、PDF の方が描画するのに扱いやすいからなのだと思います。その他理由があるとすれば、書き出しやすく、閲覧しやすく、ファイルとしても扱いやすいからというのもあるかと思います。

ということで、iOS で PDF の描画を行う場合にも Core Graphics を使う事になります。


ベクター画像によるアイコン描画の事例(余談)

OS X では Lion ぐらいから(だったと思います)一部のアイコンの描画に PDF が採用されています。


/System/Library/CoreServices/Menu Extras/Volume.menu/Contents/Resources/


システム標準の「音量」メニューエクストラのプラグインパッケージを覗いてみると、PDF ファイルを見つける事が出来ます。以前はこれは16×16くらいの小さな TIFF ファイルでした。

vol.png

Apple のウェブサイトでは、グローバルナビゲーションの部分に SVG 画像が使われています。

最近のデザイン変更でフラット化したため違いが分かりにくくなってしまったのですが、林檎の部分がそれです。

svg.png

見て分かる通り、ベクター画像の描画に採用されているのは単色塗りの単純な形状をしたアイコンです。このような場面においてはベクター画像は有用です。


PDF の作り方

今回このような画像を用意しました。

ill.png

背景は透過しています。

prev.png

Illustrator などのドロー系ソフトウェアで作るのですが、単位はピクセルにした方が分かりやすくなります。今回は幅を288pxの正方形とし、アートボードもその大きさにしました。

実際に描画されるのはこのアートボードの範囲になります。

色空間は RGB です。

パスとアンカーポイントは少ない方が描画コストがからないと思います。

効果は PDF と Core Graphics が対応しているものであれば使用出来ます。Illustrator の特殊な機能をバリバリ使ってしまうと正しく描画されなくなってしまうので、グラデーションや色ぐらいにとどめておいた方が良さそうです。具体的にどの機能が互換性があるのかについては調べられていません。

Illustrator から「別名で保存…」で PDF を選ぶと、何やら奇怪なダイアログが出てきますが、とりあえず不要そうなものは外してよいと思います。


  • サムネイル埋め込み等は無駄な情報を増やすだけなので無効にします

  • 圧縮はビットマップ画像を含んでいる場合のものなので、必要であれば無効にします

  • トンボは関係がないので無効にします

  • 裁ち落としはドキュメントの設定を使用するか、すべて0にします

  • カラープロファイルは、そもそも iOS が ColorSync を搭載しておらずカラーマネージメントに非対応なので、不要かと思われます

  • セキュリティは外します

(PDF のバージョンは関係がありそうですが、iOS が対応していないのが具体的にどれなのかは分かりません。)


Core Graphics で PDF を描画する

Core Graphics (Quartz 2D) に関しては Apple が日本語の文書を公開しており、こちらが非常に分かりやすい内容となっています。


Quartz 2Dプログラミングガイド

https://developer.apple.com/jp/devcenter/ios/library/documentation/drawingwithquartz2d.pdf



実際に PDF を描画するのに関係してくる技術は次の通りです:


  • CGContextRef

  • CGPDFDocumentRef

  • CGPDFPageRef

  • UIView#drawRect:

  • UIView#contentScaleFactor


CGContextRef

Core Graphics の描画コンテクストです。

UIView#drawRect: 内では UIGraphicsGetCurrentContext() によりその参照を取得する事が可能です。これに対して描画を行います。

描画コンテクストには描画環境の保存と破棄という概念があり、まず現状を保存する事を明示します。そして実際の描画を行い、終わったら破棄します。保存される情報は、例えば塗りつぶし色、CTMの状態、アンチエイリアスの設定、クリッピングマスク、ブレンドモードなどがあります。基本的には save〜restore 間のブロックに描画に関するコードを記述します。


UIView#drawRect

CGContextRef context = UIGraphicsGetCurrentContext();

CGContextSaveGState(context);

// 例:上下反転
CGContextScaleCTM(context, 1.0, -1.0);
// 例:パスを追加
CGContextAddRect(context, rect);
...

CGContextRestoreGState(context);



CGPDFDocumentRef

PDF ドキュメントを表します。

PDF ファイルを URL で指定すると展開されます。描画処理が終わって完全に使い終わったら CGPDFDocumentRelease() で解放しましょう。

CFURLRef PDFURL = (__bridge CFURLRef)[[NSBundle mainBundle] URLForResource:@"ファイル名" withExtension:@"pdf"];

CGPDFDocumentRef PDFDocument = CGPDFDocumentCreateWithURL(PDFURL);

// 使い終わったら解放(描画が終わったら=ビューが破棄されるとき?)
CGPDFDocumentRelease(PDFDocument);


CGPDFPageRef

PDF の各ページを表します。

PDFDocument から取得する事が可能です。ページ番号は1始まりです。

size_t pageNumber = 1;

CGPDFPageRef PDFPage = CGPDFDocumentGetPage(PDFDocument, pageNumber);

描画の度に CGPDFPage への参照を取得していると描画コストが増えてしまうので、あらかじめ -drawRect: ではないところで処理しておくのがよいと思います。


PDF ページを描画する

Core Graphics の標準的な座標系は左下原点(Y軸上方向が正=OS X NSView の標準)ですが、iOS の UIView から取得出来る描画コンテクスト CGContext は UIKit の座標系に合わせて左上原点(Y軸下方向が正)になるように修正されています。そのため、UIView に PDF を描画する際にはまずY軸が上下反転していることを考慮する必要があります。

PDF の描画には CGContextDrawPDFPage() 関数を使います。上下反転には CGContextScaleCTM() 関数により CTM を変更することで対応します。また、座標と大きさを指定するために PDF メディアの大きさを取得する必要がありますが、このときに指定するオプションに要注意です。


CGPDFBox

PDF には5種類の矩形が存在します。


  • Media Box

  • Crop Box

  • Bleed Box

  • Trim Box

  • Art Box

これらは CGPDFBox enum として定義されています。

各々の意味についてはトンボの理解が必要になるので、こちらの記事を参照してください。

http://qiita.com/items/d2df40f5d3c03dbdac26

ここでは Media Box すなわち kCGPDFMediaBox を使用します。これにより印刷可能領域全体を指定する事が可能です。(Illustrator で PDF を作成している場合、アートーボード領域が Media Box に一致します。)



-drawRect


// UIView の描画コンテクストを取得します
CGContextRef context = UIGraphicsGetCurrentContext();

// 描画環境の状態を保存
CGContextSaveGState(context);

// Media Box で矩形の大きさを取得します
CGRect boxRect = CGPDFPageGetBoxRect(PDFPage, kCGPDFMediaBox);

// この後上下反転すると画面外になるので、Y軸を移動します
CGContextTranslateCTM(context, 0.0, boxRect.size.height);

// 上下反転します
CGContextScaleCTM(context, 1.0, -1.0);

// PDF Page を描画します
CGContextDrawPDFPage(context, PDFPage);

// 描画環境の状態を破棄
CGContextRestoreGState(context);


実行結果。

3.png

これだけで PDF のベクター描画が行える訳ですが、UIView の大きさを変えても描画内容は変わりませんし、UIScrollView 経由で拡大(zoom)するとこんどはジャギーが目立ってしまいます。Core Graphics の関数を使えば拡大縮小にも耐え得るのですが、UIKit 側からの操作で適切な描画を行ってほしいものです。

ビューの大きさが分かりやすいよう、背景を白に塗りました。

右の拡大時の画面を見ると、ジャギーが出ているのが分かります。

1.png

大きさについてはメソッドの引数 rect をうまく採用すれば解決します。

拡大については UIView#contentScaleFactor を使います。


UIView#contentScaleFactor

UIScrollView の拡大でジャギーが生まれてしまったのは、contentScaleFactor の影響です。早い話、この値を「拡大率 × ディスプレイスケール」にしてやるだけで思い描いた通りの動きになります。


UIScrollViewDelegate

- (void)scrollViewDidEndZooming:(UIScrollView *)scrollView withView:(UIView *)view atScale:(CGFloat)scale

{
view.contentScaleFactor = scrollView.zoomScale * [UIScreen mainScreen].scale;
}

拡大を検知するデリゲートメソッドは他にもありますが、 -scrollViewDidEndZooming:withView:atScale: でやるのが描画コスト的にも一番環境に優しいかと思います。

これで拡大してもジャギーのない、本当のベクター描画(?)が行えました。

2.png


疑問点


PDF の大きさはプログラムではどういう扱いになるのか

以前、「288pxで作った」と書きましたが、この単位をptに置き換えたものがそのまま描画結果の大きさになります。

iOS の論理座標系では @1x を基準としているので、描画結果のピクセル値は元の288に UIScreen のスケール値を掛けたものになります。iPhone 4〜6の Retina 環境であれば288ptの2.0倍、576pxになります。

素材の PDF は「@1x 相当のピクセル数」で作れば良いということになります。


埋め込みフォントはどうなるのか

試しに Illustrator で適当なフォントを選び、文字を配置しました。それをそのまま PDF に書き出したものと、一旦アウトライン化した上で書き出したもの2種類を用意して描画してみました。

左がフォント埋め込み、右がアウトライン化

4.png

結局のところ、PDF の埋め込みフォントには対応してくれます。よく考えたら当たり前ですね。フォントのライセンス上こういった利用が許されるのかは分かりませんが。


プログラムで色などを変えられるのか

PDF を CGPath などのパスデータに展開することができれば可能かもしれませんが、現在のところその術を見出せていません。

出来そうだが、難しそうです。(この辺りどなたかご存知であれば教えてください。)

パスデータのまま使うには、SVG を扱うライブラリを採用した方が良さそうです。


この方法で UIScrollView を拡大していったらメモリを大量消費しない?

はい。手抜きしました。

CATiledLayer で取り急ぎ対応してみましたが、他に良い方法があればご指摘お願いします。


CATiledLayer 対応版

@interface PDFView ()

{
CATiledLayer *_tiledLayer;
}

@end

@implementation PDFView

- (id)initWithFrame:(CGRect)frame
{
self = [super initWithFrame:frame];
if (self) {
_tiledLayer = (CATiledLayer*)self.layer;
// この辺りのうまい設定がよくわかりません
_tiledLayer.levelsOfDetail = 1;
_tiledLayer.levelsOfDetailBias = 3;
_tiledLayer.tileSize = CGSizeMake(1024, 1024);
}
return self;
}

+ (Class)layerClass
{
return [CATiledLayer class];
}

- (void)drawLayer:(CALayer *)layer inContext:(CGContextRef)ctx
{
CGContextRef context = ctx;

// 描画環境の状態を保存
CGContextSaveGState(context);

// Media Box で矩形の大きさを取得します
CGRect boxRect = CGPDFPageGetBoxRect(PDFPage, kCGPDFMediaBox);

// この後上下反転すると画面外になるので、Y軸を移動します
CGContextTranslateCTM(context, 0.0, boxRect.size.height);

// 上下反転します
CGContextScaleCTM(context, 1.0, -1.0);

// PDF Page を描画します
CGContextDrawPDFPage(context, PDFPage);

// 描画環境の状態を破棄
CGContextRestoreGState(context);
}

@end


実際のところ使えるのか

UI 素材として活用するのであれば、Xcode の例の機能を使った方が良いです。というのも、ベクター描画なのでそれなりに描画コストがかさむ、描画の仕組みを実装する必要がある、例の機能があるならベクター化する意味がそれほどない、などがあるからです。

動的に描画してキャッシュはビットマップで持つ、という仕組みであればこの方法が使えるかもしれません。あるいは、スプラッシュでちょっとしたアニメーション演出をしたいなどの場面でしょうか。