iOSで文字を組む
こんにちは。今回はデザイナーの森が担当します。iOSでの文字組について。
普通の文字組
iOSで文字を扱う場合、以前はわりと不自由な環境でしたが、iOS 6で、属性付きの文字列を扱えるNSAttributedStringを、UITextViewやUILabelで使えるようになりかなり進歩してきました。
しかし、印刷物で普通に行われている文字組のルールのうち、最低限必要と思われる物のいくつかはまだ実装されておらず、もう少し実装の工夫が必要なようです。
では、普通な文字組をするには何が必要でしょう?
私は、次にあげる5つがあれば可能だと考えています。
それでは、UILabel, UITextViewでの状況はどうでしょう。
文字の大きさが設定できる
可能。行送り(行間)が設定できる
行と行の間隔。行送りはベースラインから次のベースラインまでの距離。行間は行の下から次の行の上までの距離。
NSAttributedStringとNSParagraphStyleで可能(iOS 6〜)。禁則処理ができる
可能。連続約物の処理ができる
約物の多く(括弧、句読点など)は、半角分の文字の前か後ろに半角分の空きを持っています。通常、日本語の組版では、これらの約物が連続した場合、空きを詰めて余分な空きができるのを防ぎます。
iOSでは、この処理に関してのAPIは用意されていませんが、NSAttributedStringとNSKernAttributeNameを使って、文字間を詰めることで可能です(iOS 6〜)。行頭、行末での約物の処理ができる
行頭に、前括弧など、前に空白を持つ約物がきた場合、半角分行頭を詰め、他の行と左側(縦書きの場合は上)を揃えます。
行末の句読点のぶら下がりをしないのであれば、行頭の約物の処理だけ考えればいいでしょう。
この処理もAPIは用意されていません。そして、連続約物の処理と違って、UILabelなどの上位レベルのAPIでの対応は難しく、CoreTextやCoreGraphicsといった、より低レベルの描画APIでの実装が必要となってきます。
それでは、今回はCoreTextを使って、上記の5つの機能を持った文字描画コードを実装していきます。
文字を組んで描画するまで
文字を描画する手順は以下のとおりです。
- 文字の属性を設定
- 改行位置の決定
- 描画処理
1. 文字の属性の設定
文字に対して属性を設定していきます。最低限設定したい下記5つの属性を、NSAttributedStringを使って設定します。
- フォント
- 文字の大きさ
- 行送り
- 色
- カーニング
フォント、文字の大きさ
任意のフォントと大きさでCTFontを作成し、kCTFontAttributeNameを指定してNSMutableAttributedStringに設定します。iOS 6以降の対応であれば、UIFontとNSFontAttributeNameでもかまいません。
NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:text];
CTFontRef ctfont = CTFontCreateWithName((__bridge CFStringRef)@"HiraKakuProN-W3", 16.0, NULL);
[attributedString addAttribute:(NSString *)kCTFontAttributeName
value:(__bridge id)ctfont
range:NSMakeRange(0, attributedString.length)];
CFRelease(ctfont);
行送り
話が前後しますが、行頭で約物の処理をするためには、行の開始、終了位置を把握する必要があります。
行送り自体はNSAttributedStringとNSParagraphStyleを使って設定可能ですが、これらでは行の開始位置が把握ができないので、独自に実装していきます。
2の「改行位置の決定」で解説します。
色
CGColorをkCTForegroundColorAttributeNameを設定します。iOS 6以降であればNSForegroundColorAttributeNameでも可。
カーニング
カーニングは詰めたいもの(今回は連続した約物)を正規表現にかけ、kCTKernAttributeNameで設定していきます。
下記は、後括弧と前括弧の組み合わせを詰める場合の例です。
// 正規表現
NSError *error;
NSString *pattern = [NSString stringWithFormat:@"(([%@]{1,})([%@]{1,}))",
@"{[「『(⦅〈《〔〘【〖",
@"}]」』)⦆〉》〕〙】〗"];
NSRegularExpression *brackets = [NSRegularExpression regularExpressionWithPattern:pattern
options:0
error:&error];
if(!error){
id block = ^(NSTextCheckingResult *match, NSMatchingFlags flag, BOOL *stop){
NSRange r = [match rangeAtIndex:0];
if(r.length > 1){
// 連続した後括弧と前括弧があった場合、文字サイズの半角分のカーニング値を
// AttributedStringに設定していく
float k = fontSize/2 * -1;
r.length = r.length -1;
[attributedString addAttribute:(NSString*)kCTKernAttributeName
value:[NSNumber numberWithFloat:k]
range:r];
}
};
}
[brackets enumerateMatchesInString:attributedString.string
options:0
range:NSMakeRange(0, attributedString.length)
usingBlock:block];
}
2. 改行位置の決定
前述のとおり、行頭の約物処理や、行間の設定しやすさから自前で実装していきます。 行頭の約物処理は主に前括弧が行頭にきた場合、空きを半角分詰めます。
まず先ほど設定した、AttributedStringで、CoreTextでの描画でする際に元となるCTTypesetterRefを作成します。
CTTypesetterRef typesetter = CTTypesetterCreateWithAttributedString((CFAttributedStringRef)attributedString);
CTTypesetterSuggestLineBreakは、行幅と行の開始位置を渡すことで、1行に何文字入るか返してくれます。
CFIndex count = CTTypesetterSuggestLineBreak(typesetter, location, width);
これを繰り返すことで、文字列全ての改行位置を決めて行きます。 ループ内で改行を決めながら描画処理をすることも可能ですが、後で改行位置の調整をしたい場合を考え、改行位置と行頭の開始位置(x)のオフセットを配列に保存しておきます。
// lines : 改行位置を記録するNSMutableArray
// lineOffsets : 行頭の開始位置のオフセットを記録するNSMutableArray
// width : 一行の幅
NSInteger location = 0;
NSInteger length = attributedString.length;
while (location < length){
// 行頭の文字を取得
NSString *linehead = [attributedString.string substringWithRange:NSMakeRange(location, 1)];
float offset;
// 先頭の文字が前括弧だったら、改行位置を半角分のオフセットを設定。
// ※この例では一種類の括弧だけ判定していますが、前述の複数の前括弧を正規表現にかけます。
if([linehead isEqualToString:@"「"]) {
offset = fontSize / 2;
}else{
offset = 0;
}
[lineOffsets addObject:[NSNumber numberWithFloat:offset]];
CFIndex count = CTTypesetterSuggestLineBreak(typesetter, location, width + offset);
[lines addObject:[NSValue valueWithRange:NSMakeRange(location, count)]];
location += count;
}
3. 描画処理
AttributedStringからCTTypesetterRefの生成と、改行位置の確定がおわった所で描画処理に移ります。
まず、このままCoreTextで描画を始めると反転してしまうので、Matrixを設定します。
//contex : CGContextRef
CGContextSetTextMatrix(context, CGAffineTransformMakeScale(1, -1));
// 描画開始位置を設定
CGContextSetTextPosition(context, x, y);
描画の開始位置を設定し、typesetterから行を生成して描画します。
// lineHeight : 行送り
NSInteger limit = lines.count;
for (int i = 0; i < limit; i++) {
// 指定位置をベースライン[*1]として描画が開始されるので、1行目は文字サイズ分、
// 2行目以降は行送り分、yの開始位置を移動します。
if(i==0){
y += fontSize;
}else{
y += lineHeight;
}
// 改行位置の取得
NSRange r = [[lines objectAtIndex:i] rangeValue];
// 行頭のオフセットの取得
float offset = [[lineOffsets objectAtIndex:i] floatValue];
// 描画開始位置を設定。offsetで行頭約物の位置を修正。
CGContextSetTextPosition(context, x - offset, y);
// typesetterから行を生成
CTLineRef ctline = CTTypesetterCreateLine(typesetter, CFRangeMake(r.location, r.length));
// 描画
CTLineDraw(ctline, context);
// CTLineRefのリリース
CFRelease(ctline);
}
CoreTextを使った文字描画は以上です。
UILabelとの比較
独自実装の必要性
CoreTextを使って文字を扱うことを解説してきましたが、前述のように、iOS 6以上であれば、大抵のことをNSAttributedString + UILabel, UITextViewで実現でき、無理に独自の実装する必要もないように思われます。
また、描画を独自に実装する場合、テキストのコピーや編集、リンクやVoiceOverの対応など、iOSがもつ基本機能についても自前での実装が必要となるなど、別の課題も出てきます。
しかし、上の例でもわかるように、処理された物とそうでないものでは明らかな違いがあり、より多くのテキストの集まりではその差は顕著になるでしょう。
そして、地味ですが、こういったことの積み重ねが、より良いプロダクトの基礎体力となっていくのではないかと思っています。
そんなGunosyでは、プロダクトをさらに磨き上げてくれるエンジニア、デザイナーを随時募集しています。興味のある方は是非ご連絡ください!
参考
古いものもありますが、CoreTextを調べてたころに参考にした物です。今では日本語の解説も増えているようです。
なお、今回解説した描画処理のコードは近日公開予定です。
Text Programming Guide for iOS
Appleのドキュメントは基本。Core Text Programming Guide
繰り返し読みました。特にLower Level Text-Handling Technologies。あとSimple Text Inputの所は参考になります。以前、iOS用のコードエディタを作ったときにすごく助かった。Quartz 2D Programming Guide
ここも読んでおくとよいです。CoreTextPageViewer
サンプルコード。参考になります。Core Text Tutorial for iOS: Making a Magazine App
わかり易い記事。AttributedStringまわりの使い方は、ここを読んどくといいです。mattt/TTTAttributedLabel
最近読み始めました。とても参考になります。行頭の約物を処理しないのならこれを使います。文字の組方ルールブック〈ヨコ組編〉
文字の扱いについて、このての本や資料に目を通しておくといいと思います。