Coverage for app/venv/lib/python3.14/site-packages/weblate/screenshots/views.py: 27%

210 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-10-07 07:15 +0000

1# Copyright © Michal Čihař <michal@weblate.org> 

2# 

3# SPDX-License-Identifier: GPL-3.0-or-later 

4from __future__ import annotations 

5 

6import difflib 

7import os 

8from contextlib import contextmanager 

9from typing import TYPE_CHECKING 

10 

11import sentry_sdk 

12from django.contrib.auth.decorators import login_required 

13from django.core.exceptions import PermissionDenied 

14from django.http import JsonResponse 

15from django.shortcuts import get_object_or_404, redirect, render 

16from django.template.loader import render_to_string 

17from django.utils.translation import gettext 

18from django.views.decorators.http import require_POST 

19from django.views.generic import DetailView, ListView 

20 

21from weblate.logger import LOGGER 

22from weblate.screenshots.forms import ScreenshotEditForm, ScreenshotForm, SearchForm 

23from weblate.screenshots.models import Screenshot 

24from weblate.trans.actions import ActionEvents 

25from weblate.trans.models import Component, Unit 

26from weblate.utils import messages 

27from weblate.utils.data import data_dir 

28from weblate.utils.lock import WeblateLock 

29from weblate.utils.requests import request 

30from weblate.utils.search import parse_query 

31from weblate.utils.views import PathViewMixin 

32 

33if TYPE_CHECKING: 33 ↛ 34line 33 didn't jump to line 34 because the condition on line 33 was never true

34 from tesserocr import PyTessBaseAPI 

35 

36 from weblate.auth.models import AuthenticatedHttpRequest 

37 from weblate.lang.models import Language 

38 

39 

40TESSERACT_LANGUAGES = { 

41 "af": "afr", # Afrikaans 

42 "am": "amh", # Amharic 

43 "ar": "ara", # Arabic 

44 "as": "asm", # Assamese 

45 "az": "aze", # Azerbaijani 

46 "az@Cyrl": "aze_cyrl", # Azerbaijani - Cyrillic 

47 "be": "bel", # Belarusian 

48 "bn": "ben", # Bengali 

49 "bo": "bod", # Tibetan 

50 "bs": "bos", # Bosnian 

51 "bg": "bul", # Bulgarian 

52 "ca": "cat", # Catalan; Valencian 

53 "ceb": "ceb", # Cebuano 

54 "cs": "ces", # Czech 

55 "zh_Hans": "chi_sim", # Chinese - Simplified 

56 "zh_Hant": "chi_tra", # Chinese - Traditional 

57 "chr": "chr", # Cherokee 

58 "cy": "cym", # Welsh 

59 "da": "dan", # Danish 

60 "de": "deu", # German 

61 "dz": "dzo", # Dzongkha 

62 "el": "ell", # Greek, Modern (1453-) 

63 "en": "eng", # English 

64 "enm": "enm", # English, Middle (1100-1500) 

65 "eo": "epo", # Esperanto 

66 "et": "est", # Estonian 

67 "eu": "eus", # Basque 

68 "fa": "fas", # Persian 

69 "fi": "fin", # Finnish 

70 "fr": "fra", # French 

71 "frk": "frk", # German Fraktur 

72 "frm": "frm", # French, Middle (ca. 1400-1600) 

73 "ga": "gle", # Irish 

74 "gl": "glg", # Galician 

75 "grc": "grc", # Greek, Ancient (-1453) 

76 "gu": "guj", # Gujarati 

77 "ht": "hat", # Haitian; Haitian Creole 

78 "he": "heb", # Hebrew 

79 "hi": "hin", # Hindi 

80 "hr": "hrv", # Croatian 

81 "hu": "hun", # Hungarian 

82 "iu": "iku", # Inuktitut 

83 "id": "ind", # Indonesian 

84 "is": "isl", # Icelandic 

85 "it": "ita", # Italian 

86 # "": "ita_old", # Italian - Old 

87 "jv": "jav", # Javanese 

88 "ja": "jpn", # Japanese 

89 "kn": "kan", # Kannada 

90 "ka": "kat", # Georgian 

91 # "": "kat_old", # Georgian - Old 

92 "kk": "kaz", # Kazakh 

93 "km": "khm", # Central Khmer 

94 "ky": "kir", # Kirghiz; Kyrgyz 

95 "ko": "kor", # Korean 

96 "ku": "kur", # Kurdish 

97 "lo": "lao", # Lao 

98 "la": "lat", # Latin 

99 "lv": "lav", # Latvian 

100 "lt": "lit", # Lithuanian 

101 "ml": "mal", # Malayalam 

102 "mr": "mar", # Marathi 

103 "mk": "mkd", # Macedonian 

104 "mt": "mlt", # Maltese 

105 "ms": "msa", # Malay 

106 "my": "mya", # Burmese 

107 "ne": "nep", # Nepali 

108 "nl": "nld", # Dutch; Flemish 

109 "nb_NO": "nor", # Norwegian 

110 # "": "ori", # Oriya 

111 "pa": "pan", # Panjabi; Punjabi 

112 "pl": "pol", # Polish 

113 "pt": "por", # Portuguese 

114 "ps": "pus", # Pushto; Pashto 

115 "ro": "ron", # Romanian; Moldavian; Moldovan 

116 "ru": "rus", # Russian 

117 "sa": "san", # Sanskrit 

118 "si": "sin", # Sinhala; Sinhalese 

119 "sk": "slk", # Slovak 

120 "sl": "slv", # Slovenian 

121 "es": "spa", # Spanish; Castilian 

122 # "": "spa_old", # Spanish; Castilian - Old 

123 "sq": "sqi", # Albanian 

124 "sr": "srp", # Serbian 

125 "sr_Latn": "srp_latn", # Serbian - Latin 

126 "sw": "swa", # Swahili 

127 "sv": "swe", # Swedish 

128 "syr": "syr", # Syriac 

129 "ta": "tam", # Tamil 

130 "te": "tel", # Telugu # codespell:ignore te 

131 "tg": "tgk", # Tajik 

132 "tl": "tgl", # Tagalog 

133 "th": "tha", # Thai # codespell:ignore tha 

134 "ti": "tir", # Tigrinya 

135 "tr": "tur", # Turkish 

136 "ug": "uig", # Uighur; Uyghur 

137 "uk": "ukr", # Ukrainian 

138 "ur": "urd", # Urdu 

139 "uz_Latn": "uzb", # Uzbek 

140 "uz": "uzb_cyrl", # Uzbek - Cyrillic 

141 "vi": "vie", # Vietnamese # codespell:ignore vie 

142 "yi": "yid", # Yiddish 

143} 

