Core Text と遊んでみましょう

こんにちは、技術部モバイル基盤グループのヴァンサン(@vincentisambart)です。

この間、クックパッドの iOS アプリの開発で Core Text と色々遊んだので、今日は Core Text の話をしましょう。

課題は表示する文字の一部の裏に角丸長方形を表示することです。例えばクックパッド iOS アプリに表示されているリンクを長く押すと表示されている角丸長方形です。以下の画像は「落し蓋」に表示されるタッチフィードバックが見えます。区域を計算したら、その後タップ区域のためにも使えますしね。

f:id:vincentisambart:20160617074525p:plain

以下に説明するやり方はクックパッド iOS アプリのやり方を簡略化したものです。(クックパッド iOS アプリは実装時にまだ Swift を使い始めていなかったので Objective-C ですけども)

Swift Playground (Swift 2.2) で開発しましょう。コードは iOS 用ですが、少しいじれば OS X でも使えるはずです。スターティングポイントは下記の新しい画像に文字列を表示するコードです。

import UIKit
import CoreText

let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()

Playground上で画像の表示をインラインに表示できて便利です。コードが実行されたら画像が定義されている場所の右側に画像のサイズが表示されていて、マウスカーソルをその上に乗せたら右の一番端に表示されるボタンをクリックしたら画像がその下に表示されます。

f:id:vincentisambart:20160617074532p:plain

framesetter には色々な設定がありますが、それでも足りない人は CTTypesetterCreateWithAttributedStringCTTypesetterSuggestLineBreakCTTypesetterCreateLine で自分で行の列を作れます。

Core Foundation

開発を続けるには Core Text が返している Core Foundation の型(CFArrayCFRange)を扱わなければいけないけど、Swift だと少し扱いづらいですね。もっと扱いやすくしましょう。

まず、 CFRange はシンプルな struct なので、そんなに扱いづらいわけではないけど、便利関数が少ないですね。 NSRange はそういう便利機能が色々あるので、簡単に変換できるようにしましょう。

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

折角 NSRange をいじっているなら、あとで使えそうな便利機能を1個追加しておきましょう。

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

CFRange は簡単でしたが、 CFArray を Swift の配列に変換するのが少しややこしいです。一番自然なやり方でやってみましょう。

let lines = CTFrameGetLines(frame) as! [CTLine]

上記のコードを実行するとクラッシュします。実は Swift の配列に変換する前に NSArray に変換すると動きます。

let lines = (CTFrameGetLines(frame) as NSArray) as! [CTLine]

じゃあそれを関数にしてみましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    return (sourceArray as NSArray) as! [T]
}

上記のコードは動きますが、Xcode 7.3では、間違っている警告「Cast from 'NSArray' to unrelated type '[T]' always fails」が出ます。そしてなぜか関数の中だと as NSArray がなくても動きます(警告まだ出ますけど)。

警告や謎キャストが好きじゃないので。代わりに CFArray の中身を新しい Swift 配列に入れるようにしましょう。

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

結局また乱暴なキャストが必要になりましたね…でも仕方ない気がします。

各文字がどこに表示されるのか

CTFrame には、表示されるグリフの位置が決まっています。ある座標がどの文字に一番近いのか教えてくれる関数(CTLineGetStringIndexForPosition)、または文字列のあるインデックスのy座標を教えてくれる関数(CTLineGetOffsetForStringIndex)があります。けれど、文字列の各文字がとっているスペースを直接教えてくれる関数がありません。英語、日本語、だけを対象にするなら CTLineGetOffsetForStringIndex で簡単にできそうですが、もっと複雑な言語にも対応したければそんなに簡単にいきません。簡単じゃないとはいえ、そこまで複雑でもありません。

詳しい説明は公式Core Text紹介でご覧になれますが、軽く説明しましょう。CTFrame に何が入っているのかといいますと、単に CTLine(行) のリストです。「行」というのは "\n" で句切らているパラグラフではなく、表示の1行1行です。

そして各行が CTRun で作られています。"run" がフォント、フォントサイズ、方向、が同じなグリフのリストです。気をつけるべきなのは、元々指定されたフォントが同じでも、文字がフォントに存在していなければ、代わりに別のフォントが使われるので、別の"run"になるかもしれません。例えば、文字列が"Vincentと申します"とフォントがシステムフォント(San Francisco)の場合、2つのrunになります:

  • 「Vincent」のグリフが入ったrun (フォント:San Francisco)
  • 「と申します」のグリフが入ったrun (フォント:Hiragino Kaku)

求める長方形を探すには、各グリフを見て、そのグリフは選択されている文字から来ているのかを確認しましょう。

しかし、グリフがどの文字から来ているのか教えてくれる CTRunGetStringIndices という関数が少し使いづらいです。グリフごとに文字列の中の開始インデックスだけを返します。1つのグリフが複数の文字を使うことがあります。例えば合字(リガチャー)や結合文字列(combining character sequence)ですね。なので開始インデックスだけではなく、文字列の区域が必要ですね。

