Categories
Software

Furigana in SwiftUI (4)

This is part 4. The previous episode is here.

To quickly recap, we are now able to layout text that looks like this:

Hello aka Konnichi wa aka 今日は aka こんにちは
こんにち is furigana that tells readers how to pronounce 今日

We accomplish this by passing an array of (mainText, furigana) pairs into our container view.

But how can we generate this array? For each entry in our strings files we typically have a romaji version and a kana/kanji version. For example “To go” has a romaji version: “iku” and a kana/kanji version: “行く”

I ended up building a two step process:

  1. convert the romaji to hiragana
  2. ‘line up’ the hiragana only string with the kana/kanji string, and infer which hiragana represented the kanji

Aside: I originally imagined a markdown scheme to represent which furigana would decorate which text. Something like this: [[今日((こんいち))]][[は]]

I eventually realized this markdown format wasn’t adding any value, so instead I can generate the (hiragana, kana/kanji) pairs directly from the inputs from the strings files.

Romaji to Hiragana

In an acronym, TDD. Test driven design was essential to accomplishing this conversion. One of the bigger challenges here is the fact that there is some ambiguity in converting from romaji to hiragana. Ō can mean おお or おう. Ji can mean じ or ぢ. Is tenin てにん or てんいん?

I started using the following process:

  1. start with the last character, and iterate through to the first
  2. at each character, prepend it to and previously unused characters
  3. determine if this updated string of characters mapped to a valid hiragana
  4. if not, assume the previous string of characters did map to a valid hiragana and add it to the final result
  5. remove the used characters from the string of unused characters
  6. go to step 2 and grab the ‘next’ character

Consider the following example: ikimasu

  1. does u have a hiragana equivalent? yup: う
  2. grab another character, s. does su have a hiragana? yup: す
  3. grab another character, a. does asu have a hiragana? nope
  4. add すto our result string, and remove su from our working value
  5. does a have a hiragana? yup: あ
  6. grab the next romaji character, m. does ma have a hiragana? yup: ま
  7. etc.

There were other wrinkles that came up right away. They included

  • how to handle digraphs like kya, sho, chu (きゃ, しょ, ちゅ)
  • handling longer consonants like the double k in kekkon with っ

Handling these wrinkles often forced me to refactor my algorithm. But thanks to my ever growing collection of TDD test cases, I could instantly see if my courageous changes broke something. I was able to refactor mercilessly which was very freeing.

Writing this, I pictured a different algorithm where step 1 is breaking a string into substrings where each substring ends in a vowel. Then each substring could probably be converted directly using my romaji -> hiragana dictionary. This might be easier to read and maintain. Hmm..

Furigana-ify my text

This felt like one of those tasks that is easy for humans to do visually, but hard to solve with a program.

When we see:

and:

みせ に  い きます

店 に  行 きます

humans are pretty good at identifying which chunks of hiragana represent the kanji below. In the happy path, it’s fairly easy to iterate through the two strings and generate the (furigana, mainText pairs)

But sadly my input data was not free of errors. There were cases where my furigana didn’t match my romaji. Also some strings included information in brackets. eg. some languages have masculine and feminine versions of adjectives. So if a user was going from Japanese to Croatian, the Japanese string would need to include gender. so the romaji might look be Takai (M) and the kana/kanji version would be 高い (男).

Sometimes this meant cleaning up the input data. Sometimes it meant tweaking the romaji to hiragana conversion. Sometimes it meant tweaking the furigana generation process. In all cases thanks to my TDD mindset, it meant creating at least one new test case. I loved the fact that I was able to refactor mercilessly and be confident I was creating any regressions.

This post has been more hand wavy than showing specific code examples, but I did come across one code thing I want to share here.

extension Character {
    var isKanji: Bool {
    // HOW???
    }
}

For better or worse, the answer required some unicode kookiness…

extension Character {
    var isKanji: Bool {
        let result = try? /\p{Script=Han}/.firstMatch(in: String(self))
        return result != nil
    }
}

Implementing similar functionality in String is left as an exercise for the users.

Alternatively, isHiragana is a more contained problem to solve

    var isHiragana: Bool {
        ("あ"..."ん").contains(self)
    }
Categories
Software

Furigana in SwiftUI (3)

This is part 3. The previous episode is here.

To quickly recap, we want to layout text that looks like this:

Hello aka Konnichi wa aka 今日は aka こんにちは
こんにち is furigana that tells readers how to pronounce 今日

By putting VStacks in an HStack, we’ve been able to create this:

At this point, I see two possible next steps:

  1. what happens when there’s more text than can fit on one line?
  2. the furigana is SO BIG! (or is the main text to small?)

