Japanese Kanji with Furigana parser in Typescript

Sandro Maglione

Sandro Maglione

Web development

This code snippet implements a parser for Japanese kanji with Furigana.

Format

Every kanji is contained inside {} parenthesis. The kanji symbol is added first, followed by a separator (|) and then the furigana.

These tokens are defined in an enum in the script:

enum KanjiToken {
  KANJI_START = "{",
  KANJI_END = "}",
  KANJI_SEPARATOR = "|"
}

You can change these tokens by updating the characters in the enum

Here are some examples:

  • {|かん}{字|じ}
  • {|かん}{情|じょう}
  • {|はしら}

The parser also accepts kana characters everywhere in the word. For example:

  • {|た}べる
  • {|と}べる
  • {|き}り
  • {|めい}っ{子|こ}

Kanji characters are always required to have furigana ☝️

Dependencies

The script has a dependency on the wanakana library to check for valid kanji and kana characters.

Note: This dependency is not required. You may decide to implement isKanji and isKana on your own.

Below is the code extract from wanakana for isKanji and isKana (converted from javascript to typescript). You could use the code below instead of installing wanakana as a dependency:

const KANJI_START = 0x4e00;
const KANJI_END = 0x9faf;
const HIRAGANA_START = 0x3041;
const HIRAGANA_END = 0x3096;
const KATAKANA_START = 0x30a1;
const KATAKANA_END = 0x30fc;
const PROLONGED_SOUND_MARK = 0x30fc;
 
const isCharInRange = (char: string, start: number, end: number): boolean => {
  const code = char.charCodeAt(0);
  return start <= code && code <= end;
};
 
const isCharKanji = (char: string): boolean =>
  isCharInRange(char, KANJI_START, KANJI_END);
 
const isCharLongDash = (char: string): boolean =>
  char.charCodeAt(0) === PROLONGED_SOUND_MARK;
 
const isCharHiragana = (char: string): boolean => {
  if (isCharLongDash(char)) return true;
  return isCharInRange(char, HIRAGANA_START, HIRAGANA_END);
};
 
const isCharKatakana = (char: string): boolean =>
  isCharInRange(char, KATAKANA_START, KATAKANA_END);
 
const isCharKana = (char: string): boolean =>
  isCharHiragana(char) || isCharKatakana(char);
 
export const isKana = (input: string): boolean => [...input].every(isCharKana);
 
export const isKanji = (input: string): boolean => [...input].every(isCharKanji);

Result

The result contains a list of objects (KanjiWord):

interface Kanji {
  symbol: string;
  furigana: string;
}
 
/** Discriminated Unions: `_tag` used to distinguish between kanji (with furigana) and kana characters */
type KanjiWord =
  | { _tag: "kanji"; value: Kanji }
  | { _tag: "kana"; value: string };

For example, the string かり{気|き}まに{配|くば}にん returns the following array:

