offline-translate 0.1.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.
- offline_translate/__init__.py +12 -0
- offline_translate/catalog.py +172 -0
- offline_translate/engine.py +118 -0
- offline_translate/languages.py +41 -0
- offline_translate/py.typed +0 -0
- offline_translate/translator.py +240 -0
- offline_translate-0.1.0.dist-info/METADATA +136 -0
- offline_translate-0.1.0.dist-info/RECORD +10 -0
- offline_translate-0.1.0.dist-info/WHEEL +4 -0
- offline_translate-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Offline translation with Google Translate's on-device language packs.
|
|
2
|
+
|
|
3
|
+
from offline_translate import Translator
|
|
4
|
+
t = Translator("~/models")
|
|
5
|
+
t.download("he")
|
|
6
|
+
t.translate("Hello", target="he").text
|
|
7
|
+
"""
|
|
8
|
+
from .languages import NAMES, normalize
|
|
9
|
+
from .translator import Language, TranslateError, Translation, Translator
|
|
10
|
+
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
|
+
__all__ = ["Translator", "Translation", "Language", "TranslateError", "NAMES", "normalize", "__version__"]
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Google Translate offline package catalog.
|
|
2
|
+
|
|
3
|
+
How the Android app finds packs (reverse engineered):
|
|
4
|
+
1. Manifest https://dl.google.com/translate/offline/manifest/gtr/<channel>/manifest.pb
|
|
5
|
+
lists every package-profile version (+ sha1/size). The channel comes from a server flag ("prod").
|
|
6
|
+
2. Profile https://dl.google.com/translate/offline/package_profile/<maj>/<min>/<patch>/translate_premium.pb.zlib
|
|
7
|
+
lists every language pack, its packages (nmt models, dictionary, translit) and their files.
|
|
8
|
+
3. Files https://dl.google.com/translate/offline/nmt/<ver>/premium/<src>_<tgt>/0/<file>.zlib
|
|
9
|
+
"""
|
|
10
|
+
import struct
|
|
11
|
+
import urllib.request
|
|
12
|
+
import zlib
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
BASE = "https://dl.google.com/translate/offline"
|
|
16
|
+
MANIFEST_URL = BASE + "/manifest/gtr/{channel}/manifest.pb"
|
|
17
|
+
USER_AGENT = "Mozilla/5.0"
|
|
18
|
+
|
|
19
|
+
PKG_NMT = 3
|
|
20
|
+
# Files our inference engine knows how to run; packages with other layouts are skipped.
|
|
21
|
+
NMT_FILES = {
|
|
22
|
+
"encoder_0.tflite", "decoder_init_0.tflite", "decoder_step_0.tflite",
|
|
23
|
+
"src_spm_model.model", "tgt_spm_model.model",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
# --- minimal protobuf reader (Google's schema isn't public) ---
|
|
28
|
+
def _varint(b, i):
|
|
29
|
+
r = s = 0
|
|
30
|
+
while True:
|
|
31
|
+
c = b[i]
|
|
32
|
+
i += 1
|
|
33
|
+
r |= (c & 0x7F) << s
|
|
34
|
+
s += 7
|
|
35
|
+
if c < 0x80:
|
|
36
|
+
return r, i
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def parse_pb(b):
|
|
40
|
+
"""Parse a protobuf message into {field: [values]}; length-delimited values stay bytes."""
|
|
41
|
+
out, i = {}, 0
|
|
42
|
+
while i < len(b):
|
|
43
|
+
key, i = _varint(b, i)
|
|
44
|
+
f, t = key >> 3, key & 7
|
|
45
|
+
if t == 0:
|
|
46
|
+
v, i = _varint(b, i)
|
|
47
|
+
elif t == 2:
|
|
48
|
+
n, i = _varint(b, i)
|
|
49
|
+
v, i = b[i:i + n], i + n
|
|
50
|
+
elif t == 5:
|
|
51
|
+
v, i = struct.unpack("<f", b[i:i + 4])[0], i + 4
|
|
52
|
+
elif t == 1:
|
|
53
|
+
v, i = struct.unpack("<d", b[i:i + 8])[0], i + 8
|
|
54
|
+
else:
|
|
55
|
+
raise ValueError(f"unsupported protobuf wire type {t}")
|
|
56
|
+
out.setdefault(f, []).append(v)
|
|
57
|
+
return out
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _str(m, f):
|
|
61
|
+
return m[f][0].decode()
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _hash(m):
|
|
65
|
+
"""{1: {1: algo, 2: hex}, 2: size} -> (hex, size)."""
|
|
66
|
+
return _str(parse_pb(m[1][0]), 2), m[2][0]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def fetch(url, timeout=60):
|
|
70
|
+
req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
|
|
71
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
72
|
+
return r.read()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def open_url(url, timeout=60):
|
|
76
|
+
return urllib.request.urlopen(urllib.request.Request(url, headers={"User-Agent": USER_AGENT}), timeout=timeout)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass(frozen=True)
|
|
80
|
+
class ProfileRef:
|
|
81
|
+
version: tuple
|
|
82
|
+
url: str
|
|
83
|
+
sha1: str # of the decompressed profile
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def version_str(self):
|
|
87
|
+
return ".".join(map(str, self.version))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_manifest(data):
|
|
91
|
+
"""Returns the premium (NMT) package profiles listed in a manifest, oldest first."""
|
|
92
|
+
refs = []
|
|
93
|
+
groups = parse_pb(data).get(2, [])
|
|
94
|
+
for raw in (r for g in groups for r in parse_pb(g).get(2, [])):
|
|
95
|
+
m = parse_pb(raw)
|
|
96
|
+
url = _str(m, 2)
|
|
97
|
+
if "translate_premium" not in url:
|
|
98
|
+
continue
|
|
99
|
+
v = parse_pb(m[1][0])
|
|
100
|
+
version = tuple(v.get(k, [0])[0] for k in (1, 2, 3))
|
|
101
|
+
refs.append(ProfileRef(version, url, _hash(parse_pb(m[3][0]))[0]))
|
|
102
|
+
return sorted(refs, key=lambda r: r.version)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True)
|
|
106
|
+
class PackFile:
|
|
107
|
+
name: str # file name on disk, e.g. encoder_0.tflite
|
|
108
|
+
url: str
|
|
109
|
+
sha1: str # of the decompressed file
|
|
110
|
+
size: int # decompressed bytes
|
|
111
|
+
download_size: int # zlib-compressed bytes
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@dataclass(frozen=True)
|
|
115
|
+
class Package:
|
|
116
|
+
name: str # e.g. "en_iw" (direction src_tgt)
|
|
117
|
+
files: tuple
|
|
118
|
+
|
|
119
|
+
@property
|
|
120
|
+
def source(self):
|
|
121
|
+
return self.name.split("_", 1)[0]
|
|
122
|
+
|
|
123
|
+
@property
|
|
124
|
+
def target(self):
|
|
125
|
+
return self.name.split("_", 1)[1]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@dataclass
|
|
129
|
+
class Pack:
|
|
130
|
+
"""A downloadable language: both directions to/from English."""
|
|
131
|
+
lang: str
|
|
132
|
+
packages: list = field(default_factory=list)
|
|
133
|
+
# False for older packs (nmt/8/1, WordPiece tokenizer) that the engine can't run yet.
|
|
134
|
+
supported: bool = True
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def download_size(self):
|
|
138
|
+
return sum(f.download_size for p in self.packages for f in p.files)
|
|
139
|
+
|
|
140
|
+
@property
|
|
141
|
+
def size(self):
|
|
142
|
+
return sum(f.size for p in self.packages for f in p.files)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def parse_profile(data):
|
|
146
|
+
"""Returns {lang_code: Pack} for every language with an NMT model (see Pack.supported)."""
|
|
147
|
+
top = parse_pb(data)
|
|
148
|
+
files = {}
|
|
149
|
+
for raw in top.get(3, []):
|
|
150
|
+
m = parse_pb(raw)
|
|
151
|
+
sha1, size = _hash(parse_pb(m[3][0]))
|
|
152
|
+
_, zsize = _hash(parse_pb(m[4][0]))
|
|
153
|
+
files[_str(m, 1)] = PackFile(_str(m, 5), _str(m, 2), sha1, size, zsize)
|
|
154
|
+
|
|
155
|
+
packages = {}
|
|
156
|
+
for raw in top.get(2, []):
|
|
157
|
+
m = parse_pb(raw)
|
|
158
|
+
if m[2][0] != PKG_NMT:
|
|
159
|
+
continue
|
|
160
|
+
pfiles = tuple(files[f.decode()] for f in m.get(4, []))
|
|
161
|
+
packages[_str(m, 1)] = Package(_str(m, 1).removesuffix("_premium_nmt"), pfiles)
|
|
162
|
+
|
|
163
|
+
packs = {}
|
|
164
|
+
for raw in top.get(1, []):
|
|
165
|
+
m = parse_pb(raw)
|
|
166
|
+
langs = [_str(parse_pb(x), 2) for x in parse_pb(m[3][0])[1]]
|
|
167
|
+
lang = next((l for l in langs if l != "en"), None)
|
|
168
|
+
pkgs = [packages[p.decode()] for p in m.get(2, []) if p.decode() in packages]
|
|
169
|
+
if lang and pkgs:
|
|
170
|
+
ok = all(NMT_FILES <= {f.name for f in p.files} for p in pkgs)
|
|
171
|
+
packs[lang] = Pack(lang, pkgs, ok)
|
|
172
|
+
return packs
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""Runs one direction of a Google Translate on-device NMT model (TFLite encoder + LSTM decoder)."""
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3")
|
|
7
|
+
|
|
8
|
+
import numpy as np
|
|
9
|
+
import sentencepiece as spm
|
|
10
|
+
from ai_edge_litert.interpreter import Interpreter
|
|
11
|
+
|
|
12
|
+
BOS, EOS = 1, 2
|
|
13
|
+
STATE_KEYS = [f"beam_state:rnn_state_{i}_{x}:00" for i in (0, 1) for x in ("c", "m")]
|
|
14
|
+
# The models are trained on single sentences; split on sentence-final punctuation + whitespace.
|
|
15
|
+
SENT_RE = re.compile(r"(?<=[.!?…。!?])\s+")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def split_sentences(text):
|
|
19
|
+
"""Lines of sentences: line breaks are kept, each line is split into sentences."""
|
|
20
|
+
return [[s for s in SENT_RE.split(line.strip()) if s] for line in text.split("\n")]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
FINAL_PUNCT = ".!?…。!?:;\"'”’)»"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def add_period_limit(processor_spec):
|
|
27
|
+
"""max_sent_len of the pack's PreprocAddPeriod step, or None if the pack doesn't use it."""
|
|
28
|
+
m = re.search(rb"PreprocAddPeriod/[^:]*:max_sent_len=(\d+)", processor_spec)
|
|
29
|
+
return int(m.group(1)) if m else None
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def with_period(sentence, limit):
|
|
33
|
+
"""Short sentences without final punctuation translate badly ("car" -> "tram"); the app adds
|
|
34
|
+
a period first. Returns (text to translate, whether a period was added)."""
|
|
35
|
+
if limit and len(sentence.split()) <= limit and sentence[-1:] not in FINAL_PUNCT:
|
|
36
|
+
return sentence + ".", True
|
|
37
|
+
return sentence, False
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def strip_period(translation):
|
|
41
|
+
return translation[:-1] if translation[-1:] in ".。" else translation
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class NmtModel:
|
|
45
|
+
def __init__(self, path):
|
|
46
|
+
path = Path(path)
|
|
47
|
+
self.src_sp = spm.SentencePieceProcessor(model_file=str(path / "src_spm_model.model"))
|
|
48
|
+
self.tgt_sp = spm.SentencePieceProcessor(model_file=str(path / "tgt_spm_model.model"))
|
|
49
|
+
runner = lambda name: Interpreter(model_path=str(path / f"{name}.tflite")).get_signature_runner()
|
|
50
|
+
self.encoder = runner("encoder_0")
|
|
51
|
+
self.dec_init = runner("decoder_init_0")
|
|
52
|
+
self.dec_step = runner("decoder_step_0")
|
|
53
|
+
spec = path / "processor_spec.pb"
|
|
54
|
+
self.period_limit = add_period_limit(spec.read_bytes()) if spec.exists() else None
|
|
55
|
+
|
|
56
|
+
def translate(self, text, beam=4):
|
|
57
|
+
return "\n".join(" ".join(self.translate_sentence(s, beam=beam) for s in line)
|
|
58
|
+
for line in split_sentences(text))
|
|
59
|
+
|
|
60
|
+
def translate_sentence(self, text, beam=4):
|
|
61
|
+
text, added = with_period(text, self.period_limit)
|
|
62
|
+
out = self._decode(text, beam=beam)
|
|
63
|
+
return strip_period(out) if added else out
|
|
64
|
+
|
|
65
|
+
def _decode(self, text, beam=4, max_len=72, alpha=1.0):
|
|
66
|
+
# Source vocabularies are lowercase-only; the model restores casing on output.
|
|
67
|
+
ids = self.src_sp.encode(text.lower()) + [EOS]
|
|
68
|
+
src = np.array([ids], np.int32)
|
|
69
|
+
pad = np.zeros_like(src, np.float32)
|
|
70
|
+
encs = self.encoder(**{"feeds:src_ids:00": src, "feeds:src_paddings:00": pad})["fetches:src_encs:00"]
|
|
71
|
+
init = self.dec_init(**{
|
|
72
|
+
"feeds:num_hyps:00": np.array([1], np.int32),
|
|
73
|
+
"feeds:src_enc_paddings:00": pad,
|
|
74
|
+
"feeds:src_encs:00": encs,
|
|
75
|
+
})
|
|
76
|
+
vecs = init["atten_init:source_vecs:00"]
|
|
77
|
+
state = {k: np.zeros((1, 512), np.float32) for k in STATE_KEYS}
|
|
78
|
+
state["beam_state:atten_context:00"] = init["beam_state:atten_context:00"]
|
|
79
|
+
|
|
80
|
+
# Each live hypothesis: (tokens, cumulative log prob). Batch rows of `state` align with `hyps`.
|
|
81
|
+
hyps = [([], 0.0)]
|
|
82
|
+
done = []
|
|
83
|
+
max_len = min(max_len, 2 * len(ids) + 10)
|
|
84
|
+
for _ in range(max_len):
|
|
85
|
+
n = len(hyps)
|
|
86
|
+
last = np.array([[t[-1] if t else BOS] for t, _ in hyps], np.int32)
|
|
87
|
+
out = self.dec_step(**{
|
|
88
|
+
"atten_init:source_contexts:00": np.repeat(vecs, n, 0),
|
|
89
|
+
"atten_init:source_vecs:00": np.repeat(vecs, n, 0),
|
|
90
|
+
"atten_init:source_padding:00": np.repeat(pad, n, 0),
|
|
91
|
+
"feeds:step_ids:00": last,
|
|
92
|
+
**state,
|
|
93
|
+
})
|
|
94
|
+
logp = out["fetches:log_probs:00"]
|
|
95
|
+
scores = np.array([s for _, s in hyps])[:, None] + logp
|
|
96
|
+
flat = np.argsort(scores, axis=None)[::-1][: 2 * beam]
|
|
97
|
+
new_hyps, rows = [], []
|
|
98
|
+
for idx in flat:
|
|
99
|
+
row, tok = divmod(int(idx), logp.shape[1])
|
|
100
|
+
toks, sc = hyps[row][0] + [tok], float(scores[row, tok])
|
|
101
|
+
if tok == EOS:
|
|
102
|
+
done.append((toks[:-1], sc / (((5 + len(toks)) / 6) ** alpha)))
|
|
103
|
+
elif len(new_hyps) < beam:
|
|
104
|
+
new_hyps.append((toks, sc))
|
|
105
|
+
rows.append(row)
|
|
106
|
+
if not new_hyps:
|
|
107
|
+
break
|
|
108
|
+
# Stop when the best finished hypothesis beats any live one's optimistic score.
|
|
109
|
+
if len(done) >= beam:
|
|
110
|
+
best_done = max(s for _, s in done)
|
|
111
|
+
best_live = max(s for _, s in new_hyps) / (((5 + max_len) / 6) ** alpha)
|
|
112
|
+
if best_done > best_live:
|
|
113
|
+
break
|
|
114
|
+
hyps = new_hyps
|
|
115
|
+
state = {k: out[k][rows] for k in state}
|
|
116
|
+
if not done:
|
|
117
|
+
done = hyps
|
|
118
|
+
return self.tgt_sp.decode(max(done, key=lambda h: h[1])[0])
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Language codes as used by Google Translate's offline catalog, with English names."""
|
|
2
|
+
|
|
3
|
+
NAMES = {
|
|
4
|
+
"af": "Afrikaans", "am": "Amharic", "ar": "Arabic", "az": "Azerbaijani", "be": "Belarusian",
|
|
5
|
+
"bg": "Bulgarian", "bn": "Bengali", "bs": "Bosnian", "ca": "Catalan", "ceb": "Cebuano",
|
|
6
|
+
"co": "Corsican", "cs": "Czech", "cy": "Welsh", "da": "Danish", "de": "German", "el": "Greek",
|
|
7
|
+
"en": "English", "eo": "Esperanto", "es": "Spanish", "et": "Estonian", "eu": "Basque",
|
|
8
|
+
"fa": "Persian", "fi": "Finnish", "fr": "French", "fy": "Frisian", "ga": "Irish",
|
|
9
|
+
"gd": "Scottish Gaelic", "gl": "Galician", "gu": "Gujarati", "ha": "Hausa", "haw": "Hawaiian",
|
|
10
|
+
"hi": "Hindi", "hmn": "Hmong", "hr": "Croatian", "ht": "Haitian Creole", "hu": "Hungarian",
|
|
11
|
+
"hy": "Armenian", "id": "Indonesian", "ig": "Igbo", "is": "Icelandic", "it": "Italian",
|
|
12
|
+
"iw": "Hebrew", "ja": "Japanese", "jw": "Javanese", "ka": "Georgian", "kk": "Kazakh",
|
|
13
|
+
"km": "Khmer", "kn": "Kannada", "ko": "Korean", "ku": "Kurdish", "ky": "Kyrgyz", "la": "Latin",
|
|
14
|
+
"lb": "Luxembourgish", "lo": "Lao", "lt": "Lithuanian", "lv": "Latvian", "mg": "Malagasy",
|
|
15
|
+
"mi": "Maori", "mk": "Macedonian", "ml": "Malayalam", "mn": "Mongolian", "mr": "Marathi",
|
|
16
|
+
"ms": "Malay", "mt": "Maltese", "my": "Burmese", "ne": "Nepali", "nl": "Dutch",
|
|
17
|
+
"no": "Norwegian", "ny": "Chichewa", "or": "Odia", "pa": "Punjabi", "pl": "Polish",
|
|
18
|
+
"ps": "Pashto", "pt": "Portuguese", "ro": "Romanian", "ru": "Russian", "rw": "Kinyarwanda",
|
|
19
|
+
"sd": "Sindhi", "si": "Sinhala", "sk": "Slovak", "sl": "Slovenian", "sm": "Samoan",
|
|
20
|
+
"sn": "Shona", "so": "Somali", "sq": "Albanian", "st": "Sesotho", "su": "Sundanese",
|
|
21
|
+
"sv": "Swedish", "sw": "Swahili", "ta": "Tamil", "te": "Telugu", "tg": "Tajik", "th": "Thai",
|
|
22
|
+
"tk": "Turkmen", "tl": "Filipino", "tr": "Turkish", "tt": "Tatar", "ug": "Uyghur",
|
|
23
|
+
"uk": "Ukrainian", "ur": "Urdu", "uz": "Uzbek", "vi": "Vietnamese", "xh": "Xhosa",
|
|
24
|
+
"yi": "Yiddish", "yo": "Yoruba", "zh": "Chinese (Simplified)", "zh-Hant": "Chinese (Traditional)",
|
|
25
|
+
"zu": "Zulu",
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
# Google uses legacy codes for a few languages; accept the modern ones too.
|
|
29
|
+
ALIASES = {"he": "iw", "jv": "jw", "fil": "tl", "nb": "no", "zh-cn": "zh", "zh-hans": "zh",
|
|
30
|
+
"zh-tw": "zh-Hant", "zh-hant": "zh-Hant"}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize(code):
|
|
34
|
+
"""Map user input (code or English name, any case) to a catalog code."""
|
|
35
|
+
c = code.strip()
|
|
36
|
+
if c.lower() in ALIASES:
|
|
37
|
+
return ALIASES[c.lower()]
|
|
38
|
+
for k, name in NAMES.items():
|
|
39
|
+
if c.lower() in (k.lower(), name.lower()):
|
|
40
|
+
return k
|
|
41
|
+
return c
|
|
File without changes
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Public API: manage language packs in a folder and translate between installed languages."""
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import shutil
|
|
5
|
+
import zlib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from . import catalog as cat
|
|
10
|
+
from .languages import NAMES, normalize
|
|
11
|
+
|
|
12
|
+
PACK_META = "pack.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class TranslateError(Exception):
|
|
16
|
+
pass
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(frozen=True)
|
|
20
|
+
class Language:
|
|
21
|
+
code: str
|
|
22
|
+
name: str
|
|
23
|
+
installed: bool
|
|
24
|
+
download_size: int # bytes to download (0 for English: it needs no pack of its own)
|
|
25
|
+
size: int # bytes on disk once installed
|
|
26
|
+
update_available: bool = False
|
|
27
|
+
builtin: bool = False # English: always "installed", every pack is X<->English
|
|
28
|
+
supported: bool = True # False: pack uses an older model format this engine can't run yet
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(frozen=True)
|
|
32
|
+
class Translation:
|
|
33
|
+
text: str
|
|
34
|
+
source: str
|
|
35
|
+
target: str
|
|
36
|
+
via: tuple # language hops, e.g. ("iw", "en", "ar") when pivoting through English
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class Translator:
|
|
40
|
+
"""
|
|
41
|
+
models_dir: folder holding the catalog cache and one sub-folder per model direction (e.g. en_iw/).
|
|
42
|
+
channel: manifest channel the Android app reads ("prod").
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, models_dir, channel="prod", beam=4):
|
|
46
|
+
self.dir = Path(models_dir).expanduser()
|
|
47
|
+
self.dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
self.channel = channel
|
|
49
|
+
self.beam = beam
|
|
50
|
+
self._packs = None
|
|
51
|
+
self._models = {}
|
|
52
|
+
|
|
53
|
+
# ---------- catalog ----------
|
|
54
|
+
@property
|
|
55
|
+
def _catalog_dir(self):
|
|
56
|
+
return self.dir / "_catalog"
|
|
57
|
+
|
|
58
|
+
def update_catalog(self):
|
|
59
|
+
"""Fetch the latest manifest + package profile from Google. Returns the profile version."""
|
|
60
|
+
refs = cat.parse_manifest(cat.fetch(cat.MANIFEST_URL.format(channel=self.channel)))
|
|
61
|
+
if not refs:
|
|
62
|
+
raise TranslateError("manifest lists no translation profiles")
|
|
63
|
+
ref = refs[-1]
|
|
64
|
+
data = zlib.decompress(cat.fetch(ref.url))
|
|
65
|
+
if hashlib.sha1(data).hexdigest() != ref.sha1:
|
|
66
|
+
raise TranslateError(f"checksum mismatch for {ref.url}")
|
|
67
|
+
self._catalog_dir.mkdir(exist_ok=True)
|
|
68
|
+
(self._catalog_dir / "profile.pb").write_bytes(data)
|
|
69
|
+
(self._catalog_dir / "profile.json").write_text(json.dumps({"version": ref.version_str, "url": ref.url}))
|
|
70
|
+
self._packs = cat.parse_profile(data)
|
|
71
|
+
return ref.version_str
|
|
72
|
+
|
|
73
|
+
def catalog_version(self):
|
|
74
|
+
meta = self._catalog_dir / "profile.json"
|
|
75
|
+
return json.loads(meta.read_text())["version"] if meta.exists() else None
|
|
76
|
+
|
|
77
|
+
def _catalog(self):
|
|
78
|
+
if self._packs is None:
|
|
79
|
+
path = self._catalog_dir / "profile.pb"
|
|
80
|
+
if path.exists():
|
|
81
|
+
self._packs = cat.parse_profile(path.read_bytes())
|
|
82
|
+
else:
|
|
83
|
+
self.update_catalog()
|
|
84
|
+
return self._packs
|
|
85
|
+
|
|
86
|
+
# ---------- languages ----------
|
|
87
|
+
def _pair_dir(self, pair):
|
|
88
|
+
return self.dir / pair
|
|
89
|
+
|
|
90
|
+
def _installed_pairs(self):
|
|
91
|
+
return {tuple(d.name.split("_", 1)) for d in self.dir.iterdir()
|
|
92
|
+
if d.is_dir() and (d / "encoder_0.tflite").exists()}
|
|
93
|
+
|
|
94
|
+
def _pack_meta(self, pair):
|
|
95
|
+
"""sha1 of each installed file; computed once for packs installed by other means."""
|
|
96
|
+
d = self._pair_dir(pair)
|
|
97
|
+
path = d / PACK_META
|
|
98
|
+
if path.exists():
|
|
99
|
+
return json.loads(path.read_text())
|
|
100
|
+
meta = {f.name: hashlib.sha1(f.read_bytes()).hexdigest()
|
|
101
|
+
for f in sorted(d.iterdir()) if f.is_file() and f.name != PACK_META}
|
|
102
|
+
path.write_text(json.dumps(meta, indent=1))
|
|
103
|
+
return meta
|
|
104
|
+
|
|
105
|
+
def _package_state(self, pkg):
|
|
106
|
+
"""(installed, update_available) for one direction."""
|
|
107
|
+
if not (self._pair_dir(pkg.name) / "encoder_0.tflite").exists():
|
|
108
|
+
return False, False
|
|
109
|
+
meta = self._pack_meta(pkg.name)
|
|
110
|
+
return True, any(meta.get(f.name) != f.sha1 for f in pkg.files)
|
|
111
|
+
|
|
112
|
+
def languages(self, installed_only=False):
|
|
113
|
+
"""All languages in the catalog (plus English), sorted by name."""
|
|
114
|
+
out = [Language("en", NAMES["en"], True, 0, 0, builtin=True)]
|
|
115
|
+
for code, pack in self._catalog().items():
|
|
116
|
+
states = [self._package_state(p) for p in pack.packages]
|
|
117
|
+
installed = all(s[0] for s in states)
|
|
118
|
+
out.append(Language(code, NAMES.get(code, code), installed, pack.download_size, pack.size,
|
|
119
|
+
update_available=installed and any(s[1] for s in states),
|
|
120
|
+
supported=pack.supported))
|
|
121
|
+
out.sort(key=lambda l: l.name)
|
|
122
|
+
return [l for l in out if l.installed] if installed_only else out
|
|
123
|
+
|
|
124
|
+
def installed_languages(self):
|
|
125
|
+
"""Languages usable for translation right now (works offline, without the catalog)."""
|
|
126
|
+
codes = {c for pair in self._installed_pairs() for c in pair}
|
|
127
|
+
return sorted(codes or {"en"}, key=lambda c: NAMES.get(c, c))
|
|
128
|
+
|
|
129
|
+
def _resolve(self, lang):
|
|
130
|
+
code = normalize(lang)
|
|
131
|
+
if code == "en":
|
|
132
|
+
raise TranslateError("English is built in; every language pack includes it")
|
|
133
|
+
if code not in self._catalog():
|
|
134
|
+
raise TranslateError(f"unknown language '{lang}'")
|
|
135
|
+
return code
|
|
136
|
+
|
|
137
|
+
def download(self, lang, progress=None, force=False):
|
|
138
|
+
"""
|
|
139
|
+
Download (or update) a language pack. Already up-to-date directions are skipped unless force.
|
|
140
|
+
progress(done_bytes, total_bytes) is called while downloading (sizes are compressed bytes).
|
|
141
|
+
"""
|
|
142
|
+
pack = self._catalog()[self._resolve(lang)]
|
|
143
|
+
if not pack.supported:
|
|
144
|
+
raise TranslateError(f"{NAMES.get(pack.lang, pack.lang)} uses an older model format that isn't supported yet")
|
|
145
|
+
todo = [p for p in pack.packages if force or self._package_state(p) != (True, False)]
|
|
146
|
+
total = sum(f.download_size for p in todo for f in p.files)
|
|
147
|
+
done = 0
|
|
148
|
+
for pkg in todo:
|
|
149
|
+
tmp = self.dir / f"{pkg.name}.partial"
|
|
150
|
+
tmp.mkdir(exist_ok=True)
|
|
151
|
+
meta = {}
|
|
152
|
+
for f in pkg.files:
|
|
153
|
+
dest = tmp / f.name
|
|
154
|
+
if dest.exists() and hashlib.sha1(dest.read_bytes()).hexdigest() == f.sha1: # resume
|
|
155
|
+
meta[f.name] = f.sha1
|
|
156
|
+
else:
|
|
157
|
+
meta[f.name] = self._download_file(f, dest, lambda n: progress and progress(done + n, total))
|
|
158
|
+
done += f.download_size
|
|
159
|
+
if progress:
|
|
160
|
+
progress(done, total)
|
|
161
|
+
(tmp / PACK_META).write_text(json.dumps(meta, indent=1))
|
|
162
|
+
final = self._pair_dir(pkg.name)
|
|
163
|
+
if final.exists():
|
|
164
|
+
shutil.rmtree(final)
|
|
165
|
+
tmp.rename(final)
|
|
166
|
+
self._models.pop(tuple(pkg.name.split("_", 1)), None)
|
|
167
|
+
|
|
168
|
+
@staticmethod
|
|
169
|
+
def _download_file(f, dest, on_bytes):
|
|
170
|
+
sha, dec, got = hashlib.sha1(), zlib.decompressobj(), 0
|
|
171
|
+
part = dest.with_suffix(dest.suffix + ".part")
|
|
172
|
+
with cat.open_url(f.url) as r, open(part, "wb") as out:
|
|
173
|
+
while chunk := r.read(1 << 16):
|
|
174
|
+
got += len(chunk)
|
|
175
|
+
on_bytes(got)
|
|
176
|
+
data = dec.decompress(chunk)
|
|
177
|
+
sha.update(data)
|
|
178
|
+
out.write(data)
|
|
179
|
+
tail = dec.flush()
|
|
180
|
+
sha.update(tail)
|
|
181
|
+
out.write(tail)
|
|
182
|
+
if sha.hexdigest() != f.sha1:
|
|
183
|
+
part.unlink()
|
|
184
|
+
raise TranslateError(f"checksum mismatch for {f.url}")
|
|
185
|
+
part.rename(dest)
|
|
186
|
+
return f.sha1
|
|
187
|
+
|
|
188
|
+
def remove(self, lang):
|
|
189
|
+
code = normalize(lang)
|
|
190
|
+
if code == "en":
|
|
191
|
+
raise TranslateError("English is built in and can't be removed")
|
|
192
|
+
removed = False
|
|
193
|
+
for pair in [(code, "en"), ("en", code)]:
|
|
194
|
+
d = self._pair_dir("_".join(pair))
|
|
195
|
+
for p in (d, d.with_name(d.name + ".partial")):
|
|
196
|
+
if p.exists():
|
|
197
|
+
shutil.rmtree(p)
|
|
198
|
+
removed = True
|
|
199
|
+
self._models.pop(pair, None)
|
|
200
|
+
if not removed:
|
|
201
|
+
raise TranslateError(f"'{lang}' is not installed")
|
|
202
|
+
|
|
203
|
+
# ---------- translation ----------
|
|
204
|
+
def detect(self, text):
|
|
205
|
+
"""Guess the source among installed languages by vocabulary coverage of non-ASCII letters."""
|
|
206
|
+
letters = {c for c in text.lower() if c.isalpha() and not c.isascii()}
|
|
207
|
+
best, best_hits = "en", 0
|
|
208
|
+
if letters:
|
|
209
|
+
for src, tgt in sorted(self._installed_pairs()):
|
|
210
|
+
if src == "en":
|
|
211
|
+
continue
|
|
212
|
+
vocab = (self._pair_dir(f"{src}_{tgt}") / "src_spm_model.vocab").read_text("utf-8")
|
|
213
|
+
hits = len(letters & set(vocab))
|
|
214
|
+
if hits > best_hits:
|
|
215
|
+
best, best_hits = src, hits
|
|
216
|
+
return best
|
|
217
|
+
|
|
218
|
+
def route(self, source, target):
|
|
219
|
+
"""Model hops for source->target: direct, or via English like the Android app."""
|
|
220
|
+
pairs = self._installed_pairs()
|
|
221
|
+
if source == target:
|
|
222
|
+
return []
|
|
223
|
+
if (source, target) in pairs:
|
|
224
|
+
return [(source, target)]
|
|
225
|
+
if (source, "en") in pairs and ("en", target) in pairs:
|
|
226
|
+
return [(source, "en"), ("en", target)]
|
|
227
|
+
missing = [l for l in (source, target) if l != "en" and not {(l, "en"), ("en", l)} <= pairs]
|
|
228
|
+
raise TranslateError(f"no model for {source} -> {target}; download: {', '.join(missing)}")
|
|
229
|
+
|
|
230
|
+
def translate(self, text, target, source=None):
|
|
231
|
+
source = normalize(source) if source else self.detect(text)
|
|
232
|
+
target = normalize(target)
|
|
233
|
+
hops = self.route(source, target)
|
|
234
|
+
for pair in hops:
|
|
235
|
+
if pair not in self._models:
|
|
236
|
+
from .engine import NmtModel # deferred: TFLite import is the slow part
|
|
237
|
+
self._models[pair] = NmtModel(self._pair_dir("_".join(pair)))
|
|
238
|
+
text = self._models[pair].translate(text, beam=self.beam)
|
|
239
|
+
via = (source,) + tuple(t for _, t in hops) if hops else (source,)
|
|
240
|
+
return Translation(text, source, target, via)
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: offline-translate
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Offline machine translation using Google Translate's on-device language packs
|
|
5
|
+
Project-URL: Homepage, https://github.com/asimon655/offline-translate
|
|
6
|
+
Project-URL: Issues, https://github.com/asimon655/offline-translate/issues
|
|
7
|
+
Author: asimon655
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: google-translate,nmt,offline,tflite,translation
|
|
11
|
+
Classifier: Development Status :: 3 - Alpha
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Operating System :: MacOS
|
|
14
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
15
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
18
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: ai-edge-litert>=1.0
|
|
22
|
+
Requires-Dist: numpy>=1.24
|
|
23
|
+
Requires-Dist: sentencepiece>=0.2
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=8; extra == 'test'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# offline-translate
|
|
29
|
+
|
|
30
|
+
Offline machine translation in Python using the same on-device neural translation models that the
|
|
31
|
+
Google Translate Android app downloads for offline use.
|
|
32
|
+
|
|
33
|
+
- ~100 languages, each pack translates to/from English (~50 MB per language)
|
|
34
|
+
- Fully offline once a pack is downloaded; no API key, no account
|
|
35
|
+
- Language pairs without English (e.g. Hebrew → French) pivot through English, like the app does
|
|
36
|
+
- Runs on CPU via [LiteRT](https://ai.google.dev/edge/litert) (TensorFlow Lite); ~0.3 s per sentence
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install offline-translate
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Supported platforms are those with `ai-edge-litert` wheels: macOS (Apple Silicon), Linux (x86_64,
|
|
43
|
+
aarch64) and Windows (x64), Python 3.10+.
|
|
44
|
+
|
|
45
|
+
## Usage
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from offline_translate import Translator
|
|
49
|
+
|
|
50
|
+
t = Translator("~/.local/share/offline-translate") # where models are stored
|
|
51
|
+
|
|
52
|
+
t.download("he") # Hebrew <-> English, ~55 MB
|
|
53
|
+
t.translate("Good morning!", target="he").text
|
|
54
|
+
# 'בוקר טוב!'
|
|
55
|
+
|
|
56
|
+
t.translate("בוקר טוב", target="en") # source is auto-detected among installed languages
|
|
57
|
+
# Translation(text='Good morning', source='iw', target='en', via=('iw', 'en'))
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Managing languages
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
for lang in t.languages(): # full catalog (fetched once from Google, then cached)
|
|
64
|
+
print(lang.code, lang.name, lang.installed, lang.download_size, lang.update_available)
|
|
65
|
+
|
|
66
|
+
t.installed_languages() # ['en', 'iw'] — works offline, no catalog needed
|
|
67
|
+
t.download("fr", progress=lambda done, total: print(f"{done}/{total}"))
|
|
68
|
+
t.remove("fr")
|
|
69
|
+
|
|
70
|
+
t.update_catalog() # check Google for a newer catalog
|
|
71
|
+
[l for l in t.languages() if l.update_available] # then t.download(code) to update
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Language codes follow Google's catalog (`iw` for Hebrew, `zh-Hant`, `jw`, …). Common aliases
|
|
75
|
+
(`he`, `zh-TW`, `jv`) and English names (`"Hebrew"`) are accepted everywhere.
|
|
76
|
+
|
|
77
|
+
English is built in: there is no separate English pack, since every pack contains both
|
|
78
|
+
directions to and from English.
|
|
79
|
+
|
|
80
|
+
### API
|
|
81
|
+
|
|
82
|
+
| | |
|
|
83
|
+
|---|---|
|
|
84
|
+
| `Translator(models_dir, channel="prod", beam=4)` | `models_dir` is created if missing. `beam=1` is faster, slightly worse. |
|
|
85
|
+
| `translate(text, target, source=None) -> Translation` | `Translation(text, source, target, via)` |
|
|
86
|
+
| `detect(text) -> str` | Guess among installed languages |
|
|
87
|
+
| `languages(installed_only=False) -> list[Language]` | `Language(code, name, installed, download_size, size, update_available, builtin, supported)` |
|
|
88
|
+
| `installed_languages() -> list[str]` | |
|
|
89
|
+
| `download(lang, progress=None, force=False)` | Verifies SHA-1 of every file; resumes interrupted downloads |
|
|
90
|
+
| `remove(lang)` | |
|
|
91
|
+
| `update_catalog() -> str` | Returns the catalog version |
|
|
92
|
+
| `TranslateError` | Raised for unknown/uninstalled languages, checksum failures, … |
|
|
93
|
+
|
|
94
|
+
Network errors surface as `OSError`.
|
|
95
|
+
|
|
96
|
+
## Command line
|
|
97
|
+
|
|
98
|
+
The repository also has a small CLI built on the library (not part of the PyPI package):
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
./translate get he # download Hebrew
|
|
102
|
+
./translate "Good morning" # English -> Hebrew by default; source auto-detected
|
|
103
|
+
./translate -s he -t fr "בוקר טוב" # any pair, via English
|
|
104
|
+
./translate list --installed
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
`tools/pull_from_phone.py` copies already-downloaded packs from a rooted Android phone over adb.
|
|
108
|
+
|
|
109
|
+
## How it works
|
|
110
|
+
|
|
111
|
+
The Android app reads a manifest at
|
|
112
|
+
`https://dl.google.com/translate/offline/manifest/gtr/prod/manifest.pb`, which lists versions of
|
|
113
|
+
a *package profile* — the catalog of language packs and their files. Each translation direction is
|
|
114
|
+
a TFLite encoder, an LSTM decoder run step by step with beam search, and SentencePiece tokenizers.
|
|
115
|
+
|
|
116
|
+
### Limitations
|
|
117
|
+
|
|
118
|
+
- 15 languages (am, az, bs, hy, kk, ky, ml, mn, ne, pa, ps, si, so, tg, uz) still ship an older
|
|
119
|
+
model format with a WordPiece tokenizer that isn't supported yet; they're listed with
|
|
120
|
+
`supported=False`.
|
|
121
|
+
- Quality matches the app's offline mode, which is below online Google Translate.
|
|
122
|
+
|
|
123
|
+
## Development
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
pip install -e ".[test]"
|
|
127
|
+
pytest # offline unit tests (fake Google server, fake model)
|
|
128
|
+
pytest --network # + checks against the real Google catalog
|
|
129
|
+
pytest --models=~/models # + end-to-end translation with installed Hebrew models
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Disclaimer
|
|
133
|
+
|
|
134
|
+
This project is not affiliated with or endorsed by Google. It contains no Google code or models;
|
|
135
|
+
language packs are downloaded from Google's public servers at runtime and are subject to Google's
|
|
136
|
+
terms.
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
offline_translate/__init__.py,sha256=JUCBJLHohZBK5zC5uX5N9Vn8Nc07_SMKrHr9OXeT-p0,462
|
|
2
|
+
offline_translate/catalog.py,sha256=acEQbX9EwmxkdTs3j_MzqsDJffNj7i0bqZ4gbPOqq6E,5401
|
|
3
|
+
offline_translate/engine.py,sha256=_BQiYut2OlBLxr4hVXnKBlrHGf0IjN-L8o_DnVWXMbk,5220
|
|
4
|
+
offline_translate/languages.py,sha256=R0JjzM1C5RI7-hW5cn6Td1v1b62N2kaU7e5MaS-XotE,2595
|
|
5
|
+
offline_translate/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
6
|
+
offline_translate/translator.py,sha256=czx1MymuFuQ0jLpUZD0HYRIDoeiZFq-falvKgpttCgk,9980
|
|
7
|
+
offline_translate-0.1.0.dist-info/METADATA,sha256=QpCeIIkNvx5SZLkAdSVmpDEtARamnuxnwx97U9z33KI,5542
|
|
8
|
+
offline_translate-0.1.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
9
|
+
offline_translate-0.1.0.dist-info/licenses/LICENSE,sha256=lx7YwLwJwmgIHoe3Bd-oiG47NRYrCqpdjYDo-YzgWw0,1066
|
|
10
|
+
offline_translate-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 asimon655
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|