I held a vote and decided to start with issue #2, fix the sizing. The ten years ago version of me would have been more than happy to do something like:

    let furiGanaSize: CGFloat = 10
    let mainTextSize: CGFloat = 24
    var body: some View {
        VStack(alignment: .center){
            Text(text.furiGana)
               .font(.system(size: furiGanaSize))
            Text(text.mainText)
               .font(.system(size: mainTextSize))
        }
    }

Define default size values that users can over ride. While this would work, it didn’t feel like the most intuitive way to let users specify the size of their text. It would also prevent use of Dynamic Type sizes, eg .font(.body)

What I really want is:

  1. Let users specify the font and size of the main text using all the usual existing SwiftUI font specification approaches
  2. measure the height of main text (mainTextHeight)
  3. use mainTextHeight to compute a desired height for the furigana text
    let furiganaHeight = mainTextHeight * 0.5

GeometryReader?

That was my naive first thought. Do something like:

VStack() {
    Text(model.furiganaText)
     .font(.system(size: furiganaSize))
    GeometryReader() { proxy in
        Text(model.mainText)
         .preference(key: ViewSizeKey.self, value: proxy.size)
         .onPreferenceChange(ViewSizeKey.self) {
           furiganaSize = $0.height * 0.4
         }
    }
}

Sadly that gets a nope. GeometryReader‘s greediness means all the elements get as wide and tall as possible. I’m still not clear on why GeometryReader needs to be so greedy. Why not just take the rect that the contents say they need? Fool me once GeometryReader, shame on you. Fool me twice, shame on me.

Layout Container?

Hell yes. I have had many positive experiences creating containers that conform to Layout. How did it do with my furigana requirements? It did exactly what I needed. sizeThatFits does the following:

  1. determine the required size for the mainText
  2. pass the mainText height into a Binding<CGFloat>
  3. determine the size for the furigana text
  4. calculate the total height (mainTextHeight + furiganaHeight + spacing)
  5. calculate the width (max(mainTextWidth, furiganaWidth))
    func sizeThatFits(proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) -> CGSize {
        guard subviews.count == 2 else {
            return .zero
        }
        let furiganaSize = subviews[0].sizeThatFits(.unspecified)
        let bodySize = subviews[1].sizeThatFits(.unspecified)
        DispatchQueue.main.async {
            bodyHeight = bodySize.height
        }
        let spacing = subviews[0].spacing.distance(to: subviews[1].spacing, along: .vertical)
        let height = furiganaSize.height + bodySize.height + spacing
        let width = max(furiganaSize.width, bodySize.width)
        return .init(width: width, height: height)
    }

placeSubviews performs similar steps:

  1. determine (again) the sizes for the furigana text and the main text
  2. create size proposals (one for furigana text, the other for the main text)
  3. place the furigana text above the main text, using the size proposals created in the previous step
    func placeSubviews(in bounds: CGRect, proposal: ProposedViewSize, subviews: Subviews, cache: inout ()) {
        guard subviews.count == 2 else {
            return
        }
        let furiganaSize = subviews[0].sizeThatFits(.unspecified)
        let bodySize = subviews[1].sizeThatFits(.unspecified)
        let spacing = subviews[0].spacing.distance(to: subviews[1].spacing, along: .vertical)
        
        let furiganaSizeProposal = ProposedViewSize(furiganaSize)
        let mainTextSizeProposal = ProposedViewSize(bodySize)
        var y = bounds.minY + furiganaSize.height / 2
        
        subviews[0].place(at: .init(x: bounds.midX, y: y), anchor: .center, proposal: furiganaSizeProposal)
        y += furiganaSize.height / 2 + spacing + bodySize.height / 2
        subviews[1].place(at: .init(x: bounds.midX, y: y), anchor: .center, proposal: mainTextSizeProposal)
    }

FuriganaContainer contains a single element of the text to display. Its code is shown below.

struct TextElement: View {
    
    let textModel: TextWithFuriGana
    @State var bodyHeight: CGFloat = 0
    
    var body: some View {
        FuriganaContainer(bodyHeight: $bodyHeight) {
            Text(textModel.furiGana)
                .font(.system(size: bodyHeight * 0.5))
            Text(textModel.mainText)
        }
    }
}

The parent function looks something like this

struct TextArray: View {
    let fullText: [TextWithFuriGana]

    var body: some View {
        HStack(alignment: .bottom, spacing: 0) {
            ForEach(fullText) { text in
                    TextElement(textModel: text)
            }
        }
    }
}

TextArray gets instantiated something like this

struct ContentView: View {
    var body: some View {
        VStack {
            TextArray(fullText: TextWithFuriGana.testArray)
                .font(.largeTitle)
        }
    }
}

One topic that hasn’t been discussed but feels less interesting is the code that determines/creates the furigana text. This ended up being an interesting and challenging task that I’ll discuss in my next post.

Categories
Software

Furigana in SwiftUI (2)

This is part 2. The previous episode is here.