次のグリフの開始インデックスに1を引けば…と思った方いるかもしれませんが、残念ながらそんなにうまく行きません。アラビア語やヘブライ語だと 3, 2, 1, 0 になりますし、次のヒンディー語の文字列「हिन्दी」のCTRunGetStringIndicesの結果が:1, 0, 2, 5。並び順が予測できません。

では、各文字で開始インデックスに一番近いインデックスを探すコードを書きましょう。

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

実装

必要な物が揃ったので実装してみましょう。最初に書いたコードの上に上記のextensionや関数を入れて、 let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil) の下に以下のコードを入れましょう。(全部が入ったコードはこの記事の一番下にも入れておきます。)

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}

表示は CTFrameDrawの上に以下のコードを入れましょう。

UIColor.cyanColor().setFill()
for zone in zonesFound {
    let roundedRectanglePath = UIBezierPath(roundedRect: zone, cornerRadius: 4)
    roundedRectanglePath.fill()
}

f:id:vincentisambart:20160617074536p:plain

最後に

一番重要な処理はやってありますけど、改善の余地はありますね。

まず、制限が1つあります:roundedRectangleRanges が合字や結合文字列の真ん中で始まる/終わる場合、グリフ全体の後ろに角丸長方形を描くことになります。それが問題になるかどうかは使う場所次第ですね。

あと、一番上のスクリーンショットみたいに、複数行に渡る時は行末や行頭では角丸にしない方がいいかもしれません。クックパッド iOS アプリでやっていて、そこまで難しくないけど記事が既に十分長いです(笑)。

全コードまとめ

import UIKit
import CoreText

extension NSRange {
    // CFRangeからNSRangeを作れるようにします。
    init(_ range: CFRange) {
        self.init(location: range.location, length: range.length)
    }
}

extension CFRange {
    // NSMaxRangeのCFRange版です。
    var max: CFIndex {
        return location + length
    }
}

// 渡されるrangeが重なるかどうか。
func rangesOverlap(range1: NSRange, _ range2: NSRange) -> Bool {
    return NSIntersectionRange(range1, range2).length != 0
}

func toArray<T>(sourceArray: CFArray) -> [T] {
    var destinationArray = [T]()
    let count = CFArrayGetCount(sourceArray)
    destinationArray.reserveCapacity(count)
    for index in 0..<count {
        let untypedValue = CFArrayGetValueAtIndex(sourceArray, index)
        let value = unsafeBitCast(untypedValue, T.self) // 😅
        destinationArray.append(value)
    }
    return destinationArray
}

func stringRangesPerGlyph(run: CTRun) -> [NSRange] {
    let runEndIndex = CTRunGetStringRange(run).max
    let glyphCount = CTRunGetGlyphCount(run)

    var stringIndices = Array<CFIndex>(count: glyphCount, repeatedValue: 0)
    CTRunGetStringIndices(run, CFRange(location: 0, length: glyphCount), &stringIndices)

    return stringIndices.map { glyphStartIndex in
        // glyphStartIndexより大きいけど一番近いインデックスを探します。
        var glyphEndIndex = runEndIndex
        for comparedIndex in stringIndices {
            if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
                glyphEndIndex = comparedIndex
            }
        }
        // 因みに let glyphEndIndex = stringIndices.filter({ $0 > glyphStartIndex }).sort().first ?? runEndIndex でもいけます。

        return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
    }
}

// 2つのCGRectが水平にすぐ隣なのかどうか。
func rectAdjacentHorizontally(rect1: CGRect, _ rect2: CGRect) -> Bool {
    if rect1.intersects(rect2) {
        return true
    }
    if abs(rect1.maxX - rect2.minX) <= 1.0 {
        return true
    }
    if abs(rect1.minX - rect2.maxX) <= 1.0 {
        return true
    }
    return false
}


let availableWidth: CGFloat = 200 // 文字表示に使える幅
let text = "français 日本語 हिन्दी English\n\n言語混ぜるのっておもしろくない?"
let font = UIFont.systemFontOfSize(20)
// kCTFontAttributeNameの代わりにNSFontAttributeNameも使えるけど、CocoaとCore Textのattributesは違いあるので、間違いを防ぐためにCore Textのを使いましょう。
let attributes = [
    kCTFontAttributeName as String: font,
    // ユーザーの言語が何であろうと、漢字は日本語フォントを優先に使ってほしいですね。
    kCTLanguageAttributeName as String: "ja",
]
// Core Textの説明書がCFAttributedStringを使うけど、NSAttributedStringとCFAttributedStringがtoll-free bridgedなので、もっと使いやすいNSAttributedStringを使います。
let attributedString = NSAttributedString(string: text, attributes: attributes)
// Core Textのframesetterというのは文字列を指定される形の中にレイアウトするツールです。
let framesetter = CTFramesetterCreateWithAttributedString(attributedString)
// 表示に必要な高さを計算します。
let frameSize = CTFramesetterSuggestFrameSizeWithConstraints(framesetter, CFRange(), nil, CGSize(width: availableWidth, height: CGFloat.max), nil)
let bounds = CGRect(origin: CGPoint.zero, size: frameSize)
// 文字列を長方形の中に表示したいだけですね。
let path = CGPathCreateWithRect(bounds, nil)
let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)