144 

145TESSERACT_URL = "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/{}" 

146 

147 

148def ensure_tesseract_language(lang: str) -> None: 

149 """ 

150 Ensure that tesseract trained data is present for a language. 

151 

152 It also always includes eng (English) and osd (Orientation and script detection). 

153 """ 

154 tessdata = data_dir("cache", "tesseract") 

155 

156 # Operate with a lock held to avoid concurrent downloads 

157 with ( 

158 WeblateLock( 

159 lock_path=data_dir("home"), 

160 scope="screenshots:tesseract-download", 

161 key=0, 

162 slug="screenshots:tesseract-download", 

163 timeout=600, 

164 ), 

165 sentry_sdk.start_span(op="ocr.models"), 

166 ): 

167 if not os.path.isdir(tessdata): 

168 os.makedirs(tessdata) 

169 

170 for code in (lang, "eng", "osd"): 

171 filename = f"{code}.traineddata" 

172 full_name = os.path.join(tessdata, filename) 

173 if os.path.exists(full_name): 

174 continue 

175 

176 url = TESSERACT_URL.format(filename) 

177 

178 LOGGER.debug("downloading tesseract data %s", url) 

179 

180 with sentry_sdk.start_span(op="ocr.download", name=url): 

181 response = request("GET", url, allow_redirects=True) 

182 

183 with open(full_name, "xb") as handle: 

184 handle.write(response.content) 

185 

186 

187def try_add_source(request: AuthenticatedHttpRequest, obj) -> bool: 

188 if "source" not in request.POST: 

189 return False 

190 

191 try: 

192 source = obj.translation.unit_set.get(pk=int(request.POST["source"])) 

193 except (Unit.DoesNotExist, ValueError): 

194 return False 

195 

196 obj.units.add(source) 

197 return True 

198 

199 

200class ScreenshotList(PathViewMixin, ListView): 

201 paginate_by = 25 

202 model = Screenshot 

203 supported_path_types = (Component,) 

204 _add_form = None 

205 

206 def get_queryset(self): 

207 return ( 

208 Screenshot.objects.filter(translation__component=self.path_object) 

209 .prefetch_related("translation__language") 

210 .order() 

211 ) 

212 

213 def get_context_data(self, **kwargs): 