To quickly recap: We want to layout text that looks like this:

Hello aka Konnichi wa aka 今日は aka こんにちは
こんにち is furigana that tells readers how to pronounce 今日

When I began looking into implementation options, all roads seemed to be leading toward an AttributedString in a UILabel. There are a variety of descriptions of CoreText support for something called Ruby Text. There is also a mark down schemes for expressing Ruby text and sample code showing how to tickle the belly of CoreText to layout furigana text. I was not able to get any CoreText furigana working and there are likely two reasons for this:

  1. There are reports (not substantiated by me) that Apple removed Ruby Text support from Core Text. This seems like an odd thing, but definitely plausible
  2. I really wanted to do something that was SwiftUI-ish (SwiftUI-y?)

It’s quite possible that if I’d kept hammering away at CoreText I’d have got it to work. My official excuse is my heart wasn’t in it.

To implement this in SwiftUI my first approach included:

  1. a markdown scheme, eg “おはよおはよざいます。<<今日((こんいち))>>は。”
  2. code to convert markdown strings (like the one above) into an array of model objects
    struct TextModel {
    let mainText: String
    let furiGana: String
    }
  3. UI code that takes in an array of TextModels and displays them in collection of VStacks in an HStack
struct TextCollection: View {
    let textElements: [TextModel]
    init(markdown: String) {
        self.textElements = markdown.convertedToElements
    }
    var body: some View {
        HStack() {
            ForEach(textElements) { element in
                VStack() {
                    Text(element.furiGana)
                    Text(element.mainText)
                }
            }
        }
    }
}

The result looks like this:

Not yet perfect, but a great start!

In the next part, I’ll discuss my journey to figure out how to size the furigana as a function of the size of the main text. Stay tuned!

Categories
Software

Furigana in SwiftUI

I have been thinking about implementing Furigana in my Flash Cards App for a while now. Until recently it has definitely not been high priority. But I am in the process of adding some Japanese vocab where I’m using kanji. So far I’ve only been adding the really basic characters, but still… In order to have this app be accessible to all users, adding furigana support has recently become more important.

What the heck is Furigana?!

Written Japanese uses four different types of characters. They are:

  1. Kanji – These are pictograms where each character represents an idea and can have multiple pronunciations. For example, 月 is the character used to express month, and moon. Sometimes it will be pronounced getsu, other times tsuki etc. It can be combined with other characters in longer ‘words’. 月曜日 means Monday. I find it quite interesting that the Japanese word for Monday literally translates to Moon Day. There are thought to be over 50,000 unique Kanji. To be considered vaguely literate, the consensus suggests you need to know how to read and write 1,500 to 2,000 Kanji characters. Yikes!
  2. Hiragana – This is the most common Japanese alphabet. It is comprised of approximately 50 characters all representing specific sounds. Each hiragana character represents a single pronunciation. Also the sound of each character is also its name. For example KA (か) is always pronounced ‘ka.’ This might be a mind blowing concept to speakers of English (Looking at you W) Young kids start reading and writing hiragana. Over time they use more Kanji (see above) Where a beginning Japanese student might write: くうこうにいきます (kuukou ni ikimasu) an adult would more likely write: 空港に行きます. Both of these mean ‘I am going to the airport’ but the first text is all hiragana while the second is a combination of kanji and hiragana
  3. Katakana – This is an alphabet, similar to hiragana, but only used for words that have been introduced to Japanese: ピアノ (Piano), カラオケ (Karaoke, an interesting word in many ways), ホテル (Hotel)
  4. Romaji – Yet another alphabet that uses the English/western alphabet to express the same sounds available with Hiragana and Katakana. For example SA (romaji) is pronounced the same as さ (hiragana) is pronounced the same as サ (katakana) I think Romaji is primarily used for creating content aimed at non-Japanese speakers (eg maps). However it is also common to see ads aimed at Japanese speakers use romaji, cuz in some circles in Japan, Western/English stuff is seen as ‘cool.’

Great, but I thought this was about furigana…

It is! Furigana is hiragana written above or beside kanji to let readers know how it should be pronounced. (Remember there are thought to be over 50,000 kanji characters and they frequently have multiple context-dependent pronunciations. As a rule of thumb, the more common the kanji the greater the number of different ways it can be pronounced.)

Here is a simple example of furigana

Hello aka Konnichi wa aka 今日は aka こんにちは
こんにち is furigana that tells readers how to pronounce 今日

One other point that bears mentioning is that Japanese can be written in rows (left to right, top to bottom) or in columns (top to bottom, right to left). I’m currently under the impression that SwiftUI only supports the row-based text layout. For the moment, I’m going to focus on row-based layout and ignore column-based layout.

If you want to learn more about what I’ve described here, you could do worse than going to Wikipedia.

The next post will jump into the SwiftUI implementation.