package jp
// Port of WanaKana (github.com/WaniKani/WanaKana, MIT License) kana->romaji conversion
// as the core engine for Wapuro style romanization.
// See WANAKANA_LICENSE for original license notice.
import (
_ "embed"
"encoding/json"
"strings"
"unicode"
)
type Style int
const (
Macron Style = iota
Wapuro
Plain
)
// IsHiragana reports whether r is a hiragana character.
func IsHiragana(r rune) bool {
return (r >= 0x3041 && r <= 0x3096) || (r >= 0x309D && r <= 0x309F)
}
// IsKatakana reports whether r is a katakana character, including ー and half-width katakana.
func IsKatakana(r rune) bool {
if r == 'ー' || r == 'ー' {
return true
}
if r >= 0x30A1 && r <= 0x30FA {
return true
}
if r >= 0x30FD && r <= 0x30FF {
return true
}
if r >= 0xFF66 && r <= 0xFF9F {
return true
}
return false
}
// IsKana reports whether r is hiragana or katakana.
func IsKana(r rune) bool {
return IsHiragana(r) || IsKatakana(r)
}
// IsKanji reports whether r is a kanji character (CJK unified ideographs + ext A + 々〆ヵヶ).
func IsKanji(r rune) bool {
if r == '々' || r == '〆' || r == 'ヵ' || r == 'ヶ' {
return true
}
if r >= 0x4E00 && r <= 0x9FFF {
return true
}
if r >= 0x3400 && r <= 0x4DBF {
return true
}
if r >= 0xF900 && r <= 0xFAFF {
return true
}
return false
}
// HasJapanese reports whether s contains any kana or kanji.
func HasJapanese(s string) bool {
for _, r := range s {
if IsKana(r) || IsKanji(r) {
return true
}
}
return false
}
func isKanaLetter(r rune) bool {
return IsKana(r) && r != 'ー' && r != 'ー'
}
// IsKanaOnly reports whether s contains >=1 kana and only kana/ー/punct/space.
func IsKanaOnly(s string) bool {
hasKana := false
for _, r := range s {
if isKanaLetter(r) {
hasKana = true
continue
}
if r == 'ー' || r == 'ー' || unicode.IsPunct(r) || unicode.IsSpace(r) {
continue
}
return false
}
return hasKana
}
var halfwidthKatakana = map[rune]rune{
'。': '。', '「': '「', '」': '」', '、': '、', '・': '・',
'ヲ': 'ヲ', 'ァ': 'ァ', 'ィ': 'ィ', 'ゥ': 'ゥ', 'ェ': 'ェ', 'ォ': 'ォ',
'ャ': 'ャ', 'ュ': 'ュ', 'ョ': 'ョ', 'ッ': 'ッ', 'ー': 'ー',
'ア': 'ア', 'イ': 'イ', 'ウ': 'ウ', 'エ': 'エ', 'オ': 'オ',
'カ': 'カ', 'キ': 'キ', 'ク': 'ク', 'ケ': 'ケ', 'コ': 'コ',
'サ': 'サ', 'シ': 'シ', 'ス': 'ス', 'セ': 'セ', 'ソ': 'ソ',
'タ': 'タ', 'チ': 'チ', 'ツ': 'ツ', 'テ': 'テ', 'ト': 'ト',
'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ',
'ハ': 'ハ', 'ヒ': 'ヒ', 'フ': 'フ', 'ヘ': 'ヘ', 'ホ': 'ホ',
'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ',
'ヤ': 'ヤ', 'ユ': 'ユ', 'ヨ': 'ヨ',
'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ',
'ワ': 'ワ', 'ン': 'ン',
}
var voicedKatakana = map[rune]rune{
'カ': 'ガ', 'キ': 'ギ', 'ク': 'グ', 'ケ': 'ゲ', 'コ': 'ゴ',
'サ': 'ザ', 'シ': 'ジ', 'ス': 'ズ', 'セ': 'ゼ', 'ソ': 'ゾ',
'タ': 'ダ', 'チ': 'ヂ', 'ツ': 'ヅ', 'テ': 'デ', 'ト': 'ド',
'ハ': 'バ', 'ヒ': 'ビ', 'フ': 'ブ', 'ヘ': 'ベ', 'ホ': 'ボ',
'ウ': 'ヴ', 'ワ': 'ヷ', 'ヰ': 'ヸ', 'ヱ': 'ヹ', 'ヲ': 'ヺ',
}
var semiVoicedKatakana = map[rune]rune{
'ハ': 'パ', 'ヒ': 'ピ', 'フ': 'プ', 'ヘ': 'ペ', 'ホ': 'ポ',
}
func normalizeHalfwidth(s string) string {
runes := []rune(s)
var out []rune
for i := 0; i < len(runes); i++ {
r := runes[i]
if r == '゙' || r == '゚' {
continue
}
if full, ok := halfwidthKatakana[r]; ok {
if i+1 < len(runes) {
if runes[i+1] == '゙' {
if v, vok := voicedKatakana[full]; vok {
out = append(out, v)
i++
continue
}
} else if runes[i+1] == '゚' {
if sv, sok := semiVoicedKatakana[full]; sok {
out = append(out, sv)
i++
continue
}
}
}
out = append(out, full)
} else {
out = append(out, r)
}
}
return string(out)
}
// ToHiragana converts katakana to hiragana, leaving other characters unchanged.
func ToHiragana(s string) string {
s = normalizeHalfwidth(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r >= 0x30A1 && r <= 0x30F6 {
b.WriteRune(r - 0x60)
} else if r == 0x30FD {
b.WriteRune(0x309D) // ヽ -> ゝ
} else if r == 0x30FE {
b.WriteRune(0x309E) // ヾ -> ゞ
} else {
b.WriteRune(r)
}
}
return b.String()
}
// ToKatakana converts hiragana to katakana, leaving other characters unchanged.
func ToKatakana(s string) string {
s = normalizeHalfwidth(s)
var b strings.Builder
b.Grow(len(s))
for _, r := range s {
if r >= 0x3041 && r <= 0x3096 {
b.WriteRune(r + 0x60)
} else if r == 0x309D {
b.WriteRune(0x30FD) // ゝ -> ヽ
} else if r == 0x309E {
b.WriteRune(0x30FE) // ゞ -> ヾ
} else {
b.WriteRune(r)
}
}
return b.String()
}
func isSmallAttaching(r rune) bool {
switch r {
case 'ゃ', 'ゅ', 'ょ', 'ぁ', 'ぃ', 'ぅ', 'ぇ', 'ぉ', 'ゎ',
'ャ', 'ュ', 'ョ', 'ァ', 'ィ', 'ゥ', 'ェ', 'ォ', 'ヮ',
'ャ', 'ュ', 'ョ', 'ァ', 'ィ', 'ゥ', 'ェ', 'ォ':
return true
default:
return false
}
}
// Moras segments kana into moras. Small ゃゅょぁぃぅぇぉ and ゎ attach to the
// previous kana; っ, ん, and ー are each their own mora.
func Moras(kana string) []string {
var moras []string
for _, r := range kana {
if isSmallAttaching(r) && len(moras) > 0 {
moras[len(moras)-1] += string(r)
} else {
moras = append(moras, string(r))
}
}
return moras
}
//go:embed wanakana_tree.json
var wanakanaTreeJSON []byte
type trieNode struct {
val string
children map[rune]*trieNode
}
var wanakanaRoot *trieNode
func parseTrie(raw map[string]interface{}) *trieNode {
n := &trieNode{children: make(map[rune]*trieNode)}
for k, v := range raw {
if k == "" {
if s, ok := v.(string); ok {
n.val = s
}
} else {
r := []rune(k)[0]
if childRaw, ok := v.(map[string]interface{}); ok {
n.children[r] = parseTrie(childRaw)
}
}
}
return n
}
func init() {
var raw map[string]interface{}
if err := json.Unmarshal(wanakanaTreeJSON, &raw); err != nil {
panic(err)
}
wanakanaRoot = parseTrie(raw)
}
func isCharKatakana(r rune) bool {
return r >= 0x30A1 && r <= 0x30FC
}
var wanakanaLongVowels = map[byte]rune{
'a': 'あ',
'i': 'い',
'u': 'う',
'e': 'え',
'o': 'う',
}
// wanakanaKatakanaToHiragana implements WanaKana's katakanaToHiragana transform
// with convertLongVowelMark=true and isDestinationRomaji=true.
func wanakanaKatakanaToHiragana(s string) string {
runes := []rune(s)
var b strings.Builder
var prevKana string
for idx, r := range runes {
if r == '・' || (r == 'ー' && idx < 1) || r == 'ヶ' || r == 'ヵ' {
b.WriteRune(r)
prevKana = ""
continue
}
if prevKana != "" && r == 'ー' && idx > 0 {
romaji := wanakanaApplyMapping(prevKana)
var lastV byte
if len(romaji) > 0 {
lastV = romaji[len(romaji)-1]
}
if isCharKatakana(runes[idx-1]) && lastV == 'o' {
b.WriteRune('お')
} else if mapped, ok := wanakanaLongVowels[lastV]; ok {
b.WriteRune(mapped)
}
continue
}
if r != 'ー' && isCharKatakana(r) {
h := r - 0x60
prevKana = string(h)
b.WriteRune(h)
continue
}
prevKana = ""
b.WriteRune(r)
}
return b.String()
}
// wanakanaApplyMapping implements WanaKana's applyMapping over the Hepburn trie.
func wanakanaApplyMapping(s string) string {
runes := []rune(s)
var b strings.Builder
i := 0
n := len(runes)
for i < n {
curr := wanakanaRoot
lastMatch := ""
lastMatchIdx := -1
j := i
for j < n {
next, ok := curr.children[runes[j]]
if !ok {
break
}
curr = next
if curr.val != "" || (j == i && curr.val == "" && runes[j] == 'っ') {
lastMatch = curr.val
lastMatchIdx = j + 1
}
j++
}
if lastMatchIdx != -1 {
b.WriteString(lastMatch)
i = lastMatchIdx
} else {
b.WriteRune(runes[i])
i++
}
}
return b.String()
}
// ToRomajiWapuro converts kana to Wapuro romaji with 100% WanaKana parity.
func ToRomajiWapuro(s string) string {
hira := wanakanaKatakanaToHiragana(s)
return wanakanaApplyMapping(hira)
}
var dakutenMap = map[rune]rune{
'か': 'が', 'き': 'ぎ', 'く': 'ぐ', 'け': 'げ', 'こ': 'ご',
'さ': 'ざ', 'し': 'じ', 'す': 'ず', 'せ': 'ぜ', 'そ': 'ぞ',
'た': 'だ', 'ち': 'ぢ', 'つ': 'づ', 'て': 'で', 'と': 'ど',
'は': 'ば', 'ひ': 'び', 'ふ': 'ぶ', 'へ': 'べ', 'ほ': 'ぼ',
'カ': 'ガ', 'キ': 'ギ', 'ク': 'グ', 'ケ': 'ゲ', 'コ': 'ゴ',
'サ': 'ザ', 'シ': 'ジ', 'ス': 'ズ', 'セ': 'ゼ', 'ソ': 'ゾ',
'タ': 'ダ', 'チ': 'ヂ', 'ツ': 'ヅ', 'テ': 'デ', 'ト': 'ド',
'ハ': 'バ', 'ヒ': 'ビ', 'フ': 'ブ', 'ヘ': 'ベ', 'ホ': 'ボ',
'ウ': 'ヴ',
}
func expandIterationMarks(kana string) string {
runes := []rune(kana)
var out []rune
for _, r := range runes {
switch r {
case 'ゝ', 'ヽ':
if len(out) > 0 {
out = append(out, out[len(out)-1])
} else {
out = append(out, r)
}
case 'ゞ', 'ヾ':
if len(out) > 0 {
prev := out[len(out)-1]
if voiced, ok := dakutenMap[prev]; ok {
out = append(out, voiced)
} else {
out = append(out, prev)
}
} else {
out = append(out, r)
}
default:
out = append(out, r)
}
}
return string(out)
}
var macronMap = map[rune]rune{
'a': 'ā', 'i': 'ī', 'u': 'ū', 'e': 'ē', 'o': 'ō',
'A': 'Ā', 'I': 'Ī', 'U': 'Ū', 'E': 'Ē', 'O': 'Ō',
}
func lastVowel(s string) rune {
runes := []rune(s)
for i := len(runes) - 1; i >= 0; i-- {
ch := runes[i]
switch ch {
case 'a', 'i', 'u', 'e', 'o':
return ch
case 'ā':
return 'a'
case 'ī':
return 'i'
case 'ū':
return 'u'
case 'ē':
return 'e'
case 'ō':
return 'o'
}
}
return 0
}
func lengthenLastVowel(s string) string {
runes := []rune(s)
for i := len(runes) - 1; i >= 0; i-- {
if mac, ok := macronMap[runes[i]]; ok {
runes[i] = mac
return string(runes)
}
}
return s
}
// Romaji converts kana to Hepburn romaji context-free according to style.
// Wapuro style uses the WanaKana engine.
// Macron and Plain styles apply Hepburn conventions (e.g. ヲ/を -> "o", ー -> macron/drop).
func Romaji(kana string, st Style) string {
if st == Wapuro {
return ToRomajiWapuro(kana)
}
kana = expandIterationMarks(kana)
moras := Moras(kana)
var out []string
for i := 0; i < len(moras); i++ {
m := moras[i]
cur := strings.Join(out, "")
prevV := lastVowel(cur)
if m == "を" || m == "ヲ" {
out = append(out, "o")
continue
}
if m == "っ" || m == "ッ" {
if i+1 < len(moras) {
nxt := ToRomajiWapuro(moras[i+1])
if strings.HasPrefix(nxt, "ch") {
out = append(out, "t")
} else if len(nxt) > 0 && !strings.ContainsRune("aiueo", rune(nxt[0])) {
out = append(out, string(nxt[0]))
}
}
continue
}
if m == "ん" || m == "ン" {
if i+1 < len(moras) {
nxt := ToRomajiWapuro(moras[i+1])
if len(nxt) > 0 && (strings.ContainsRune("aiueo", rune(nxt[0])) || strings.HasPrefix(nxt, "y")) {
out = append(out, "n'")
} else {
out = append(out, "n")
}
} else {
out = append(out, "n")
}
continue
}
if m == "ー" || m == "ー" {
if prevV != 0 {
switch st {
case Macron:
out = []string{lengthenLastVowel(cur)}
case Plain:
// drop
}
} else {
out = append(out, "-")
}
continue
}
out = append(out, ToRomajiWapuro(m))
}
return strings.Join(out, "")
}
// RomajiPron converts reading aligned with pronunciation into romaji.
// It merges long vowels when pron has ー and reading has ウ after o/u
// or a vowel repeat (おお, ああ...). It preserves "ei" when reading has イ after e.
// Particles ハ->wa, ヘ->e are romanized according to pron.
// ヲ/を is romanized as "o" in Macron and Plain, and "wo" in Wapuro (unless pron is "o").
// Falls back to Romaji(reading) when pron is empty or alignment fails.
func RomajiPron(reading, pron string, st Style) string {
if pron == "" {
return Romaji(reading, st)
}
rNorm := expandIterationMarks(reading)
pNorm := expandIterationMarks(pron)
rMoras := Moras(ToKatakana(rNorm))
pMoras := Moras(ToKatakana(pNorm))
if len(rMoras) != len(pMoras) {
return Romaji(reading, st)
}
var out []string
for i := 0; i < len(rMoras); i++ {
r := rMoras[i]
p := pMoras[i]
// Particle check
var particle string
if r == "ハ" && p == "ワ" {
particle = "wa"
} else if r == "ヘ" && p == "エ" {
particle = "e"
} else if r == "ヲ" {
if st == Macron || st == Plain {
particle = "o"
} else if p == "オ" {
particle = "o"
} else {
particle = "wo"
}
}
cur := strings.Join(out, "")
prevV := lastVowel(cur)
if p == "ー" || p == "ー" {
if r == "イ" && prevV == 'e' {
// Keep "ei"
out = append(out, "i")
} else if r == "ウ" && (prevV == 'o' || prevV == 'u') {
switch st {
case Macron:
out = []string{lengthenLastVowel(cur)}
case Wapuro:
out = append(out, "u")
case Plain:
// drop
}
} else if prevV != 0 && ((prevV == 'a' && r == "ア") ||
(prevV == 'i' && r == "イ") ||
(prevV == 'u' && r == "ウ") ||
(prevV == 'e' && r == "エ") ||
(prevV == 'o' && r == "オ")) {
switch st {
case Macron:
out = []string{lengthenLastVowel(cur)}
case Wapuro:
out = append(out, string(prevV))
case Plain:
// drop
}
} else if r == "ー" || r == "ー" {
if prevV != 0 {
switch st {
case Macron:
out = []string{lengthenLastVowel(cur)}
case Wapuro:
out = append(out, string(prevV))
case Plain:
// drop
}
} else {
out = append(out, "-")
}
} else {
if particle != "" {
out = append(out, particle)
} else {
out = append(out, ToRomajiWapuro(r))
}
}
} else if r == "ッ" {
if i+1 < len(rMoras) {
nxt := ToRomajiWapuro(rMoras[i+1])
if strings.HasPrefix(nxt, "ch") {
out = append(out, "t")
} else if len(nxt) > 0 && !strings.ContainsRune("aiueo", rune(nxt[0])) {
out = append(out, string(nxt[0]))
}
}
} else if r == "ン" {
if i+1 < len(rMoras) {
nxt := ToRomajiWapuro(rMoras[i+1])
if len(nxt) > 0 && (strings.ContainsRune("aiueo", rune(nxt[0])) || strings.HasPrefix(nxt, "y")) {
out = append(out, "n'")
} else {
out = append(out, "n")
}
} else {
out = append(out, "n")
}
} else if r == "ー" || r == "ー" {
if prevV != 0 {
switch st {
case Macron:
out = []string{lengthenLastVowel(cur)}
case Wapuro:
out = append(out, string(prevV))
case Plain:
// drop
}
} else {
out = append(out, "-")
}
} else {
if particle != "" {
out = append(out, particle)
} else {
out = append(out, ToRomajiWapuro(r))
}
}
}
return strings.Join(out, "")
}