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 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)