214 result = super().get_context_data(**kwargs) 

215 result["object"] = self.path_object 

216 if self.request.user.has_perm("screenshot.add", self.path_object): 

217 if self._add_form is not None: 

218 result["add_form"] = self._add_form 

219 else: 

220 result["add_form"] = ScreenshotForm(self.path_object) 

221 return result 

222 

223 def post(self, request: AuthenticatedHttpRequest, **kwargs): 

224 component = self.path_object 

225 if not request.user.has_perm("screenshot.add", component): 

226 raise PermissionDenied 

227 self._add_form = ScreenshotForm(component, request.POST, request.FILES) 

228 if self._add_form.is_valid(): 

229 obj = Screenshot.objects.create( 

230 user=request.user, **self._add_form.cleaned_data 

231 ) 

232 request.user.profile.increase_count("uploaded") 

233 obj.change_set.create( 

234 action=ActionEvents.SCREENSHOT_ADDED, 

235 user=request.user, 

236 target=obj.name, 

237 ) 

238 

239 try_add_source(request, obj) 

240 messages.success( 

241 request, 

242 gettext( 

243 "Screenshot has been uploaded, " 

244 "you can now assign it to source strings." 

245 ), 

246 ) 

247 return redirect(obj) 

248 messages.error( 

249 request, gettext("Could not upload screenshot, please fix errors below.") 

250 ) 

251 return self.get(request, **kwargs) 

252 

253 

254class ScreenshotDetail(DetailView): 

255 model = Screenshot 

256 _edit_form = None 

257 request: AuthenticatedHttpRequest 

258 

259 def get_object(self, *args, **kwargs): 

260 obj = super().get_object(*args, **kwargs) 

261 self.request.user.check_access_component(obj.translation.component) 

262 return obj 

263 

264 def get_context_data(self, **kwargs): 

265 result = super().get_context_data(**kwargs) 

266 component = result["object"].translation.component 

267 if self.request.user.has_perm("screenshot.edit", component): 

268 if self._edit_form is not None: 

269 result["edit_form"] = self._edit_form 

270 else: 

271 result["edit_form"] = ScreenshotEditForm(instance=result["object"]) 

272 # Blank list for search results, this is populated later via JavaScript 

273 result["units"] = [] 

274 result["search_query"] = "" 

275 return result 

276 

277 def post(self, request: AuthenticatedHttpRequest, **kwargs): 

278 obj = self.get_object() 

279 if request.user.has_perm("screenshot.edit", obj.translation): 

280 self._edit_form = ScreenshotEditForm( 

281 request.POST, request.FILES, instance=obj 

282 ) 

283 if self._edit_form.is_valid(): 

284 if request.FILES: 

285 obj.user = request.user 

286 request.user.profile.increase_count("uploaded") 

287 obj.change_set.create( 

288 action=ActionEvents.SCREENSHOT_UPLOADED, 

289 user=request.user, 

290 target=obj.name, 

291 ) 

292 self._edit_form.save() 

293 else: 

294 return self.get(request, **kwargs) 

295 return redirect(obj) 

296 

297 

298@require_POST 

299@login_required 

300def delete_screenshot(request: AuthenticatedHttpRequest, pk): 

301 obj = get_object_or_404(Screenshot, pk=pk) 

302 component = obj.translation.component 

303 if not request.user.has_perm("screenshot.delete", obj.translation): 

304 raise PermissionDenied 

305 

306 obj.delete() 

307 

308 messages.success(request, gettext("Screenshot %s has been deleted.") % obj.name) 

309 

310 return redirect("screenshots", path=component.get_url_path()) 

311 

312 

313def get_screenshot(request: AuthenticatedHttpRequest, pk): 

314 obj = get_object_or_404(Screenshot, pk=pk) 

315 if not request.user.has_perm("screenshot.edit", obj.translation.component): 

316 raise PermissionDenied 

317 return obj 

318 

319 

320@require_POST 

321@login_required 

322def remove_source(request: AuthenticatedHttpRequest, pk): 

323 obj = get_screenshot(request, pk) 

324 

325 obj.units.remove(request.POST["source"]) 

326 

327 messages.success(request, gettext("Source has been removed.")) 

328 

329 return redirect(obj) 

330 

331 

332def search_results(request: AuthenticatedHttpRequest, code, obj, units=None): 

333 if units is None: 

334 units = [] 

335 else: 

336 units = ( 

337 units.exclude(id__in=obj.units.values_list("id", flat=True)) 

338 .prefetch_full() 

339 .count_screenshots() 

340 ) 

