search
LoginSignup
9
Help us understand the problem. What are the problem?

More than 3 years have passed since last update.

アイエンター #1 Advent Calendar 2018 Day 10

posted at

updated at

【Swift4】CTRubyAnnotationとNSAttributedStringでルビを振る

はじめに

NSAttributedStringを利用したルビの実装についてです。
以下の記事を参考にさせていただきました。
https://qiita.com/woxtu/items/284369fd2654edac2248
こちらに則って、青空文庫形式での実装になります。
例:「デーモン|小暮閣下《こぐれかっか》」

ルビ判別のための正規表現

青空文庫形式自体を判別するための正規表現をextensionで実装しました。
ほぼ参考記事のままとなります。

String+Ruby.swift
func find(pattern: String) -> NSTextCheckingResult? {
    do {
        let findRubyText = try NSRegularExpression(pattern: pattern, options: [])
        return findRubyText.firstMatch(
            in: self,
            options: [],
            range: NSMakeRange(0, self.utf16.count))
    } catch {
        return nil
    }
}

func replace(pattern: String, template: String) -> String {
    do {
        let replaceRubyText = try NSRegularExpression(pattern: pattern, options: [])
        return replaceRubyText.stringByReplacingMatches(
            in: self,
            options: [],
            range: NSMakeRange(0, self.utf16.count),
            withTemplate: template)
    } catch {
        return self
    }
}

ルビ付きのNSAttributedStringを作る

ルビ付きのNSAttributedStringを作る際は、
KeyがkCTRubyAnnotationAttributeName、ValueがCTRubyAnnotationattributeを追加する必要があります。
https://developer.apple.com/documentation/coretext/ctrubyannotation
今回はStringのextensionとして実装しています。
こちらもほぼ参考記事のままとなっています。

String+Ruby.swift
func rubyAttributedString(font: UIFont, textColor: UIColor) -> NSMutableAttributedString {
    let attributed =
        self.replace(pattern: "(|.+?《.+?》)", template: ",$1,")
            .components(separatedBy: ",")
            .map { x -> NSAttributedString in
                if let pair = x.find(pattern: "|(.+?)《(.+?)》") {
                    let baseText = (x as NSString).substring(with: pair.range(at: 1))
                    let ruby = (x as NSString).substring(with: pair.range(at: 2))

                    let rubyAttribute: [AnyHashable: Any] = [
                        kCTRubyAnnotationSizeFactorAttributeName: 0.5,
                        kCTForegroundColorAttributeName: textColor
                    ]

                    let annotation = CTRubyAnnotationCreateWithAttributes(.auto, .auto, .before, ruby as CFString, rubyAttribute as CFDictionary)

                    return NSAttributedString(
                        string: baseText,
                        attributes: [.font: font,
                                     .foregroundColor: textColor,
                                     kCTRubyAnnotationAttributeName as NSAttributedString.Key: annotation])

                } else {
                    return NSAttributedString(
                        string: x,
                        attributes: [.font: font,
                                     .foregroundColor: textColor]
                    )
                }
            }
            .reduce(NSMutableAttributedString()) { $0.append($1); return $0 }

    return attributed
}

処理の流れとしては、
文字列をルビのあるものとそうでないもので分けて、それぞれmap内でNSAttributedStringとし、
最後にreduceで結合しているような流れです。

CTRubyAnnotation

CTRubyAnnotationCTRubyAnnotationCreateWithAttributesで生成しています。
色々な記事を見る限り、以前まではAttributedString.foregroundColorに指定されている文字色がルビにも適用されていたようなのですが、
今回実装してみたところ適用されていなかったため、CTRubyAnnotationCreateWithAttributeskCTForegroundColorAttributeNameを利用してルビに色を適用しています。
Attributeで指定することが分かるまでにそこそこ詰まりました。。

let rubyAttribute: [AnyHashable: Any] = 
    [
        kCTRubyAnnotationSizeFactorAttributeName: 0.5,
        kCTForegroundColorAttributeName: textColor
    ]

let annotation = CTRubyAnnotationCreateWithAttributes(.auto, 
                                                      .auto, 
                                                      .before, 
                                                      ruby as CFString, 
                                                      rubyAttribute as CFDictionary)

それぞれのプロパティ概要は以下の通りです。

CTRubyAnnotationRef CTRubyAnnotationCreateWithAttributes(
    CTRubyAlignment alignment,// ルビとテキストの長さが違う場合の設定の仕方
    CTRubyOverhang overhang,// ルビがテキストより長い場合に他テキストの上にまで表示されて良いか
    CTRubyPosition position,// テキストに対するルビの位置
    CFStringRef string,// 文字列
    CFDictionaryRef attributes )// 付加情報(サイズや文字色等..)

ルビをUILabelに適用する

ルビの高さの分描画してあげる必要があるので、UILabelを継承したカスタムクラスを作成しました。

RubyLabel.swift
class RubyLabel: UILabel {

    override func draw(_ rect: CGRect) {
        guard let context = UIGraphicsGetCurrentContext(),
            let attributed = self.attributedText else { return }

        context.textMatrix = CGAffineTransform.identity
        context.translateBy(x: 0, y: rect.height)
        context.scaleBy(x: 1.0, y: -1.0)

        let frame = CTFramesetterCreateFrame(CTFramesetterCreateWithAttributedString(attributed),
                                             CFRangeMake(0, attributed.length),
                                             CGPath(rect: rect, transform: nil),
                                             nil)

        CTFrameDraw(frame, context)

    }

    override var intrinsicContentSize: CGSize {
        let baseSize = super.intrinsicContentSize

        return CGSize(width: baseSize.width,
                      height: baseSize.height * 1.5)

    }

}

drawしてあげただけだと下部分が切れてしまうため、
intrinsicContentSizeをoverrideしてルビの高さ分を加算しています。

スクリーンショット 2018-12-08 18.29.41.png

ルビが表示されました!

ルビのないラベルと高さを合わせる

ルビの分下にずれてしまうのでベースのテキストの位置が合うようにinsetsを噛ませました。

RubyLabel.swift
override func draw(_ rect: CGRect) {
    guard let context = UIGraphicsGetCurrentContext(),
        let attributed = self.attributedText else { return }

    let newRect = rect.inset(by: UIEdgeInsets(top: self.font.pointSize / 2, left: 0, bottom: 0, right: 0))


    context.textMatrix = CGAffineTransform.identity
    context.translateBy(x: 0, y: newRect.height)
    context.scaleBy(x: 1.0, y: -1.0)

    let frame = CTFramesetterCreateFrame(CTFramesetterCreateWithAttributedString(attributed),
                                         CFRangeMake(0, attributed.length),
                                         CGPath(rect: newRect, transform: nil),
                                         nil)

    CTFrameDraw(frame, context)

}

override var intrinsicContentSize: CGSize {
    let baseSize = super.intrinsicContentSize

    return CGSize(width: baseSize.width,
                  height: baseSize.height * 1.5 + self.font.pointSize / 2)

}

おわりに

コード全体はこちらになります。
https://github.com/y-negi/RubySample

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
9
Help us understand the problem. What are the problem?