[
  { _tag: "kana", value: "かり" },
  { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
  { _tag: "kana", value: "まに" },
  { _tag: "kanji", value: { symbol: "配", furigana: "くば" } },
  { _tag: "kana", value: "にん" }
]

You can use _tag to distinguish between kana and kanji:

if (kanjiWord._tag === 'kana') {
  const value: string = kanjiWord.value;
  // ...
} else if (kanjiWord._tag === 'kanji') {
  const value: Kanji = kanjiWord.value;
  // ...
}

Full script

The script exports a single parser function that accepts a string as input and returns a non-empty list of KanjiWord when successful, or an error otherwise.

Here is the full script:

import { isKana, isKanji as isKanjiWanakana } from "wanakana";
 
const isKanji = (str: string) =>
  str.split("").every((char) => isKanjiWanakana(char) || char === "々");
 
enum KanjiToken {
  KANJI_START = "{",
  KANJI_END = "}",
  KANJI_SEPARATOR = "|",
}
 
interface Kanji {
  symbol: string;
  furigana: string;
}
 
type KanjiWord =
  | { _tag: "kanji"; value: Kanji }
  | { _tag: "kana"; value: string };
 
interface ParserError {
  _tag: "Error";
  value: string;
}
 
type ParserResultTemp =
  | ParserError
  | {
      _tag: "Success";
      value: KanjiWord;
      nextSource: string;
    };
export type ParserResult =
  | ParserError
  | {
      _tag: "Success";
      value: { 0: KanjiWord } & KanjiWord[];
    };
 
const parserKanji = (source: string): ParserResultTemp => {
  if (source.length === 0) {
    return { _tag: "Error", value: `Kanji is empty` };
  } else if (source[0] !== KanjiToken.KANJI_START) {
    return { _tag: "Error", value: `Missing kanji start token in "${source}"` };
  }
 
  let index = 1;
  let char = source[index];
 
  let symbol = "";
  while (char !== KanjiToken.KANJI_SEPARATOR) {
    if (index === source.length) {
      return {
        _tag: "Error",
        value: `Missing kanji separator token ("${KanjiToken.KANJI_SEPARATOR}") in "${source}"`,
      };
    }
 
    symbol += char;
 
    index += 1;
    char = source[index];
  }
 
  if (symbol.length === 0) {
    return {
      _tag: "Error",
      value: `Kanji symbol is empty in "${source}"`,
    };
  } else if (!isKanji(symbol)) {
    return {
      _tag: "Error",
      value: `Invalid kanji symbol in "${symbol}" for "${source}"`,
    };
  }
 
  // Skip separator
  index += 1;
  char = source[index];
 
  let furigana = "";
  while (char !== KanjiToken.KANJI_END) {
    if (index === source.length) {
      return {
        _tag: "Error",
        value: `Missing kanji end token ("${KanjiToken.KANJI_END}") in "${source}"`,
      };
    }
 
    furigana += char;
 
    index += 1;
    char = source[index];
  }
 
  if (furigana.length === 0) {
    return {
      _tag: "Error",
      value: `Kanji furigana is empty in "${source}" for symbol "${symbol}"`,
    };
  } else if (!isKana(furigana)) {
    return {
      _tag: "Error",
      value: `Invalid furigana characters in "${furigana}" for "${source}"`,
    };
  }
 
  return {
    _tag: "Success",
    value: { _tag: "kanji", value: { symbol, furigana } },
    nextSource: source.slice(symbol.length + furigana.length + 3),
  };
};
 
const parserKana = (source: string): ParserResultTemp => {
  const takeWhileKana = (str: string): string => {
    const char = str[0];
    if (str.length > 0 && isKana(char)) {
      return `${char}${takeWhileKana(str.slice(1))}`;
    } else {
      return ``;
    }
  };
 
  const kana = takeWhileKana(source);
  if (kana.length === 0) {
    return { _tag: "Error", value: `Kana characters missing in "${source}"` };
  } else if (!isKana(kana)) {
    return {
      _tag: "Error",
      value: `Invalid kana characters in "${kana}" for "${source}"`,
    };
  }
 
  return {
    _tag: "Success",
    value: { _tag: "kana", value: kana },
    nextSource: source.slice(kana.length),
  };
};
 
export const parser = (source: string): ParserResult => {
  if (source.length === 0) {
    return { _tag: "Error", value: `Source is empty` };
  }
 
  const kanjiWordList: KanjiWord[] = [];
 
  let index = 0;
  let parseSource = source;
  while (parseSource.length > 0) {
    let char = parseSource[index];
    if (char === KanjiToken.KANJI_START) {
      const kanji = parserKanji(parseSource);
      if (kanji._tag === "Error") {
        return kanji;
      }
 
      parseSource = kanji.nextSource;
      kanjiWordList.push(kanji.value);
    } else {
      const kana = parserKana(parseSource);
      if (kana._tag === "Error") {
        return kana;
      }
 
      parseSource = kana.nextSource;
      kanjiWordList.push(kana.value);
    }
  }
 
  if (kanjiWordList.length === 0) {
    return { _tag: "Error", value: `Kanji is empty in "${source}"` };
  }
 
  return {
    _tag: "Success",
    value: [kanjiWordList[0], ...kanjiWordList.slice(1)],
  };
};

Testing

The parser function has been tested on multiple inputs (using vitest):

All tests are passing for some general cases of both success and error when parsingAll tests are passing for some general cases of both success and error when parsing

import { describe, expect, test } from "vitest";
import { ParserResult, parser } from "./parser";
 
describe("Success", () => {
  test("double kanji and single kana", () => {
    expect(parser("{気|き}{配|くば}り")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
        { _tag: "kanji", value: { symbol: "配", furigana: "くば" } },
        { _tag: "kana", value: "り" },
      ],
    });
  });
 
  test("double kanji with kana in between", () => {
    expect(parser("{気|き}り{配|くば}")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
        { _tag: "kana", value: "り" },
        { _tag: "kanji", value: { symbol: "配", furigana: "くば" } },
      ],
    });
  });
 
  test("double kana, kanji, double kana, kanji, double kana", () => {
    expect(parser("かり{気|き}まに{配|くば}にん")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kana", value: "かり" },
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
        { _tag: "kana", value: "まに" },
        { _tag: "kanji", value: { symbol: "配", furigana: "くば" } },
        { _tag: "kana", value: "にん" },
      ],
    });
  });
 
  test("single kanji and kana", () => {
    expect(parser("{気|き}り")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
        { _tag: "kana", value: "り" },
      ],
    });
  });
 
  test("single kanji", () => {
    expect(parser("{気|き}")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [{ _tag: "kanji", value: { symbol: "気", furigana: "き" } }],
    });
  });
 
  test("single kana and single kanji", () => {
    expect(parser("り{気|き}")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kana", value: "り" },
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
      ],
    });
  });
 
  test("double kana and single kanji", () => {
    expect(parser("りか{気|き}")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kana", value: "りか" },
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
      ],
    });
  });
 
  test("double kana and double kanji", () => {
    expect(parser("りか{気|き}{感|かん}")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kana", value: "りか" },
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
        { _tag: "kanji", value: { symbol: "感", furigana: "かん" } },
      ],
    });
  });
 
  test("double kana and double kanji and kana", () => {
    expect(parser("りか{気|き}{感|かん}り")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [
        { _tag: "kana", value: "りか" },
        { _tag: "kanji", value: { symbol: "気", furigana: "き" } },
        { _tag: "kanji", value: { symbol: "感", furigana: "かん" } },
        { _tag: "kana", value: "り" },
      ],
    });
  });
 
  test("special kanji 々 character", () => {
    expect(parser("{々|き}")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [{ _tag: "kanji", value: { symbol: "々", furigana: "き" } }],
    });
  });
 
  test("only kana", () => {
    expect(parser("ありき")).toStrictEqual<ParserResult>({
      _tag: "Success",
      value: [{ _tag: "kana", value: "ありき" }],
    });
  });
});
 
describe("Error", () => {
  test("empty string", () => {
    const result = parser("");
    expect(result._tag).toBe<"Error">("Error");
  });
 
  test("no kanji character", () => {
    const result = parser("{か|かん}");
    expect(result._tag).toBe<"Error">("Error");
  });
 
  test("no kana character in kanji", () => {
    const result = parser("{感|感}");
    expect(result._tag).toBe<"Error">("Error");
  });
 
  test("kanji without furigana", () => {
    const result = parser("{感|かん}間");
    expect(result._tag).toBe<"Error">("Error");
  });
 
  test("missing start kanji token", () => {
    const result = parser("感|かん}り");
    expect(result._tag).toBe<"Error">("Error");
  });
 
  test("missing end kanji token", () => {
    const result = parser("{感|かんり");
    expect(result._tag).toBe<"Error">("Error");
  });
 
  test("missing separator kanji token", () => {
    const result = parser("{感かん}り");
    expect(result._tag).toBe<"Error">("Error");
  });
});

Feel free to use this snippet in your own code 💁🏼‍♂️

👋・Interested in learning more, every week?

Every week I build a new open source project, with a new language or library, and teach you how I did it, what I learned, and how you can do the same. Join me and other 600+ readers.