AXLR / Catalog / language

kana_romanize

Romanize Japanese hiragana and katakana in modified Hepburn (long vowels with macrons or as spelled, sokuon doubling with tch, n' before vowels and y, optional traditional m before b/m/p) or Kunrei-shiki (ISO 3602: si, ti, tu, hu, sya, circumflexes), with yoon and the katakana extensions for loanwords (fa, ti, vu). Kanji are left unchanged and listed.

Install

/tools install kana_romanize
/tools install kana_romanize@1.0.0

In a console session, or let the model find it in normal mode: axlr_catalog describes it and axlr_install_tool asks for the same install card. The card reads catalog axlr · kana_romanize 1.0.0 · verified once the index signature, the manifest digest and every file digest check out. Then the model runs it with axlr_run_tool kana_romanize; each run is approved as a local_exec.

Arguments

One JSON object on stdin, validated against the schema before the program starts.

nametyperequireddefaultmeaning
textstringyesJapanese text in kana; kanji and other characters are copied unchanged. length ≥ 1; length ≤ 100000
systemstringno"hepburn"one of "hepburn", "kunrei"
long_vowelsstringno"mark"mark: macron (Hepburn) or circumflex (Kunrei); as_is: vowels as spelled, ー repeats the vowel. one of "mark", "as_is"
n_before_bmpstringno"n"Hepburn ん before b, m, p: n (modern) or m (traditional, shimbun). one of "n", "m"
capitalizebooleannofalseUpper-case the first letter of each space-separated word
Input schema (JSON)
{
  "type": "object",
  "required": [
    "text"
  ],
  "additionalProperties": false,
  "properties": {
    "text": {
      "type": "string",
      "minLength": 1,
      "maxLength": 100000,
      "description": "Japanese text in kana; kanji and other characters are copied unchanged"
    },
    "system": {
      "type": "string",
      "enum": [
        "hepburn",
        "kunrei"
      ],
      "default": "hepburn"
    },
    "long_vowels": {
      "type": "string",
      "enum": [
        "mark",
        "as_is"
      ],
      "default": "mark",
      "description": "mark: macron (Hepburn) or circumflex (Kunrei); as_is: vowels as spelled, ー repeats the vowel"
    },
    "n_before_bmp": {
      "type": "string",
      "enum": [
        "n",
        "m"
      ],
      "default": "n",
      "description": "Hepburn ん before b, m, p: n (modern) or m (traditional, shimbun)"
    },
    "capitalize": {
      "type": "boolean",
      "default": false,
      "description": "Upper-case the first letter of each space-separated word"
    }
  }
}

Example

Input

{
  "text": "とうきょう",
  "capitalize": true
}

Output (stdout)

{
  "result": "Tōkyō",
  "system": "modified Hepburn"
}

Files

The command the install card registers is python3 kana_romanize.py, run in the workspace with PATH and HOME as its only environment. Each file's SHA-256 is in the signed index; the console checks them before showing the card and again before writing.

kana_romanize.py · sha256 f0d7a1515482f17aef5fe4963b01f3897922c50ce1f5e5fa503fecbde2a4ade9
#!/usr/bin/env python3
"""Romanize Japanese hiragana and katakana: modified (revised) Hepburn or Kunrei-shiki (ISO 3602).

Kana are read mora by mora (yoon and katakana extensions before single kana), then sokuon,
syllabic n and long vowels are resolved. Kanji and other characters are copied unchanged and
listed in "unconverted": reading kanji needs a dictionary.

Input (JSON on stdin):
  text         the Japanese text (required)
  system       "hepburn" (default) | "kunrei"
  long_vowels  "mark" (default: macron in Hepburn, circumflex in Kunrei) | "as_is"
  n_before_bmp "n" (default, modern Hepburn) | "m" (traditional Hepburn: shimbun)
  capitalize   bool, default false: upper-case the first letter of each space-separated word
"""
import json
import re
import sys
import unicodedata

MAX_TEXT = 100000

# (Hepburn, Kunrei) for single kana.
SINGLE = {
    "あ": "a", "い": "i", "う": "u", "え": "e", "お": "o",
    "か": "ka", "き": "ki", "く": "ku", "け": "ke", "こ": "ko",
    "が": "ga", "ぎ": "gi", "ぐ": "gu", "げ": "ge", "ご": "go",
    "さ": "sa", "し": ("shi", "si"), "す": "su", "せ": "se", "そ": "so",
    "ざ": "za", "じ": ("ji", "zi"), "ず": "zu", "ぜ": "ze", "ぞ": "zo",
    "た": "ta", "ち": ("chi", "ti"), "つ": ("tsu", "tu"), "て": "te", "と": "to",
    "だ": "da", "ぢ": ("ji", "zi"), "づ": "zu", "で": "de", "ど": "do",
    "な": "na", "に": "ni", "ぬ": "nu", "ね": "ne", "の": "no",
    "は": "ha", "ひ": "hi", "ふ": ("fu", "hu"), "へ": "he", "ほ": "ho",
    "ば": "ba", "び": "bi", "ぶ": "bu", "べ": "be", "ぼ": "bo",
    "ぱ": "pa", "ぴ": "pi", "ぷ": "pu", "ぺ": "pe", "ぽ": "po",
    "ま": "ma", "み": "mi", "む": "mu", "め": "me", "も": "mo",
    "や": "ya", "ゆ": "yu", "よ": "yo",
    "ら": "ra", "り": "ri", "る": "ru", "れ": "re", "ろ": "ro",
    "わ": "wa", "ゐ": "i", "ゑ": "e", "を": "o",
    "ゔ": "vu",
    "ぁ": "a", "ぃ": "i", "ぅ": "u", "ぇ": "e", "ぉ": "o", "ゃ": "ya", "ゅ": "yu", "ょ": "yo", "ゎ": "wa",
    "ゕ": "ka", "ゖ": "ke",
    "ヷ": "va", "ヸ": "vi", "ヹ": "ve", "ヺ": "vo",  # ヷ ヸ ヹ ヺ
}
BARE_VOWELS = {"あ": "a", "い": "i", "う": "u", "え": "e", "お": "o"}

# Yoon: i-row kana + small ya/yu/yo.
YOON_BASE = {"き": ("ky", "ky"), "ぎ": ("gy", "gy"), "し": ("sh", "sy"), "じ": ("j", "zy"), "ち": ("ch", "ty"),
             "ぢ": ("j", "zy"), "に": ("ny", "ny"), "ひ": ("hy", "hy"), "び": ("by", "by"), "ぴ": ("py", "py"),
             "み": ("my", "my"), "り": ("ry", "ry")}
COMBO = {}
for base, (hep, kun) in YOON_BASE.items():
    for small, vowel in (("ゃ", "a"), ("ゅ", "u"), ("ょ", "o")):
        COMBO[base + small] = (hep + vowel, kun + vowel, False)
# Extensions for loanwords (katakana, also accepted in hiragana); the flag marks spellings
# outside ISO 3602, which are written in Hepburn in both systems.
EXTENDED = {
    "しぇ": ("she", "sye"), "じぇ": ("je", "zye"), "ちぇ": ("che", "tye"),
    "ふぁ": "fa", "ふぃ": "fi", "ふぇ": "fe", "ふぉ": "fo", "ふゅ": "fyu",
    "てぃ": "ti", "でぃ": "di", "とぅ": "tu", "どぅ": "du", "てゅ": "tyu", "でゅ": "dyu",
    "うぃ": "wi", "うぇ": "we", "うぉ": "wo",
    "ゔぁ": "va", "ゔぃ": "vi", "ゔぇ": "ve", "ゔぉ": "vo", "ゔゅ": "vyu",
    "つぁ": "tsa", "つぃ": "tsi", "つぇ": "tse", "つぉ": "tso",
    "いぇ": "ye", "くぁ": "kwa", "くぃ": "kwi", "くぇ": "kwe", "くぉ": "kwo", "ぐぁ": "gwa",
    "すぃ": "si", "ずぃ": "zi",
}
for key, value in EXTENDED.items():
    hep, kun = value if isinstance(value, tuple) else (value, value)
    COMBO[key] = (hep, kun, not isinstance(value, tuple))

VOICED = dict(zip("かきくけこさしすせそたちつてとはひふへほう", "がぎぐげござじずぜぞだぢづでどばびぶべぼゔ"))
PUNCT = {"。": ".", "、": ",", "「": "\"", "」": "\"", "『": "\"", "』": "\"", "・": " ", "〜": "~", "~": "~",
         " ": " ", "(": "(", ")": ")"}
MARK = {"hepburn": dict(zip("aeiou", "āēīōū")), "kunrei": dict(zip("aeiou", "âêîôû"))}


def fail(message):
    print(json.dumps({"error": message}))
    sys.exit(2)


def to_hiragana(char):
    code = ord(char)
    if 0x30A1 <= code <= 0x30F6:
        return chr(code - 0x60)
    if char in ("ヽ", "ヾ"):  # ヽ ヾ
        return chr(code - 0x60)
    return char


def single(kana, system):
    value = SINGLE[kana]
    if isinstance(value, tuple):
        return value[0] if system == "hepburn" else value[1]
    return value


def tokenize(text, system):
    """List of tokens: dicts with kind mora|n|sokuon|long|other."""
    tokens = []
    chars = [to_hiragana(c) for c in text]
    i = 0
    while i < len(chars):
        char = chars[i]
        pair = char + chars[i + 1] if i + 1 < len(chars) else ""
        if pair in COMBO:
            hep, kun, outside = COMBO[pair]
            tokens.append({"kind": "mora", "text": hep if system == "hepburn" else kun, "kana": pair,
                           "outside": outside and system == "kunrei"})
            i += 2
            continue
        if char in ("ゝ", "ゞ"):  # iteration marks repeat the previous kana
            previous = next((t for t in reversed(tokens) if t["kind"] == "mora"), None)
            if previous and len(previous["kana"]) == 1:
                kana = previous["kana"]
                if char == "ゞ":
                    kana = VOICED.get(kana, kana)
                tokens.append({"kind": "mora", "text": single(kana, system), "kana": kana})
            else:
                tokens.append({"kind": "other", "text": text[i], "source": text[i]})
            i += 1
            continue
        if char == "ー":
            tokens.append({"kind": "long", "text": ""})
        elif char == "っ":
            tokens.append({"kind": "sokuon", "text": ""})
        elif char == "ん":
            tokens.append({"kind": "n", "text": "n"})
        elif char in SINGLE:
            tokens.append({"kind": "mora", "text": single(char, system), "kana": char,
                           "bare": BARE_VOWELS.get(char)})
        else:
            tokens.append({"kind": "other", "text": PUNCT.get(char, text[i]), "source": text[i]})
        i += 1
    return tokens


def render(tokens, system, long_vowels, n_before_bmp, notes, unconverted):
    out = []
    marks = MARK[system]
    previous = None  # index in out of the last mora with its vowel, for long vowels
    for index, token in enumerate(tokens):
        kind = token["kind"]
        following = tokens[index + 1] if index + 1 < len(tokens) else None
        if kind == "mora":
            if token.get("outside"):
                notes.add("extended katakana outside ISO 3602 are written in Hepburn")
            bare = token.get("bare")
            if long_vowels == "mark" and bare and previous is not None:
                position, vowel, lengthened = previous
                if not lengthened and (bare == vowel and vowel in "aueo" or (vowel == "o" and bare == "u")):
                    out[position] = out[position][:-1] + marks[vowel]
                    previous = (position, vowel, True)
                    continue
            out.append(token["text"])
            last = token["text"][-1]
            previous = (len(out) - 1, last, False) if last in "aeiou" else None
        elif kind == "long":
            if previous is None:
                out.append("-")
            elif long_vowels == "as_is":
                position, vowel, _ = previous
                out.append(vowel)
                previous = None
            else:
                position, vowel, lengthened = previous
                if not lengthened:
                    out[position] = out[position][:-1] + marks[vowel]
                    previous = (position, vowel, True)
        elif kind == "sokuon":
            nxt = following["text"] if following and following["kind"] == "mora" else ""
            if nxt and nxt[0] not in "aeiouy":
                out.append("t" if system == "hepburn" and nxt.startswith("ch") else nxt[0])
            else:
                notes.add("a sokuon (small tsu) not followed by a consonant has no romanization and was dropped")
            previous = None
        elif kind == "n":
            nxt = following["text"] if following and following["kind"] == "mora" else ""
            if nxt and nxt[0] in "aeiouy":
                out.append("n'")
            elif system == "hepburn" and n_before_bmp == "m" and nxt[:1] in ("b", "m", "p"):
                out.append("m")
            else:
                out.append("n")
            previous = None
        else:
            source = token.get("source", "")
            if ord(source) > 0x7F and source not in PUNCT and not source.isspace() \
                    and source not in unconverted and len(unconverted) < 50:
                unconverted.append(source)
            out.append(token["text"])
            previous = None
    return "".join(out)


def main():
    try:
        request = json.loads(sys.stdin.buffer.read().decode("utf-8"))
    except (ValueError, UnicodeDecodeError) as error:
        fail(f"stdin is not JSON: {error}")
    if not isinstance(request, dict):
        fail("input must be a JSON object")
    unknown = sorted(set(request) - {"text", "system", "long_vowels", "n_before_bmp", "capitalize"})
    if unknown:
        fail(f"unknown fields: {', '.join(unknown)}")
    text = request.get("text")
    if not isinstance(text, str) or not text:
        fail("text must be a non-empty string")
    if len(text) > MAX_TEXT:
        fail(f"text is longer than {MAX_TEXT} characters")
    system = request.get("system", "hepburn")
    if system not in ("hepburn", "kunrei"):
        fail("system must be hepburn or kunrei")
    long_vowels = request.get("long_vowels", "mark")
    if long_vowels not in ("mark", "as_is"):
        fail("long_vowels must be mark or as_is")
    n_before_bmp = request.get("n_before_bmp", "n")
    if n_before_bmp not in ("n", "m"):
        fail("n_before_bmp must be n or m")
    capitalize = request.get("capitalize", False)
    if not isinstance(capitalize, bool):
        fail("capitalize must be a boolean")

    # Half-width katakana and full-width ASCII to their usual forms; NFC joins combining dakuten.
    text = "".join(unicodedata.normalize("NFKC", c) if 0xFF00 <= ord(c) <= 0xFFEF else c for c in text)
    text = unicodedata.normalize("NFC", text)
    notes, unconverted = set(), []
    result = render(tokenize(text, system), system, long_vowels, n_before_bmp, notes, unconverted)
    if capitalize:
        result = re.sub(r"(^|\s)([^\W\d_])", lambda m: m.group(1) + m.group(2).upper(), result)
    if unconverted:
        notes.add("kanji and other characters are left as they are (reading kanji needs a dictionary such as pykakasi)")
    answer = {"result": result,
              "system": "modified Hepburn" if system == "hepburn" else "Kunrei-shiki (ISO 3602)",
              "long_vowels": long_vowels}
    if unconverted:
        answer["unconverted"] = unconverted
    if notes:
        answer["notes"] = sorted(notes)
    sys.stdout.buffer.write((json.dumps(answer, ensure_ascii=False) + "\n").encode("utf-8"))


if __name__ == "__main__":
    main()

Where it is served

The registry serves only AXLR clients, so a browser gets 403; fetch with a User-Agent that starts with axlr:

curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/kana_romanize/1.0.0/manifest.json
curl -A axlr-docs/1.0 -O https://registry.underpassai.com/tools/kana_romanize/1.0.0/kana_romanize.py