Cover Image for Swift UI で青空文庫形式でルビが振られた文章を縦書きで表示する

Swift UI で青空文庫形式でルビが振られた文章を縦書きで表示する

概要

Swift UI を使って 青空文庫編 【テキスト中に現れる記号について】 に定義された方式でルビが振られた文章を縦書きで表示する

出来上がったもの

プレビュー用のサンプルは 吾輩は猫である 冒頭の一文

import SwiftUI
import UIKit


extension String {

    private func createRuby(string: String, ruby: String, textAttributes: [NSAttributedString.Key: Any]) -> NSAttributedString {
        var unmanage = Unmanaged.passRetained(ruby as CFString)
        defer { unmanage.release() }
        var text: [Unmanaged<CFString>?] = [unmanage, .none, .none, .none]
        let annotation = CTRubyAnnotationCreate(.auto, .auto, 0.5, &text)
        let attributedString = NSMutableAttributedString(string: string,
                                                         attributes: [kCTRubyAnnotationAttributeName as NSAttributedString.Key: annotation])
        attributedString.addAttributes(textAttributes, range: NSRange(location: 0, length: string.count))
        return attributedString
    }

    func createRubyText(
        font: UIFont = UIFont(name: "HiraMinProN-W3", size: 18.0)!,
        textColor: UIColor = .black
    ) -> NSAttributedString {

        let style = NSMutableParagraphStyle()
        style.minimumLineHeight = 24
        style.maximumLineHeight = 24

        let textAttributes: [NSAttributedString.Key: Any] = [
                .font: font,
                .verticalGlyphForm: true,
                .paragraphStyle: style,
                .foregroundColor: textColor,
        ]

        let workingAttributedText = NSMutableAttributedString(string: self, attributes: textAttributes)

        // 名前付き正規表現は iOS11+ じゃないと無理です
        let rubyRegex = try! NSRegularExpression(pattern: "|(?<string>.+?)《(?<ruby>.+?)》", options: [])

        for result in rubyRegex.matches(in: self, options: [], range: NSRange(location: 0, length: self.count)).reversed() {
            guard
                let stringRange = Range(result.range(withName: "string"), in: self),
                let rubyRange = Range(result.range(withName: "ruby"), in: self)
                else {
                continue
            }

            let string = String(self[stringRange])
            let ruby = String(self[rubyRange])
            workingAttributedText.replaceCharacters(in: result.range,
                                                    with: createRuby(string: string, ruby: ruby, textAttributes: textAttributes))
        }

        return workingAttributedText
    }
}

struct TategakiText: View {
    var text: String

    var body: some View {
        Canvas { context, size in
            context.withCGContext(content: { cgContext in
                draw(context: cgContext, size: size)
            })
        }
    }

    func draw(context: CGContext, size: CGSize) {
        context.scaleBy(x: 1, y: -1)
        context.translateBy(x: 0, y: -size.height)
        let setter = CTFramesetterCreateWithAttributedString(text.createRubyText())
        let path = CGPath(rect: CGRect(origin: CGPointZero, size: size), transform: nil)
        let frameAttrs = [
            kCTFrameProgressionAttributeName: CTFrameProgression.rightToLeft.rawValue,
        ]
        let ct = CTFramesetterCreateFrame(setter, CFRangeMake(0, 0), path, frameAttrs as CFDictionary)

        CTFrameDraw(ct, context)
    }
}

#Preview {
    TategakiText(text: """
|吾輩《わがはい》は猫である。名前はまだ無い。
 どこで生れたかとんと|見当《けんとう》がつかぬ。何でも薄暗いじめじめした所でニャーニャー泣いていた事だけは記憶している。吾輩はここで始めて人間というものを見た。しかもあとで聞くとそれは書生という人間中で一番|獰悪《どうあく》な種族であったそうだ。この書生というのは時々我々を|捕《つかま》えて|煮《に》て食うという話である。しかしその当時は何という考もなかったから別段恐しいとも思わなかった。ただ彼の|掌《てのひら》に載せられてスーと持ち上げられた時何だかフワフワした感じがあったばかりである。掌の上で少し落ちついて書生の顔を見たのがいわゆる人間というものの|見始《みはじめ》であろう。この時妙なものだと思った感じが今でも残っている。第一毛をもって装飾されべきはずの顔がつるつるしてまるで|薬缶《やかん》だ。その|後《ご》猫にもだいぶ|逢《あ》ったがこんな|片輪《かたわ》には一度も|出会《でく》わした事がない。のみならず顔の真中があまりに突起している。そうしてその穴の中から時々ぷうぷうと|煙《けむり》を吹く。どうも|咽《む》せぽくて実に弱った。これが人間の飲む|煙草《たばこ》というものである事はようやくこの頃知った。
""")
}

String.createRubyText で 青空文庫形式のテキストをルビ付きの NSAttributedString にして、TategakiText の方で View として表示している。

名詠式 in Swift4 をベースに、SwiftUIでルビを振るために必要なこと #Swift - Qiita を参考にしながら、String の extension に分割したり、fontやcolorを指定できるようにした。 その後、SwiftUIでUIViewRepresentableを使わずCGContextに描く をベースに CGContextで描くようにした。

参考