[わ]

[try! Swift Tokyo 2026] ふりがな Deep Dive

おはようございます。watura です。 try! Swift Tokyo 2026 でLTをしてきました。
5min という時間があまりにも短すぎたので、記事にしてみました。

まずは,スライドです。

try! Swift Tokyo

try! Swift Tokyo Swiftを使った開発のコツや最新の事例を求めて 世界中から開発者が集います。 日頃のSwiftの知識やスキルを披露し、協 tryswift.jp

try! Swiftは立川ステージガーデン開催なので、立川近辺在住の私にとってめっちゃ近くて最高!というかんじです。
逆に近すぎて電車でいくと、チャリで行くよりめっちゃ時間がかかってしまう。でも、飲酒運転はだめだしね。。。みたいな距離です。

というわけで、CfPで「ふりがな Deep Dive」と題して,日本語のルビ表現をText Rendererで実装したよ!というLTを出したところ採択されました。
他の発表者がみなすごい人たちなので、やっばいぞ!!!という感じで準備しています。

なお、LT5分なんかにおさまらないよ。という長さの内容なので、がっつり記事にしてみました。

TALES まもなく1周年

note は2025年4月ごろにTALESという物語投稿サイトを公開しました。

小説が無料で読める物語投稿サイト TALES(テイルズ) ここでは、誰でも、自身が創作した物語を投稿することができます。誰でも、投稿された物語を読み、楽しむことができます。そんな、 tales.note.com

2026/04/13現在、この記事で語る「ふりがな」描画はTALES iOSアプリで使われています。
さくさく作品を読めるのでぜひ使っていろいろな作品を読んでください。

小説が読める・投稿できる物語投稿サイトTALES(テイルズ)アプリ - App Store note inc. (Tokyo)の「小説が読める・投稿できる物語投稿サイトTALES(テイルズ)」をApp S apps.apple.com

TALES iOS App

といった構成でアプリを作りました。

ほぼFullSwiftUI

リリース時点では大きく2箇所UIKitをUIViewRepresentableに包んでいた箇所がありました。
現在はこの記事に書く方法を使ったので、1箇所です。

この2箇所です。エディタに関してはSwiftUIは全くといっていいほど、まともにサポートされておらず、UITextView一択でした。

ビューワーに関してもn万文字を1つのTextで表示するぜ!とかとすると全く耐えられない状態で、こちらもUITextViewを使って表示するというようなことをしていました。

そういったパフォーマンス周りでなかなかSwiftUIだめだね。となった以外に、Textの装飾能力の弱さというものがありました。

それが、今回のルビです。ルビとはこのように、親文字のそばに小さな文字が書かれている日本語の表現法のひとつです。

など、多様な使われ方をしています。TALESに投稿されるような物語には必須の機能です。

なんと、これがSwiftUIでは描画できないし、UIKitでもCoreTextの機能を呼び出せばなんとかなるという感じです。

UIKitでのやり方

CTRubyAnnotationCreate

CTRubyAnnotationCreate(_:_:_:_:) | Apple Developer Documentation Creates an immutable ruby annotation object. developer.apple.com

CTではじまる→CoreText です。
NSAttributedStringのAttributedKeyではなく、CoreTextのAttributeを指定してUITextViewなどに与えると表示されます。

(_:_:_:_:) となっているように、結構細かいことをしっかりと指定できる優れたAnnotationとなっています。

import CoreText

let ruby = "物語投稿サイト"
let baseText = "TALES"

let rubyAttribute: [CFString: Any] = [
    kCTRubyAnnotationSizeFactorAttributeName: 0.5
]

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

let attributedString = NSAttributedString(
    string: baseText,
    attributes: [
        kCTRubyAnnotationAttributeName as NSAttributedString.Key: annotation
    ]
)

NSAttributedStringなので、UITextViewでもUILabelでもいけるます。
UIKitの世界が使えるならば、ふりがな描画はこれを使うのが最も安定しており、おすすめです。
しかも、オプションで細かく設定もできます。
UIKitでやりましょう!

ただ、今回はSwiftUIのScrollViewの中にVStackをいれ、スクロールをdisabledにしたUITextViewをいれていました。N万文字、太字やルビ、傍点ありみたいな文章を表示するともっさりしていました。
また、スクロール量から読書の進捗測りたいな!みたいな要望や、スクロールに合わせてナビゲーションバー、タブを非表示にしたい。みたいな対応をさせていくと、救いがないほどもっさりしました。

その上、残念なことにNSAttributedStringからAttributedStringに変換すると、キレイさっぱりこのAnnotationが付与されていたことを忘れてしまいます。

というわけで、SwiftUIでの実装を調査しました。

達成したいこと

ふりがなをW3Cなどで定義されているレベルまで対応しようとすると、TALESではオーバースペックすぎるのでシンプルに表示するというところで止めておきました。

一番シンプルなパターン

struct ContentView: View {
    var body: some View {
        VStack(alignment: .center) {
            Text("物語投稿サイト")
                .font(.caption2)
            Text("TALES")
        }
    }
}

表示パターンがかぎられており、例外的なことも発生しないみたいなパターンであればTextをかさねるみたいなことでなんとかできなくはないです。

なにをどうかんがても、このシンプルパターンで十分ですとはならないので、他の方法をためしました。

TextRenderer

TextRendererはTextに付与するものです。どのようにTextを描画するかというのを変更できるstructになります。

struct CustomTextRenderer: TextRenderer {
    func draw(layout: Text.Layout, in ctx: inout GraphicsContext) {
        for line in layout {
            ctx.draw(line)
        }
    }
}

