Gunosy Tech Blog

Gunosyの開発メンバーが知見を共有するブログです。

iOSで文字を組む

こんにちは。今回はデザイナーの森が担当します。iOSでの文字組について。

普通の文字組

iOSで文字を扱う場合、以前はわりと不自由な環境でしたが、iOS 6で、属性付きの文字列を扱えるNSAttributedStringを、UITextViewUILabelで使えるようになりかなり進歩してきました。

しかし、印刷物で普通に行われている文字組のルールのうち、最低限必要と思われる物のいくつかはまだ実装されておらず、もう少し実装の工夫が必要なようです。

では、普通な文字組をするには何が必要でしょう? 私は、次にあげる5つがあれば可能だと考えています。

f:id:cou_z:20170617214112p:plain

それでは、UILabel, UITextViewでの状況はどうでしょう。

1. 文字の大きさが設定できる

可能。

2. 行送り(行間)が設定できる

行と行の間隔。行送りはベースライン)から次のベースラインまでの距離。行間は行の下から次の行の上までの距離。 NSAttributedStringとNSParagraphStyleで可能(iOS 6〜)。

3. 禁則処理ができる

可能。

4. 連続約物の処理ができる

f:id:cou_z:20170617214343p:plain

約物の多く(括弧、句読点など)は、半角分の文字の前か後ろに半角分の空きを持っています。通常、日本語の組版では、これらの約物が連続した場合、空きを詰めて余分な空きができるのを防ぎます。 iOSでは、この処理に関してのAPIは用意されていませんが、NSAttributedStringと[NSKernAttributeName][*NSKernAttributeName]を使って、文字間を詰めることで可能です(iOS 6〜)。

5. 行頭、行末での約物の処理ができる

f:id:cou_z:20170617214403p:plain

行頭に、前括弧など、前に空白を持つ約物がきた場合、半角分行頭を詰め、他の行と左側(縦書きの場合は上)を揃えます。 行末の句読点のぶら下がりをしないのであれば、行頭の約物の処理だけ考えればいいでしょう。 この処理もAPIは用意されていません。そして、連続約物の処理と違って、UILabelなどの上位レベルのAPIでの対応は難しく、CoreTextCoreGraphicsといった、より低レベルの描画APIでの実装が必要となってきます。

それでは、今回はCoreTextを使って、上記の5つの機能を持った文字描画コードを実装していきます。

文字を組んで描画するまで

文字を描画する手順は以下のとおりです。

  1. 文字の属性を設定
  2. 改行位置の決定
  3. 描画処理

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で設定していきます。

f:id:cou_z:20170617214831p:plain

下記は、後括弧と前括弧の組み合わせを詰める場合の例です。

// 正規表現
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. 改行位置の決定

前述のとおり、行頭の約物処理や、行間の設定しやすさから自前で実装していきます。 行頭の約物処理は主に前括弧が行頭にきた場合、行の開始位置を半角分前に移動することで行頭の約物の位置を揃えます。

f:id:cou_z:20170617214907p:plain

まず先ほど設定した、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);
}
[*NSKernAttributeName]: https://developer.apple.com/library/ios/documentation/UIKit/Reference/NSAttributedString_UIKit_Additions/Reference/Reference.html#//apple_ref/doc/uid/TP40011688-CH1-SW16 “NSAttributedString UIKit Additions Reference  Character Attributes”

CoreTextを使った文字描画は以上です。

UILabelとの比較

f:id:cou_z:20170617215016p:plain

独自実装の必要性

CoreTextを使って文字を扱うことを解説してきましたが、前述のように、iOS 6以上であれば、大抵のことをNSAttributedString + UILabel, UITextViewで実現でき、無理に独自の実装する必要もないように思われます。

また、描画を独自に実装する場合、テキストのコピーや編集、リンクやVoiceOverの対応など、iOSがもつ基本機能についても自前での実装が必要となるなど、別の課題も出てきます。

しかし、上の例でもわかるように、処理された物とそうでないものでは明らかな違いがあり、より多くのテキストの集まりではその差は顕著になるでしょう。

そして、地味ですが、こういったことの積み重ねが、より良いプロダクトの基礎体力となっていくのではないかと思っています。

そんなGunosyでは、プロダクトをさらに磨き上げてくれるエンジニア、デザイナーを随時募集しています。興味のある方は是非ご連絡ください!

余談

時々話題にあがる分かち書き改行ですが、iOS単体でもそれなりに可能です。 ご存知のようにCFStringTokenizerを使うことで、分かち書きの改行位置候補を取得することができます。 品詞などはわかりませんが、レイアウトをするには十分なデータを得ることができます。

CFStringTokenizerRef tokens = CFStringTokenizerCreate(kCFAllocatorDefault, 
                                                    text, 
                                                    CFRangeMake(0, text.length),
                                                    kCFStringTokenizerUnitWordBoundary, 
                                                    CFLocaleCopyCurrent());
                                                      
while(kCFStringTokenizerTokenNone != CFStringTokenizerAdvanceToNextToken(tokens)) {
  CFRange range = CFStringTokenizerGetCurrentTokenRange(tokens);
  // 得られたrangeをどうにかする
}

CSS3とHTML5で、美しい組版を実現するには、どうすればいいのでしょう?
CSS3, と, H, T, M, L, 5, で, 、, 美しい, 組, 版, を, 実現, する, に, は, 、, どう, すれ, ば, いい, の, でしょ, う, ?

ただ、ここから文字を組むには、禁則処理や単語の分割可否の判定など、独自に実装しなければならないことが増えてしまいますので、別の改行モード(kCFStringTokenizerUnitLineBreak)の区切り位置と組み合わせると、もう少し楽になります。

kCFStringTokenizerUnitLineBreak

CSS3, と, HTML5, で、, 美, し, い, 組, 版, を, 実, 現, す, る, に, は、, ど, う, す, れ, ば, い, い, の, で, しょ, う?

結果(kCFStringTokenizerUnitWordBoundary + kCFStringTokenizerUnitLineBreak)

CSS3, と, HTML5, で、, 美しい, 組, 版, を, 実現, する, に, は、, どう, すれ, ば, いい, の, でしょ, う?

だいぶ使いやすくなりました。 そして、これで前述のCTTypesetterSuggestLineBreakで得られる改行位置を補足すると、精度はそれほど高くないですが、分かち書きっぽい改行が可能です。

参考

古いものもありますが、CoreTextを調べてたころに参考にした物です。今では日本語の解説も増えているようです。 なお、今回解説した描画処理のコードは近日公開予定です。公開しました。

追記

  • 2014/05/20
    • 一部テキストを修正
    • 参考URLのコメントの間違いを修正。
    • 余談を追加
    • コードを公開