let roundedRectangleRanges = [
    NSRange(location: 10, length: 5),
    NSRange(location: 31, length: 3),
]

let lines: [CTLine] = toArray(CTFrameGetLines(frame))
var lineOrigins = Array<CGPoint>(count: lines.count, repeatedValue: CGPoint.zero)
CTFrameGetLineOrigins(frame, CFRange(location: 0, length: lines.count), &lineOrigins)

var rectsFound = [CGRect]()

for (lineIndex, line) in lines.enumerate() {
    let lineRange = NSRange(CTLineGetStringRange(line))
    let runs: [CTRun] = toArray(CTLineGetGlyphRuns(line))

    var ascent: CGFloat = 0
    var descent: CGFloat = 0
    CTLineGetTypographicBounds(line, &ascent, &descent, nil)
    let lineOrigin = lineOrigins[lineIndex]
    let lineMinY = lineOrigin.y - descent
    let lineMaxY = lineOrigin.y + ascent

    for roundedRectangleRange in roundedRectangleRanges {
        if !rangesOverlap(roundedRectangleRange, lineRange) {
            continue // この長方形がこの行に入りません。
        }

        var rectsForRangeOnCurrentLine = [CGRect]()

        for run in runs {
            let runRange = NSRange(CTRunGetStringRange(run))
            if !rangesOverlap(roundedRectangleRange, runRange) {
                continue // この長方形がこのrunに入りません。
            }

            let glyphCount = CTRunGetGlyphCount(run)
            let allGlyphs = CFRange(location: 0, length: glyphCount)
            let stringRanges = stringRangesPerGlyph(run)
            var positions = Array<CGPoint>(count: glyphCount, repeatedValue: CGPoint.zero)
            CTRunGetPositions(run, allGlyphs, &positions)
            var advances = Array<CGSize>(count: glyphCount, repeatedValue: CGSize.zero)
            CTRunGetAdvances(run, allGlyphs, &advances)

            // 以前に説明したとおり、グリフが並び順通りじゃないから、まずはグリフ1つずつ長方形を作ります。
            for (glyphIndex, stringRange) in stringRanges.enumerate() {
                if !rangesOverlap(roundedRectangleRange, stringRange) {
                    continue
                }
                let x1 = positions[glyphIndex].x
                let x2 = positions[glyphIndex].x + advances[glyphIndex].width
                let minX = min(x1, x2)
                let maxX = max(x1, x2)
                let rect = CGRect(x: minX, y: lineMinY, width: maxX - minX, height: lineMaxY - lineMinY)
                rectsForRangeOnCurrentLine.append(rect)
            }
        }

        // このrangeのすぐ隣の長方形をくっつけます。
        rectsForRangeOnCurrentLine.sortInPlace { $0.minX < $1.minX }
        var mergedRects = [CGRect]()
        for rect in rectsForRangeOnCurrentLine {
            var rectToAppend = rect
            if let previousRect = mergedRects.last {
                if rectAdjacentHorizontally(previousRect, rect) {
                    mergedRects.removeLast()
                    let minX = min(previousRect.minX, rect.minX)
                    let maxX = max(previousRect.maxX, rect.maxX)
                    rectToAppend.origin.x = minX
                    rectToAppend.size.width = maxX - minX
                }
            }
            mergedRects.append(rectToAppend)
        }
        rectsFound.appendContentsOf(mergedRects)
    }
}


// 新しい画像を描き始めます。
UIGraphicsBeginImageContextWithOptions(frameSize, true, 2.0)
if let context = UIGraphicsGetCurrentContext() {
    // 背景を白で塗ります。
    CGContextSetRGBFillColor(context, 1.0, 1.0, 1.0, 1.0)
    CGContextFillRect(context, bounds)

    // 何もしないとCore Textの表示が逆さまなので正しい方向に表示されるようにします。
    CGContextTranslateCTM(context, 0, frameSize.height)
    CGContextScaleCTM(context, 1.0, -1.0)

    UIColor.cyanColor().setFill()
    for rect in rectsFound {
        let roundedRectanglePath = UIBezierPath(roundedRect: rect, cornerRadius: 4)
        roundedRectanglePath.fill()
    }

    // 文字の表示です。
    CTFrameDraw(frame, context)
}
// 描いた画像を出します。
let image = UIGraphicsGetImageFromCurrentImageContext()
// 描き終えます。
UIGraphicsEndImageContext()