なにもしないで行を表示するだけのサンプル

try! Swift Tokyo 2025

ワードアートがつくれる Text Renderer ですが描画をいじれるのならば、**一段上に文字いれるくらい余裕じゃない?**という想像で実装をはじめました。
なお、今回の使い方は、本当にTextRendererでやるべきことか?というと :thinking_face: ってなるような内容ではあります。

Ruby Attributeを定義します。よみがな文字列とidをもつ構造体とします。

struct RubyAttribute: TextAttribute {
    /// ルビ全体の読み仮名文字列。
    let fullReading: String
    /// 同一ルビを識別するための一意なID。
    let rubyID = UUID()
}

つかうときは、ふりがなを付与したい文字をTextとして取り出して,customAttributeで指定します。

  let text = Text("小説が無料で読める")
    + Text("TALES")
          .customAttribute(
             RubyAttribute(fullReading: "物語投稿サイト")
          )
    + Text("もうすぐ1周年")

RubyAttributeのrubyID

画面に表示されたときに、画面端で改行されてしまったときにどうする?という問題を解決するためにいれています。
Text Rendererでは基本的には一行ごと処理をするので、

public func draw(layout: Text.Layout, in context: inout GraphicsContext) {
    for (lineIndex, line) in layout.enumerated() {
        for run in line {
            if let ruby = run[RubyAttribute.self] {
                // ふりがな
                // 
            }
        }
    }
}

一行だけで定義されている内容が、無理やり改行されてしまったのか、

Text("TALES")
          .customAttribute(
             RubyAttribute(fullReading: "物語投稿サイト")
          )

最初からわけてある

Text("TAL")
          .customAttribute(
             RubyAttribute(fullReading: "物語投稿サイト")
          )
Text("ES")
          .customAttribute(
             RubyAttribute(fullReading: "物語投稿サイト")
          )

のかわからず、同じ描画が繰り返されてしまうという問題があります。

なので、draw処理内で描画したRubyAttribute情報を保持しておき、何文字描画するか・したか計算できるようにするためにrubyIDというものを持たせています。
その結果、TALの上に物語投稿、ESの上にサイトと描画できるのです。
(実際には親文字が何文字だから、何文字描画して。。。みたいな計算もしていますが)

描画位置の調整

ふりがなの文字列が親文字列より短いときどう描画しますか?
CTRubyAnnotationであれば、中央寄せ、均等割、先頭寄せ、末尾寄せなど選べたりします。
今回は、サンプルとして均等割で作ってみました。

親文字全体、ふりがな全体の幅を計算します

let resolved = context.resolve(Text(fullReading).font(.system(size: fontSize)))
let totalGlyphWidth = resolved.measure(in: CGSize(width: .max, height: .max)).width

こういうコードで計算させています。
ResolvedTextのmeasureをつかうと必要なサイズがわかるので、そのサイズをつかって、親文字の幅,ふりがなの幅を計算しています。
幅を計算するだけなので、次のようなふりがなの長さが親文字より短い場合という処理にもつかっています。
なにも考えずに描画すると、このように、左右がちょんぎられてしまい読めなくなります。

正しい表示

親文字の左右に余白をいれ、ふりがなを全部表示できるようにします。
ただ、すこし、TextRendererをつかう方法では解決できない問題がありました。ざっくりこういう感じの処理をしています。

⏺ -- ルビの配置間隔を決める --

  if ルビがはみ出す:
    ルビ同士は最小間隔で詰めて並べる
    親文字を均等に広げて合わせる
  else:
    余白をルビの両端と文字間に均等分配する

  -- 描画 --

  ルビを等間隔に描画(start + step × i)

  親文字を描画:
    if ルビがはみ出す:
      各文字を均等にずらして描画
      最後の文字まで描いたら後続テキストのオフセットを加算
    else:
      そのまま描画

ただ、ふりがなによって必要となる幅が大幅にふえてしまった場合に、文字が溢れてしまうという問題があります。
こういう文字ですね。 TALESこれが複数並んでいるときとかに、溢れてしまうことがあります。 TALES
これは、TextRendererがレイアウトを調整するようなものではないため、横にずらして描画するくらいしかできないためです。

TextLayoutをいい感じに弄れる仕組みができればいいのになと思っています。

RubyAttributeを設定するときに、強制的に空白や幅ゼロ不可視文字をつっこんで、

といった、地道な変更をしてごまかすことももちろん可能です。
日本語という形態素解析とかしていかないと文節、単語区切りができない言語のため、ユーザーさんにマークアップしてもらったものを利用するというのが一番安定する方法なのではないかと思います。

さて、達成したいことのうち、

これらを解決しました。

ルビのある分だいたいViewの高さが調整されるは、走査したときにoffsetをいれたり、理想heightを返却するようにしたりするという感じでできます。
簡単です。


try! Swift で発表したあと、いろいろなセッションをかんがえながら、かんがえていたら、文字列の途中で改行対応とかもっとカイゼンできる点が思いついてきたりしたので、時間がとれたらもっと安定して動作するものにできるのではないかと思います。


はい。この記事で覚えてほしいことは、物語投稿サイトTALESは2026年4月22日で1周年を迎えます。

小説が無料で読める物語投稿サイト TALES(テイルズ) ここでは、誰でも、自身が創作した物語を投稿することができます。誰でも、投稿された物語を読み、楽しむことができます。そんな、 tales.note.com


もっと具体的な全部のコード?
Claude Code とかCodexにいったら、きっといい感じなのをつくってくれるし、細かい調整をやってくれると思うのでその方がいいよ!

#note