Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

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

negi0205
i-enter
「効果」をつねに提供します。スマホアプリ開発No.1の実績。最新のIoTに対応した開発も行います。
https://www.i-enter.co.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした