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
Meta

The Relief of Breathing Out

When I find myself with a few quiet minutes, I’ll often practice a breathing technique.

  1. Breathe in for 4 seconds
  2. Hold for 4 seconds
  3. Breathe out for 4 seconds
  4. Hold for 4 seconds
  5. goto 1

Usually when I’m doing this, holding for 4 seconds feels fine, but sometimes, my lungs are yelling ‘C’mon brain! Breathe!’ But even on the days when holding for 4 seconds feels fine, there is a sense of relief when I start breathing again. I recently noticed something odd in that relief.

The relief feels the same when I’m breathe is as when I’m breathe out. This didn’t initially make sense to me. I get why I feel relief in the inhale. My lungs are getting a fresh new supply of O2. Let the feast begin.

But when I breathe in and hold for a few seconds, then exhale I feel a sense of relief. What’s going on there? As far as my lungs can tell, there is no fresh supply of O2

I’m very tempted to get Teleological and imagine that my body ‘knows’ that breathing out is a necessary step to be able to inhale.

I think ‘results-oriented’ is a controversial concept. Meeting an objective is probably better than not completing something. But what if the situation has changed and the objective no longer makes sense? Perhaps accomplishing the original goal might now be detrimental. Also (as recognized by our clever lungs) there can be steps that don’t accomplish the ultimate goal, but are essential in clearing the way for us to achieve our results.

Spare a grateful thought for the lowly exhale. Even though it doesn’t bring any O2 into our thirsty alveoli, it clears the way for inhalation to waltz in and be the respiratory hero.

Not surprisingly our bodies are not being clever. Our brain gets panicky when the concentration of CO2 in our lungs goes up. That is what happens when we hold our breath. When we exhale we expel CO2, or more specifically reduce its concentration. Our bodies aren’t cleverly realizing exhalation is getting us closer to the O2. On the other hand having our bodies get panicky when CO2 is too prevalent sounds like a clever design decision.

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.