341 

342 return JsonResponse( 

343 data={ 

344 "responseCode": code, 

345 "results": render_to_string( 

346 "screenshots/screenshot_sources_search.html", 

347 { 

348 "object": obj, 

349 "units": units, 

350 "user": request.user, 

351 "search_query": "", 

352 }, 

353 ), 

354 } 

355 ) 

356 

357 

358@login_required 

359@require_POST 

360def search_source(request: AuthenticatedHttpRequest, pk): 

361 obj = get_screenshot(request, pk) 

362 translation = obj.translation 

363 

364 form = SearchForm(request.POST) 

365 if not form.is_valid(): 

366 return search_results(request, 400, obj) 

367 filters, annotations = parse_query( 

368 form.cleaned_data["q"], project=translation.component.project 

369 ) 

370 return search_results( 

371 request, 

372 200, 

373 obj, 

374 translation.unit_set.annotate(**annotations).filter(filters), 

375 ) 

376 

377 

378def ocr_get_strings(api, image: str, resolution: int = 72): 

379 from tesserocr import RIL, iterate_level 

380 

381 try: 

382 api.SetImageFile(image) 

383 except RuntimeError: 

384 pass 

385 else: 

386 api.SetSourceResolution(resolution) 

387 

388 with sentry_sdk.start_span(op="ocr.recognize", name=image): 

389 api.Recognize() 

390 

391 with sentry_sdk.start_span(op="ocr.iterate", name=image): 

392 iterator = api.GetIterator() 

393 level = RIL.TEXTLINE 

394 for r in iterate_level(iterator, level): 

395 with sentry_sdk.start_span(op="ocr.text", name=image): 

396 try: 

397 yield r.GetUTF8Text(level) 

398 except RuntimeError: 

399 continue 

400 finally: 

401 api.Clear() 

402 

403 

404def ocr_extract(api, image: str, strings, resolution: int): 

405 """Extract closes matches from an image.""" 

406 for ocr_result in ocr_get_strings(api, image, resolution): 

407 parts = [ocr_result, *ocr_result.split("|"), *ocr_result.split()] 

408 for part in parts: 

409 yield from difflib.get_close_matches(part, strings, cutoff=0.9) 

410 

411 

412@contextmanager 

413def get_tesseract(language: Language) -> PyTessBaseAPI: 

414 from tesserocr import OEM, PSM, PyTessBaseAPI 

415 

416 # Get matching language 

417 try: 

418 tess_language = TESSERACT_LANGUAGES[language.code] 

419 except KeyError: 

420 try: 

421 tess_language = TESSERACT_LANGUAGES[language.base_code] 

422 except KeyError: 

423 tess_language = "eng" 

424 

425 ensure_tesseract_language(tess_language) 

426 

427 with PyTessBaseAPI( 

428 path=data_dir("cache", "tesseract") + "/", 

429 psm=PSM.SPARSE_TEXT_OSD, 

430 oem=OEM.LSTM_ONLY, 

431 lang=tess_language, 

432 ) as api: 

433 yield api 

434 

435 

436@login_required 

437@require_POST 

438def ocr_search(request: AuthenticatedHttpRequest, pk): 

439 from PIL import Image 

440 

441 obj = get_screenshot(request, pk) 

442 translation = obj.translation 

443 

444 # Find all our strings 

445 sources = dict(translation.unit_set.values_list("source", "pk")) 

446 strings = tuple(sources.keys()) 

447 

448 # Extract and match strings 

449 with Image.open(obj.image.path), get_tesseract(translation.language) as api: 

450 results = { 

451 sources[match] 

452 for resolution in (72, 300) 

453 for match in ocr_extract(api, obj.image.path, strings, resolution) 

454 } 

455 

456 return search_results( 

457 request, 200, obj, translation.unit_set.filter(pk__in=results) 

458 ) 

459 

460 

461@login_required 

462@require_POST 

463def add_source(request: AuthenticatedHttpRequest, pk): 

464 obj = get_screenshot(request, pk) 

465 result = try_add_source(request, obj) 

466 return JsonResponse(data={"responseCode": 200, "status": result}) 

467 

468 

469@login_required 

470def get_sources(request: AuthenticatedHttpRequest, pk): 

471 obj = get_screenshot(request, pk) 

472 return render( 

473 request, 

474 "screenshots/screenshot_sources_body.html", 

475 {"object": obj, "search_query": ""}, 

476 )