Repository navigation
Gukhanmun 0.3.0: Han character variants, particle-based reading selection, punctuation profiles, and loanword spellings #32
dahlia
announced in
Announcements
Replies: 0 comments
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Gukhanmun is a library and CLI tool for converting Korean text written in mixed hanja/hangul script into consistently annotated output. Academic texts, historical documents, and legal materials routinely mix Chinese characters (hanja) with Korean script (hangul); Gukhanmun reads them and renders the result in your chosen mode: hangul-only, ruby annotation, original script, and more. It is available as a Rust crate, a Node.js package, a WebAssembly module, and a standalone CLI binary.
0.3.0 adds four things that are reachable from every surface: dictionary matching for han character variants, so
沖縄県reads as 오키나와현 and藝術can be emitted as 芸術; reading selection from the Korean particle that follows a hanja word, which picks 일체 over 일절 in一切를; opt-in punctuation profiles, for output that should not keep the source's punctuation; and the National Institute of Korean Language's loanword spelling examples, which give香港its usual 홍콩. The dictionary layer underneath now keeps competing readings instead of collapsing them at extraction time. If you are upgrading from 0.2.x, check the upgrading notes below, because the loanword dictionary changesko-kroutput.Han character variants
Dictionary headwords record hanja in orthodox forms. Real mixed-script text arrives in whatever register its source used: Japanese material writes
沖縄県where the Standard Korean Dictionary (標準國語大辭典) has沖繩縣, simplified Chinese uses发where the dictionary has發, and older printings carry CJK compatibility ideographs. Those spellings used to miss the dictionary and fall back to per-character readings, which produced a plausible-looking but wrong conversion.Recognition now expands each character of a source token into its variant equivalence class and lets the dictionary decide which combination is a real word. For
沖縄県the engine tries the exact spelling first, then Joyo, traditional, simplified, z-variant, and curated Asahi candidates, and沖繩縣is the one that exists as a headword:The annotation now carries the dictionary spelling alongside the source spelling. Directives, first-occurrence tracking, and homophone checks use the dictionary spelling as the lexical identity, so a variant on the input side does not change what the rest of the pipeline considers the same word.
Output is a separate policy.
--hanja-variant-setchooses which register the renderer emits whenever the selected mode keeps hanja:as-dictionary(the default),shinjitai,kanxi,simplified, orasahimoji. In Rust the same setting isBuilder::hanja_variant_set(), and both JavaScript bindings take ahanjaVariantSetoption.The limits are deliberate. Only variants that differ at the code-point level are handled, so regional glyph differences that Unicode unifies through variation selectors stay out of scope. When a variant merges several characters, as
弁merges 弁, 辯, 辨, and 瓣, the engine accepts a spelling only if the dictionary lists it, and leaves the token unresolved rather than guessing when more than one dictionary word survives. Expansion is capped at 32 characters and 256 candidates per lookup, so ordinary hanja runs do not turn into scans of the generated tables. The simplified mapping is many-to-one and lossy per character, and the Asahi profile covers only the encoded subset of that character set.The variant tables are pinned and reviewable: gukhanmun-unihan records the Unicode 17.0.0 inputs, the checked-in Joyo pairs, and the small verified Asahi subset, and
mise run generate-unihan-checkfails when the generated tables drift. The original proposal is in #11 and the implementation in #12.Reading selection from following particles
Dictionary extraction used to collapse competing readings into a single value per key before the runtime dictionary was ever built. That kept lookup simple and discarded the evidence a sentence supplies.
一切is both 일절 and 일체,東京is both 동경 and 도쿄, and which one a text means depends on how the rest of the sentence is written.0.3.0 keeps the candidates from extraction through storage and lookup, and selects one during conversion. A higher-priority dictionary still replaces the whole candidate set for a key, so an explicit override is not quietly reopened by a bundled reading.
The first consumer of that information is the particle that follows the word. In
그는 東京을 좋아한다the particle을fits 동경 and not 도쿄;東京를says the opposite. Gukhanmun now reads a whole particle after a matched key, compares each candidate's final syllable with the form the particle requires, and keeps the candidates that fit:This is evidence, not grammar correction. The source particle is left exactly as written, and the previous reading survives when the answer is uncertain: only whole particles count (
을/를,은/는,이/가,과/와, and으로/로with its ㄹ exception, plus combinations such as으로는,과도, and와만), a particle separated from the word by inline markup is not a match, and the copula이다is not mistaken for the subject particle이. If no candidate fits, or if several fit equally, conversion falls back to the recorded default rather than inventing a reading. An explicit dictionary override stays authoritative throughout.Rust callers can also create and inspect candidate sets:
ReadingCandidate,ReadingCandidates, andMapDictionary::insert_candidates()build them, andlookup().candidates()reads them back.gukhanmun-mkdictacceptspreferenceandsuffix_readingcolumns in TSV and CSV input, orpreferenceandsuffixReadingin JSONL. Binary dictionaries are written as v1 for scalar data and as v2 when candidates or suffix readings require it; the bundled Standard Korean Dictionary and Open Korean Dictionary data now ship as v2. Homophone marking indexes only the selected candidate, including its suffix reading, so the annotations and the conversion agree.The design rationale is in #19, the storage and API work in #22, and the Open Korean Dictionary half in #21 and #24. The particle filter, including its streaming lookahead, is in #20 and #25.
Punctuation profiles
Hanja conversion leaves punctuation alone, which is usually right: a document that quotes classical text keeps its
、/。and「/」. It is the wrong default for a document being prepared for modern hangul-only presentation, where the converted characters sit next to source punctuation and the two styles no longer agree.Punctuation normalization is now available as an opt-in output profile that runs after annotation policies and before rendering, so reading selection, generated glosses, and ruby spans are untouched. It works on hangul-only text too, and it is independent of hanja rendering, hanja variants, dictionaries, and the Korean presets.
preserve(default)modern,/.〈〉/《》traditional、/。「」/『』traditional-vertical︑/︒﹁﹂/﹃﹄In Rust the profile is
Builder::punctuation(PunctuationProfile::modern()); JavaScript bindings takepunctuation: "modern". A custom profile can start from a base and override individual roles, and is accepted as JSON from--punctuation-profile, from a JavaScript object, or throughPunctuationConfigin Rust with theserdefeature:{ "base": "modern", "period": null, "comma": { "mark": "、", "after": "remove" }, "single": { "open": "︿", "close": "﹀" } }Input recognition is shared across profiles, so mixed styles in one document converge on the chosen output: ASCII, fullwidth, ideographic, and vertical comma and period forms, and the
〈〉,《》,「」,『』delimiter families including their vertical and U+2329/U+232A spellings. The transformation favors technical text over consistency. Stops between digits, runs of periods, and periods next to Latin letters or digits are left alone, as are ASCII and fullwidth commas inside numbers or before Latin words, so3.14,1,000,example.com, andHello, worldsurvive whileIMF, 세계은행can still be normalized. URLs andmailto:runs are protected. There is no smart-quote, ellipsis, or dash handling, and unmatched delimiter pairs are not repaired.HTML gets its own handling. Named, decimal, and hexadecimal character references are recognized in text and normalize like literal characters, decoding happens exactly once so
.stays literal text, and untouched references keep their original spelling. Generated marks are escaped so they cannot recombine with adjacent source text into a different reference. Markdown needs more care, because changing punctuation can create a list, a heading, or a link, or break emphasis delimiters. With normalization enabled the writer escapes text with numeric references and falls back to local inline HTML where emphasis would otherwise be lost, which is why the Markdown source spelling changes while the rendered meaning does not:The feature, its guards, and the HTML and Markdown adapters are described in the punctuation guide. The proposal is in #27 and the implementation in #28.
Loanword spellings
The Standard Korean Dictionary maps
香港to 샹강, after the Mandarin reading. The dictionary does contain a홍콩entry, but it lists only the English original spelling, so no extraction rule could recover a香港 → 홍콩mapping from that snapshot. The loanword spelling examples record the hanja spelling directly, so the mapping is extractable.0.3.0 bundles the National Institute of Korean Language's loanword spelling examples as a separate dictionary. The source snapshot keeps all 67,303 records with provenance, and the generated dictionary has 3,249 keys, with eight explicit editorial decisions where the source offers competing readings. Dictionary precedence is user dictionaries first, then loanword examples, then the preset's other dictionaries.
The
ko-krpreset now includes this dictionary, in Rust and in the CLI, so香港converts to 홍콩 by default. Theko-kppreset leaves it off, since the source's South Korean spellings are the wrong norm for North Korean text; pass--loanwordsto enable it there.--no-loanwordsdisables only this dictionary and keeps the rest of the preset, and it cannot be combined with--no-bundled-dictionaries.For Rust, the umbrella crate's
loanwordsfeature is enabled by default,Preset::KoKrincludes the dictionary, andBuilder::bundled_loanwords()andBuilder::no_bundled_loanwords()control it explicitly. The data lives in its owngukhanmun-loanwordscrate, whose source can also be used directly as aHanjaDictionary.JavaScript presets never add dictionary data on their own, so the bindings reach the same data through two new packages:
@gukhanmun/loanwords-cdbprovides the same data in CDB format. Either loader returns a dictionary source that goes into thedictionariesarray, before the preset dictionaries:Installing a package does not change conversion by itself; the loader has to be in
dictionaries. The data's reuse terms are recorded in each package's ATTRIBUTION.md and are separate from the software license.The extraction, licensing review, and weekly update pipeline are in #18 and #26, and enabling the dictionary by default for
ko-kris #31.Other changes
*.*-maintenancebranch that ships the dictionary, keeping a separate pull request for main. Until a dictionary has shipped in a stable release it updates main only. (Bundle NIKL loanword spelling examples as a dictionary #18, Bundle NIKL loanwords with weekly data updates #26, Update bundled Standard Korean Dictionary and Open Korean Dictionary data automatically #29, Automate Standard Korean Dictionary and Open Korean Dictionary updates #30)Enginestreaming boundaries changed slightly: a sole non-block rootClosewith pending text is emitted at the next token, atflush(), or atfinish(), andfinish()now supplies end-of-file evidence thatflush()deliberately does not. Particle lookahead retains four extra source scalars beyond the dictionary tail allowance so a chunk boundary cannot change the chosen reading. (Filter dictionary readings using following Korean particles #20, Use Korean particles to select readings #25)MapDictionaryentries. (Preserve multiple dictionary readings and separate reading selection #19, Preserve competing readings until conversion #22)gukhanmun-dict-extractgained loanword collection and offline extraction commands behind theloanwords-collectandloanwords-clifeatures, with provenance reports and an audit mode. The commands needNIKL_LOANWORD_API_KEYonly for live collection; ordinary builds use the pinned snapshot. (Bundle NIKL loanword spelling examples as a dictionary #18, Bundle NIKL loanwords with weekly data updates #26)ko-krpreset #31)Upgrading and installing
Upgrading from 0.2.x
The
ko-krpreset now includes the loanword dictionary, which changes output for foreign names written in hanja:香港converts to 홍콩 rather than 샹강. To keep the previous answers, pass--no-loanwordson the CLI, callBuilder::no_bundled_loanwords()in Rust, or keep the JavaScript binding as it is, since its presets still load no dictionary data implicitly.Rust builds that use
--no-default-featureswith theko-krpreset must now either enable theloanwordsfeature or callBuilder::no_bundled_loanwords(); without the feature,build()fails withError::Configinstead of silently converting without the dictionary.Dictionary data files have a version. Bundled FST and CDB dictionaries that carry candidates or suffix readings are written as v2, and a reader from before this release rejects them rather than misreading them. Upgrade
@gukhanmun/wasmor@gukhanmun/napitogether with@gukhanmun/stdict-fst,@gukhanmun/stdict-cdb, and the Open Korean Dictionary packages. Scalar custom dictionaries built withgukhanmun-mkdictare still emitted as v1 and load in older readers.Beyond those, the defaults are unchanged: punctuation normalization stays opt-in, the hanja variant set defaults to
as-dictionary, and the CLI--numeralsdefault remainssmart.CLI
Install a prebuilt binary with mise:
On Windows, use
winget:Or build from source with Cargo:
Prebuilt archives for Linux, macOS, and Windows are attached to the 0.3.0 release on GitHub.
Rust
See the Rust installation guide for feature flag options, and the CHANGES.md section for the complete list of API additions.
JavaScript/TypeScript
See the JavaScript installation guide for npm, yarn, Bun, and Deno equivalents.
All reactions