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.
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.4
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.