libkuraji 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- libkuraji/__init__.py +69 -0
- libkuraji/cli.py +113 -0
- libkuraji/jtalk_dic.py +384 -0
- libkuraji/kana.py +644 -0
- libkuraji/limits.py +36 -0
- libkuraji/mecab_correct.py +429 -0
- libkuraji/translator2.py +2047 -0
- libkuraji/unicodeutil.py +95 -0
- libkuraji-1.0.0.dist-info/METADATA +248 -0
- libkuraji-1.0.0.dist-info/RECORD +14 -0
- libkuraji-1.0.0.dist-info/WHEEL +5 -0
- libkuraji-1.0.0.dist-info/entry_points.txt +2 -0
- libkuraji-1.0.0.dist-info/licenses/LICENSE +29 -0
- libkuraji-1.0.0.dist-info/top_level.txt +1 -0
libkuraji/__init__.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# libkuraji
|
|
2
|
+
# Copyright (C) 2019-2026 Takuya Nishimoto
|
|
3
|
+
# License: BSD 3-Clause. See LICENSE.
|
|
4
|
+
|
|
5
|
+
from .kana import translate_with_pos
|
|
6
|
+
from .limits import InputTooLongError
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def translate(text: str, nabcc: bool = False) -> str:
|
|
10
|
+
"""Translate kana text (translator2 output) into braille cells."""
|
|
11
|
+
return translate_with_pos(text, nabcc=nabcc)[0]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def initialize(analyzer=None, logwrite=None):
|
|
15
|
+
"""Initialize the morphological analyzer for mixed-text (Kanji/Kana) translation.
|
|
16
|
+
|
|
17
|
+
If analyzer is None, a default JTalkDicAnalyzer (which uses fugashi and
|
|
18
|
+
automatically downloads the JTalk dictionary if needed) will be used.
|
|
19
|
+
"""
|
|
20
|
+
if analyzer is None:
|
|
21
|
+
try:
|
|
22
|
+
from .jtalk_dic import make_analyzer
|
|
23
|
+
analyzer = make_analyzer()
|
|
24
|
+
except ImportError as e:
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"The default analyzer requires 'fugashi' and dictionary dependencies. "
|
|
27
|
+
"Please run 'pip install libkuraji[integration]' to install them, "
|
|
28
|
+
"or pass a custom analyzer instance."
|
|
29
|
+
) from e
|
|
30
|
+
from . import translator2
|
|
31
|
+
translator2.initialize(analyzer=analyzer, logwrite=logwrite)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def translate_kanji(
|
|
35
|
+
text: str, cursorPos: int = 0, nabcc: bool = False, **kwargs
|
|
36
|
+
) -> tuple[str, list[int], list[int], int]:
|
|
37
|
+
"""Translate mixed Kanji/Kana Japanese text, returning braille and position maps.
|
|
38
|
+
|
|
39
|
+
If the analyzer has not been initialized, it will be automatically initialized
|
|
40
|
+
with the default JTalkDicAnalyzer.
|
|
41
|
+
|
|
42
|
+
Keyword arguments are forwarded to ``translator2.translate``. Notable options:
|
|
43
|
+
|
|
44
|
+
* ``unicodeIO`` (bool, default ``False``): If ``True``, return Unicode braille
|
|
45
|
+
(U+2800..U+28FF, blanks as U+0020), matching the CLI and liblouis
|
|
46
|
+
``dotsIO | ucBrl``. If ``False``, return liblouis ``dotsIO`` cells
|
|
47
|
+
(U+8000..U+80FF, blanks as U+2800), the inherited nvdajp translator2
|
|
48
|
+
default. See ``docs/encoding.md``.
|
|
49
|
+
"""
|
|
50
|
+
from . import translator2
|
|
51
|
+
if not translator2.mecab_initialized:
|
|
52
|
+
initialize()
|
|
53
|
+
return translator2.translate(text, cursorPos=cursorPos, nabcc=nabcc, **kwargs)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def terminate():
|
|
57
|
+
"""Terminate the morphological analyzer and clean up resources."""
|
|
58
|
+
from . import translator2
|
|
59
|
+
translator2.terminate()
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"translate",
|
|
64
|
+
"translate_with_pos",
|
|
65
|
+
"initialize",
|
|
66
|
+
"translate_kanji",
|
|
67
|
+
"terminate",
|
|
68
|
+
"InputTooLongError",
|
|
69
|
+
]
|
libkuraji/cli.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# libkuraji command line interface
|
|
2
|
+
# Copyright (C) 2026 Takuya Nishimoto
|
|
3
|
+
# License: BSD 3-Clause. See LICENSE.
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from typing import List
|
|
9
|
+
|
|
10
|
+
from .kana import translate_with_pos
|
|
11
|
+
from .limits import InputTooLongError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def has_kanji(text: str) -> bool:
|
|
15
|
+
"""Return True if the text contains any Kanji (CJK Unified Ideographs)."""
|
|
16
|
+
return any(0x4E00 <= ord(c) <= 0x9FCF or 0x3400 <= ord(c) <= 0x4DBF for c in text)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def make_out_pos(in_pos: List[int], in_len: int, out_len: int) -> List[int]:
|
|
20
|
+
"""Reconstruct outPos (input index -> output index mapping) from inPos."""
|
|
21
|
+
out_pos = [-1] * in_len
|
|
22
|
+
for p in range(out_len - 1, -1, -1):
|
|
23
|
+
if in_pos[p] < len(out_pos):
|
|
24
|
+
out_pos[in_pos[p]] = p
|
|
25
|
+
prev = 0
|
|
26
|
+
for p in range(in_len):
|
|
27
|
+
if out_pos[p] == -1:
|
|
28
|
+
out_pos[p] = prev
|
|
29
|
+
else:
|
|
30
|
+
prev = out_pos[p]
|
|
31
|
+
return out_pos
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def main(argv=None) -> int:
|
|
35
|
+
parser = argparse.ArgumentParser(
|
|
36
|
+
prog="kuraji",
|
|
37
|
+
description="Translate Japanese text into braille.",
|
|
38
|
+
)
|
|
39
|
+
parser.add_argument("text", nargs="*", help="text to translate; reads stdin if omitted")
|
|
40
|
+
parser.add_argument("--nabcc", action="store_true", help="use NABCC output mode")
|
|
41
|
+
|
|
42
|
+
group = parser.add_mutually_exclusive_group()
|
|
43
|
+
group.add_argument("-k", "--kana", action="store_true", help="force kana-only translation (disable MeCab)")
|
|
44
|
+
group.add_argument("-j", "--kanji", action="store_true", help="force mixed Kanji/Kana translation (requires MeCab)")
|
|
45
|
+
|
|
46
|
+
parser.add_argument("-p", "--positions", action="store_true", help="output position mapping JSON")
|
|
47
|
+
args = parser.parse_args(argv)
|
|
48
|
+
|
|
49
|
+
# Check if MeCab analyzer support is available
|
|
50
|
+
has_mecab = False
|
|
51
|
+
from . import translator2
|
|
52
|
+
if translator2.mecab_initialized:
|
|
53
|
+
has_mecab = True
|
|
54
|
+
else:
|
|
55
|
+
try:
|
|
56
|
+
import fugashi # noqa: F401
|
|
57
|
+
has_mecab = True
|
|
58
|
+
except ImportError:
|
|
59
|
+
pass
|
|
60
|
+
|
|
61
|
+
if args.kanji and not has_mecab:
|
|
62
|
+
print(
|
|
63
|
+
"Error: Mixed-text translation requires MeCab. Please run 'pip install libkuraji[integration]' to install it.",
|
|
64
|
+
file=sys.stderr,
|
|
65
|
+
)
|
|
66
|
+
return 1
|
|
67
|
+
|
|
68
|
+
lines = args.text if args.text else (line.rstrip("\n") for line in sys.stdin)
|
|
69
|
+
|
|
70
|
+
for line in lines:
|
|
71
|
+
try:
|
|
72
|
+
use_kanji = False
|
|
73
|
+
if args.kanji:
|
|
74
|
+
use_kanji = True
|
|
75
|
+
elif not args.kana and has_kanji(line):
|
|
76
|
+
if has_mecab:
|
|
77
|
+
use_kanji = True
|
|
78
|
+
else:
|
|
79
|
+
print(
|
|
80
|
+
"Error: Input contains Kanji but MeCab is not installed. "
|
|
81
|
+
"Please run 'pip install libkuraji[integration]' to install it, "
|
|
82
|
+
"or use -k/--kana to force kana-only translation.",
|
|
83
|
+
file=sys.stderr,
|
|
84
|
+
)
|
|
85
|
+
return 1
|
|
86
|
+
|
|
87
|
+
if use_kanji:
|
|
88
|
+
from . import translate_kanji
|
|
89
|
+
# translate_kanji returns (outbuf, inPos, outPos, cursorPos)
|
|
90
|
+
cells, in_pos, out_pos, _ = translate_kanji(line, nabcc=args.nabcc, unicodeIO=True)
|
|
91
|
+
else:
|
|
92
|
+
cells, in_pos = translate_with_pos(line, nabcc=args.nabcc)
|
|
93
|
+
out_pos = make_out_pos(in_pos, len(line), len(cells))
|
|
94
|
+
|
|
95
|
+
if args.positions:
|
|
96
|
+
out_data = {
|
|
97
|
+
"text": line,
|
|
98
|
+
"braille": cells,
|
|
99
|
+
"inPos": in_pos,
|
|
100
|
+
"outPos": out_pos,
|
|
101
|
+
}
|
|
102
|
+
print(json.dumps(out_data, ensure_ascii=False))
|
|
103
|
+
else:
|
|
104
|
+
print(cells)
|
|
105
|
+
except InputTooLongError as e:
|
|
106
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
107
|
+
return 1
|
|
108
|
+
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
if __name__ == "__main__":
|
|
113
|
+
sys.exit(main())
|
libkuraji/jtalk_dic.py
ADDED
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
# coding: UTF-8
|
|
2
|
+
# jtalk_dic.py
|
|
3
|
+
# Copyright (C) 2026 Takuya Nishimoto
|
|
4
|
+
# License: BSD 3-Clause. See LICENSE.
|
|
5
|
+
#
|
|
6
|
+
# MeCab + JTalk extended dictionary (NAIST-JDIC + nvdajp custom entries)
|
|
7
|
+
# adapter for translator2.
|
|
8
|
+
#
|
|
9
|
+
# The dictionary binaries (sys.dic, matrix.bin, char.bin, unk.dic, dicrc,
|
|
10
|
+
# DIC_VERSION) are NOT part of this source tree. They are built and published
|
|
11
|
+
# from the libkuraji-jtalk-dic repository as GitHub Release assets
|
|
12
|
+
# (jtalk-dic-v*.zip). This module downloads the zip for a pinned tag on
|
|
13
|
+
# demand, extracts it to a cache directory, and wraps a fugashi Tagger so it
|
|
14
|
+
# can be injected into translator2.initialize(analyzer=...).
|
|
15
|
+
#
|
|
16
|
+
# This is an OPTIONAL runtime path. The default libkuraji test suite uses
|
|
17
|
+
# a recorded fixture (mecabFixture.json) via ReplayAnalyzer and does not
|
|
18
|
+
# require MeCab, fugashi, or network access. Integration tests opt in via
|
|
19
|
+
# the LIBKURAJI_INTEGRATION=1 environment variable.
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import unicodedata
|
|
28
|
+
import zipfile
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Callable, List, Optional
|
|
31
|
+
|
|
32
|
+
# Pinned default release tag of libkuraji-jtalk-dic. Override with the
|
|
33
|
+
# LIBKURAJI_JTALK_DIC_TAG environment variable. Bump this when a new
|
|
34
|
+
# dictionary release is validated against the harness.
|
|
35
|
+
DEFAULT_DIC_TAG = "v1.1.7"
|
|
36
|
+
|
|
37
|
+
# Owner/repo of the dictionary release.
|
|
38
|
+
DIC_REPO = "nishimotz/libkuraji-jtalk-dic"
|
|
39
|
+
|
|
40
|
+
# Asset name pattern for the dictionary zip. Must match release-dic.yml
|
|
41
|
+
# in libkuraji-jtalk-dic.
|
|
42
|
+
_DIC_ZIP_NAME = "jtalk-dic-{tag}.zip"
|
|
43
|
+
_DIC_ZIP_SHA_NAME = "jtalk-dic-{tag}.zip.sha256"
|
|
44
|
+
_DIC_TAG_RE = re.compile(r"^v\d+\.\d+\.\d+$")
|
|
45
|
+
_DOWNLOAD_TIMEOUT_SEC = 120
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
# Halfwidth-to-fullwidth conversion for the MeCab input pipeline.
|
|
49
|
+
#
|
|
50
|
+
# The JTalk extended dictionary (NAIST-JDIC + nvdajp custom entries) was
|
|
51
|
+
# built with fullwidth ASCII characters as its unknown-word (unk) class
|
|
52
|
+
# definitions, so MeCab must be fed fullwidth input to reproduce the
|
|
53
|
+
# analysis recorded in tests/mecabFixture.json. This is the text2mecab
|
|
54
|
+
# conversion originally written by Takuya Nishimoto for python-jtalk,
|
|
55
|
+
# re-licensed to BSD 3-Clause for libkuraji.
|
|
56
|
+
#
|
|
57
|
+
# The dedicated fullwidth block U+FF01..U+FF5E mirrors ASCII U+0021..U+007E
|
|
58
|
+
# one-to-one. The ASCII space (U+0020) maps to U+3000. Three characters
|
|
59
|
+
# have special targets that differ from the naive fullwidth mapping:
|
|
60
|
+
# - "-" (HYPHEN-MINUS) -> U+2212 MINUS SIGN
|
|
61
|
+
# - "~" (TILDE) -> U+301C WAVE DASH
|
|
62
|
+
# - "\" (REVERSE SOLIDUS) -> U+FFE5 FULLWIDTH YEN SIGN
|
|
63
|
+
# - U+FFFD REPLACEMENT CHARACTER -> fullwidth question mark
|
|
64
|
+
_HALF_TO_FULL: dict[str, str] = {}
|
|
65
|
+
for _c in range(0x21, 0x7F): # '!' .. '~'
|
|
66
|
+
_HALF_TO_FULL[chr(_c)] = chr(_c + 0xFEE0)
|
|
67
|
+
_HALF_TO_FULL[" "] = "\u3000" # fullwidth space
|
|
68
|
+
_HALF_TO_FULL["-"] = "\u2212" # MINUS SIGN
|
|
69
|
+
_HALF_TO_FULL["~"] = "\u301C" # WAVE DASH
|
|
70
|
+
_HALF_TO_FULL["\\"] = "\uFFE5" # FULLWIDTH YEN SIGN
|
|
71
|
+
_HALF_TO_FULL["\ufffd"] = "\uff1f" # fullwidth question mark
|
|
72
|
+
_HALF2FULL_TABLE = str.maketrans(_HALF_TO_FULL)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _to_mecab_text(text: str) -> str:
|
|
76
|
+
"""Normalize text the way the JTalk dictionary expects.
|
|
77
|
+
|
|
78
|
+
NFKC-normalize (which folds compatibility characters down to their
|
|
79
|
+
halfwidth ASCII form), then widen ASCII to the fullwidth block the
|
|
80
|
+
dictionary's unk classes were built against.
|
|
81
|
+
"""
|
|
82
|
+
text = unicodedata.normalize("NFKC", text)
|
|
83
|
+
return text.translate(_HALF2FULL_TABLE)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _env_truthy(name: str) -> bool:
|
|
87
|
+
v = os.environ.get(name, "").strip().lower()
|
|
88
|
+
return v in ("1", "true", "yes", "on")
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _validate_dic_tag(tag: str) -> str:
|
|
92
|
+
tag = tag.strip()
|
|
93
|
+
if not _DIC_TAG_RE.fullmatch(tag):
|
|
94
|
+
raise ValueError(f"invalid dictionary release tag: {tag!r}")
|
|
95
|
+
return tag
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_dic_tag() -> str:
|
|
99
|
+
"""Return the dictionary release tag to use."""
|
|
100
|
+
raw = os.environ.get("LIBKURAJI_JTALK_DIC_TAG", DEFAULT_DIC_TAG).strip()
|
|
101
|
+
return _validate_dic_tag(raw or DEFAULT_DIC_TAG)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_cache_dir() -> Path:
|
|
105
|
+
"""Return the cache directory used to store downloaded dictionaries.
|
|
106
|
+
|
|
107
|
+
Override with LIBKURAJI_JTALK_DIC_DIR (points directly at an already
|
|
108
|
+
extracted dictionary directory, skipping download entirely).
|
|
109
|
+
"""
|
|
110
|
+
override = os.environ.get("LIBKURAJI_JTALK_DIC_DIR", "").strip()
|
|
111
|
+
if override:
|
|
112
|
+
return Path(override)
|
|
113
|
+
base = os.environ.get("LIBKURAJI_CACHE_DIR", "").strip()
|
|
114
|
+
if base:
|
|
115
|
+
return Path(base) / "libkuraji-jtalk-dic"
|
|
116
|
+
# ~/.cache/libkuraji-jtalk-dic on POSIX, %LOCALAPPDATA% on Windows.
|
|
117
|
+
if sys.platform == "win32":
|
|
118
|
+
local = os.environ.get("LOCALAPPDATA")
|
|
119
|
+
if local:
|
|
120
|
+
return Path(local) / "libkuraji-jtalk-dic"
|
|
121
|
+
return Path.home() / ".cache" / "libkuraji-jtalk-dic"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _gh_available() -> bool:
|
|
125
|
+
return shutil.which("gh") is not None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _parse_sha256_digest(content: str) -> str:
|
|
129
|
+
line = content.strip().splitlines()[0]
|
|
130
|
+
return line.split()[0].lower()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _verify_zip_sha256(zip_path: Path, expected_digest: str) -> None:
|
|
134
|
+
digest = hashlib.sha256()
|
|
135
|
+
with zip_path.open("rb") as f:
|
|
136
|
+
for chunk in iter(lambda: f.read(65536), b""):
|
|
137
|
+
digest.update(chunk)
|
|
138
|
+
actual = digest.hexdigest().lower()
|
|
139
|
+
expected = expected_digest.lower()
|
|
140
|
+
if actual != expected:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"SHA-256 mismatch for {zip_path.name}: expected {expected}, got {actual}"
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _safe_extract_zip(zf: zipfile.ZipFile, dest: Path) -> None:
|
|
147
|
+
dest_resolved = dest.resolve()
|
|
148
|
+
for member in zf.namelist():
|
|
149
|
+
target = (dest / member).resolve()
|
|
150
|
+
if not target.is_relative_to(dest_resolved):
|
|
151
|
+
raise RuntimeError(f"unsafe zip member path: {member!r}")
|
|
152
|
+
zf.extractall(dest)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _log_subprocess_output(
|
|
156
|
+
logwrite: Optional[Callable[[str], None]],
|
|
157
|
+
prefix: str,
|
|
158
|
+
data: bytes | None,
|
|
159
|
+
) -> None:
|
|
160
|
+
if not logwrite or not data:
|
|
161
|
+
return
|
|
162
|
+
text = data.decode("utf-8", errors="replace").strip()
|
|
163
|
+
if text:
|
|
164
|
+
logwrite(f"{prefix}: {text}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _download_assets(
|
|
168
|
+
tag: str,
|
|
169
|
+
cache: Path,
|
|
170
|
+
zip_name: str,
|
|
171
|
+
sha_name: str,
|
|
172
|
+
logwrite: Optional[Callable[[str], None]] = None,
|
|
173
|
+
) -> None:
|
|
174
|
+
zip_path = cache / zip_name
|
|
175
|
+
sha_path = cache / sha_name
|
|
176
|
+
downloaded = False
|
|
177
|
+
if _gh_available():
|
|
178
|
+
try:
|
|
179
|
+
result = subprocess.run(
|
|
180
|
+
[
|
|
181
|
+
"gh", "release", "download", tag,
|
|
182
|
+
"--repo", DIC_REPO,
|
|
183
|
+
"--pattern", zip_name,
|
|
184
|
+
"--pattern", sha_name,
|
|
185
|
+
"--dir", str(cache),
|
|
186
|
+
"--clobber",
|
|
187
|
+
],
|
|
188
|
+
check=True,
|
|
189
|
+
capture_output=True,
|
|
190
|
+
)
|
|
191
|
+
downloaded = True
|
|
192
|
+
_log_subprocess_output(logwrite, "gh download stdout", result.stdout)
|
|
193
|
+
except subprocess.CalledProcessError as exc:
|
|
194
|
+
if logwrite:
|
|
195
|
+
logwrite(f"gh download failed (exit {exc.returncode}); falling back to GitHub REST API")
|
|
196
|
+
_log_subprocess_output(logwrite, "gh download stdout", exc.stdout)
|
|
197
|
+
_log_subprocess_output(logwrite, "gh download stderr", exc.stderr)
|
|
198
|
+
except FileNotFoundError:
|
|
199
|
+
if logwrite:
|
|
200
|
+
logwrite("gh CLI not found; falling back to GitHub REST API")
|
|
201
|
+
|
|
202
|
+
if not downloaded:
|
|
203
|
+
_download_asset_via_rest(tag, zip_name, zip_path)
|
|
204
|
+
_download_asset_via_rest(tag, sha_name, sha_path)
|
|
205
|
+
|
|
206
|
+
if not sha_path.exists():
|
|
207
|
+
raise RuntimeError(f"SHA-256 sidecar not found for {zip_name}")
|
|
208
|
+
expected_digest = _parse_sha256_digest(sha_path.read_text(encoding="utf-8"))
|
|
209
|
+
_verify_zip_sha256(zip_path, expected_digest)
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def download_dic(
|
|
213
|
+
tag: Optional[str] = None,
|
|
214
|
+
force: bool = False,
|
|
215
|
+
logwrite: Optional[Callable[[str], None]] = None,
|
|
216
|
+
) -> Path:
|
|
217
|
+
"""Download and extract the dictionary zip for the given release tag.
|
|
218
|
+
|
|
219
|
+
Returns the path to the extracted dictionary directory (the one
|
|
220
|
+
containing sys.dic). Uses the gh CLI if available, otherwise falls
|
|
221
|
+
back to the GitHub REST API via urllib.
|
|
222
|
+
"""
|
|
223
|
+
tag = _validate_dic_tag(tag or get_dic_tag())
|
|
224
|
+
cache = get_cache_dir()
|
|
225
|
+
# When LIBKURAJI_JTALK_DIC_DIR points directly at a dictionary dir,
|
|
226
|
+
# cache == that dir and we must not try to "extract" into it.
|
|
227
|
+
if os.environ.get("LIBKURAJI_JTALK_DIC_DIR", "").strip():
|
|
228
|
+
if (cache / "sys.dic").exists():
|
|
229
|
+
return cache
|
|
230
|
+
raise FileNotFoundError(
|
|
231
|
+
f"LIBKURAJI_JTALK_DIC_DIR={cache} does not contain sys.dic"
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
extracted = cache / tag
|
|
235
|
+
if (extracted / "sys.dic").exists() and not force:
|
|
236
|
+
return extracted
|
|
237
|
+
|
|
238
|
+
cache.mkdir(parents=True, exist_ok=True)
|
|
239
|
+
zip_name = _DIC_ZIP_NAME.format(tag=tag)
|
|
240
|
+
sha_name = _DIC_ZIP_SHA_NAME.format(tag=tag)
|
|
241
|
+
zip_path = cache / zip_name
|
|
242
|
+
|
|
243
|
+
if not zip_path.exists() or force:
|
|
244
|
+
_download_assets(tag, cache, zip_name, sha_name, logwrite=logwrite)
|
|
245
|
+
else:
|
|
246
|
+
sha_path = cache / sha_name
|
|
247
|
+
if not sha_path.exists():
|
|
248
|
+
_download_asset_via_rest(tag, sha_name, sha_path)
|
|
249
|
+
expected_digest = _parse_sha256_digest(sha_path.read_text(encoding="utf-8"))
|
|
250
|
+
_verify_zip_sha256(zip_path, expected_digest)
|
|
251
|
+
|
|
252
|
+
with zipfile.ZipFile(zip_path) as zf:
|
|
253
|
+
_safe_extract_zip(zf, extracted)
|
|
254
|
+
|
|
255
|
+
dicrc_path = extracted / "dicrc"
|
|
256
|
+
if dicrc_path.exists():
|
|
257
|
+
try:
|
|
258
|
+
content = dicrc_path.read_bytes()
|
|
259
|
+
lf_content = content.replace(b"\r\n", b"\n")
|
|
260
|
+
if lf_content != content:
|
|
261
|
+
dicrc_path.write_bytes(lf_content)
|
|
262
|
+
except Exception:
|
|
263
|
+
pass
|
|
264
|
+
|
|
265
|
+
if not (extracted / "sys.dic").exists():
|
|
266
|
+
raise RuntimeError(
|
|
267
|
+
f"sys.dic not found in extracted {zip_name}; archive layout changed?"
|
|
268
|
+
)
|
|
269
|
+
return extracted
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _download_asset_via_rest(tag: str, asset_name: str, dest: Path) -> None:
|
|
273
|
+
import json
|
|
274
|
+
import urllib.parse
|
|
275
|
+
import urllib.request
|
|
276
|
+
|
|
277
|
+
tag_quoted = urllib.parse.quote(tag, safe="")
|
|
278
|
+
url = f"https://api.github.com/repos/{DIC_REPO}/releases/tags/{tag_quoted}"
|
|
279
|
+
with urllib.request.urlopen(url, timeout=_DOWNLOAD_TIMEOUT_SEC) as r: # noqa: S310
|
|
280
|
+
release = json.loads(r.read().decode("utf-8"))
|
|
281
|
+
asset_url = None
|
|
282
|
+
for a in release.get("assets", []):
|
|
283
|
+
if a.get("name") == asset_name:
|
|
284
|
+
asset_url = a.get("browser_download_url")
|
|
285
|
+
break
|
|
286
|
+
if not asset_url:
|
|
287
|
+
raise RuntimeError(f"asset {asset_name} not found in release {tag}")
|
|
288
|
+
with urllib.request.urlopen(asset_url, timeout=_DOWNLOAD_TIMEOUT_SEC) as r, dest.open("wb") as f: # noqa: S310
|
|
289
|
+
shutil.copyfileobj(r, f)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class JTalkDicAnalyzer:
|
|
293
|
+
"""Morphological analyzer backed by MeCab (via fugashi) and the
|
|
294
|
+
JTalk extended dictionary.
|
|
295
|
+
|
|
296
|
+
Implements the small contract expected by translator2.initialize():
|
|
297
|
+
- is_ready() -> bool
|
|
298
|
+
- analyze(text, logwrite=None) -> list[str] (MeCab feature lines)
|
|
299
|
+
|
|
300
|
+
The feature lines are returned in the CSV form translator2 expects
|
|
301
|
+
(surface + 13+ comma-separated feature fields), matching the format
|
|
302
|
+
emitted by the JTalk extended dictionary's braille-notation field.
|
|
303
|
+
"""
|
|
304
|
+
|
|
305
|
+
def __init__(self, dic_dir: Path, logwrite: Optional[Callable[[str], None]] = None):
|
|
306
|
+
self.dic_dir = Path(dic_dir)
|
|
307
|
+
self._log = logwrite or (lambda s: None)
|
|
308
|
+
self._tagger = None
|
|
309
|
+
try:
|
|
310
|
+
from fugashi import GenericTagger # type: ignore
|
|
311
|
+
except ImportError as e:
|
|
312
|
+
raise ImportError(
|
|
313
|
+
"fugashi is required for JTalkDicAnalyzer; "
|
|
314
|
+
"install with `pip install -e .[integration]`"
|
|
315
|
+
) from e
|
|
316
|
+
# fugashi.Tagger (the Unidic variant) rejects dictionaries whose
|
|
317
|
+
# feature field count doesn't match Unidic's schema, so we use the
|
|
318
|
+
# GenericTagger which accepts any feature layout.
|
|
319
|
+
#
|
|
320
|
+
# fugashi wraps the user arg with a forced `-C` and runs it through
|
|
321
|
+
# shlex.split, which mangles backslashes on Windows. Always pass
|
|
322
|
+
# forward-slash paths, and give MeCab an explicit `-r <dicrc>` so it
|
|
323
|
+
# doesn't fall back to the system default (`c:\mecab\mecabrc`).
|
|
324
|
+
d = str(self.dic_dir).replace("\\", "/")
|
|
325
|
+
arg = f"-r {d}/dicrc -d {d}"
|
|
326
|
+
self._tagger = GenericTagger(arg)
|
|
327
|
+
self._log(f"JTalkDicAnalyzer initialized with dic_dir={self.dic_dir}")
|
|
328
|
+
|
|
329
|
+
def is_ready(self) -> bool:
|
|
330
|
+
return self._tagger is not None and (self.dic_dir / "sys.dic").exists()
|
|
331
|
+
|
|
332
|
+
def _reparse(self, text: str) -> List[str]:
|
|
333
|
+
"""Re-parse a single character to fill missing readings.
|
|
334
|
+
|
|
335
|
+
Used by correct_features (PATTERN 1/2) for unknown words whose
|
|
336
|
+
reading is empty. The input is widened the same way as analyze().
|
|
337
|
+
"""
|
|
338
|
+
text = _to_mecab_text(text)
|
|
339
|
+
out: list[str] = []
|
|
340
|
+
for word in self._tagger(text):
|
|
341
|
+
out.append(f"{word.surface},{word.feature_raw}")
|
|
342
|
+
return out
|
|
343
|
+
|
|
344
|
+
def analyze(self, text: str, logwrite: Optional[Callable[[str], None]] = None) -> List[str]:
|
|
345
|
+
if self._tagger is None:
|
|
346
|
+
raise RuntimeError("JTalkDicAnalyzer: tagger not initialized")
|
|
347
|
+
log = logwrite or self._log
|
|
348
|
+
# The JTalk dictionary's unknown-word classes are defined for
|
|
349
|
+
# fullwidth ASCII, so we widen the input before handing it to
|
|
350
|
+
# MeCab. This matches the analysis recorded in mecabFixture.json.
|
|
351
|
+
text = _to_mecab_text(text)
|
|
352
|
+
# GenericTagger exposes `feature_raw` (CSV feature string) and
|
|
353
|
+
# `surface`. translator2.mecab_to_morphs expects each line to start
|
|
354
|
+
# with the surface, followed by the comma-separated feature fields,
|
|
355
|
+
# matching the format recorded in mecabFixture.json.
|
|
356
|
+
raw_lines: list[str] = []
|
|
357
|
+
for word in self._tagger(text):
|
|
358
|
+
surface = word.surface
|
|
359
|
+
raw = word.feature_raw
|
|
360
|
+
raw_lines.append(f"{surface},{raw}")
|
|
361
|
+
log(f"mecab: {raw_lines[-1]}")
|
|
362
|
+
# Post-process (correct) the features to match the reference
|
|
363
|
+
# analysis. The correction may re-parse individual characters to
|
|
364
|
+
# fill missing readings for unknown words.
|
|
365
|
+
from .mecab_correct import correct_features
|
|
366
|
+
return correct_features(raw_lines, reparse=self._reparse)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def make_analyzer(
|
|
370
|
+
tag: Optional[str] = None,
|
|
371
|
+
logwrite: Optional[Callable[[str], None]] = None,
|
|
372
|
+
) -> JTalkDicAnalyzer:
|
|
373
|
+
"""Convenience: download (if needed) and return a ready analyzer.
|
|
374
|
+
|
|
375
|
+
Requires LIBKURAJI_INTEGRATION=1 to be set; otherwise raises so that
|
|
376
|
+
accidental calls in the default test path fail loudly.
|
|
377
|
+
"""
|
|
378
|
+
if not _env_truthy("LIBKURAJI_INTEGRATION"):
|
|
379
|
+
raise RuntimeError(
|
|
380
|
+
"JTalk dictionary integration is opt-in; "
|
|
381
|
+
"set LIBKURAJI_INTEGRATION=1 to enable."
|
|
382
|
+
)
|
|
383
|
+
dic_dir = download_dic(tag, logwrite=logwrite)
|
|
384
|
+
return JTalkDicAnalyzer(dic_dir, logwrite=logwrite)
|