こんにちは、技術部モバイル基盤グループのヴァンサン(@vincentisambart)です。
この間、クックパッドの iOS アプリの開発で Core Text と色々遊んだので、今日は Core Text の話をしましょう。
課題は表示する文字の一部の裏に角丸長方形を表示することです。例えばクックパッド iOS アプリに表示されているリンクを長く押すと表示されている角丸長方形です。以下の画像は「落し蓋」に表示されるタッチフィードバックが見えます。区域を計算したら、その後タップ区域のためにも使えますしね。
以下に説明するやり方はクックパッド 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)
let attributes = [
kCTFontAttributeName as String: font,
kCTLanguageAttributeName as String: "ja",
]
let attributedString = NSAttributedString(string: text, attributes: attributes)
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)
CGContextTranslateCTM(context, 0, frameSize.height)
CGContextScaleCTM(context, 1.0, -1.0)
CTFrameDraw(frame, context)
}
let image = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
Playground上で画像の表示をインラインに表示できて便利です。コードが実行されたら画像が定義されている場所の右側に画像のサイズが表示されていて、マウスカーソルをその上に乗せたら右の一番端に表示されるボタンをクリックしたら画像がその下に表示されます。
framesetter には色々な設定がありますが、それでも足りない人は CTTypesetterCreateWithAttributedString
、 CTTypesetterSuggestLineBreak
、 CTTypesetterCreateLine
で自分で行の列を作れます。
Core Foundation
開発を続けるには Core Text が返している Core Foundation の型(CFArray
や CFRange
)を扱わなければいけないけど、Swift だと少し扱いづらいですね。もっと扱いやすくしましょう。
まず、 CFRange
はシンプルな struct
なので、そんなに扱いづらいわけではないけど、便利関数が少ないですね。 NSRange
はそういう便利機能が色々あるので、簡単に変換できるようにしましょう。
extension NSRange {
init(_ range: CFRange) {
self.init(location: range.location, length: range.length)
}
}
extension CFRange {
var max: CFIndex {
return location + length
}
}
折角 NSRange
をいじっているなら、あとで使えそうな便利機能を1個追加しておきましょう。
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
var glyphEndIndex = runEndIndex
for comparedIndex in stringIndices {
if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
glyphEndIndex = comparedIndex
}
}
return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
}
}
実装
必要な物が揃ったので実装してみましょう。最初に書いたコードの上に上記のextensionや関数を入れて、 let frame = CTFramesetterCreateFrame(framesetter, CFRange(), path, nil)
の下に以下のコードを入れましょう。(全部が入ったコードはこの記事の一番下にも入れておきます。)
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
}
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)
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)
}
}
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()
}
最後に
一番重要な処理はやってありますけど、改善の余地はありますね。
まず、制限が1つあります:roundedRectangleRanges が合字や結合文字列の真ん中で始まる/終わる場合、グリフ全体の後ろに角丸長方形を描くことになります。それが問題になるかどうかは使う場所次第ですね。
あと、一番上のスクリーンショットみたいに、複数行に渡る時は行末や行頭では角丸にしない方がいいかもしれません。クックパッド iOS アプリでやっていて、そこまで難しくないけど記事が既に十分長いです(笑)。
全コードまとめ
import UIKit
import CoreText
extension NSRange {
init(_ range: CFRange) {
self.init(location: range.location, length: range.length)
}
}
extension CFRange {
var max: CFIndex {
return location + length
}
}
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
var glyphEndIndex = runEndIndex
for comparedIndex in stringIndices {
if comparedIndex > glyphStartIndex && comparedIndex < glyphEndIndex {
glyphEndIndex = comparedIndex
}
}
return NSRange(location: glyphStartIndex, length: glyphEndIndex - glyphStartIndex)
}
}
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)
let attributes = [
kCTFontAttributeName as String: font,
kCTLanguageAttributeName as String: "ja",
]
let attributedString = NSAttributedString(string: text, attributes: attributes)
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
}
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)
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)
}
}
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)
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()