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
« 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
6import difflib
7import os
8from contextlib import contextmanager
9from typing import TYPE_CHECKING
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
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
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
36 from weblate.auth.models import AuthenticatedHttpRequest
37 from weblate.lang.models import Language
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}
145TESSERACT_URL = "https://raw.githubusercontent.com/tesseract-ocr/tessdata_fast/main/{}"
148def ensure_tesseract_language(lang: str) -> None:
149 """
150 Ensure that tesseract trained data is present for a language.
152 It also always includes eng (English) and osd (Orientation and script detection).
153 """
154 tessdata = data_dir("cache", "tesseract")
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)
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
176 url = TESSERACT_URL.format(filename)
178 LOGGER.debug("downloading tesseract data %s", url)
180 with sentry_sdk.start_span(op="ocr.download", name=url):
181 response = request("GET", url, allow_redirects=True)
183 with open(full_name, "xb") as handle:
184 handle.write(response.content)
187def try_add_source(request: AuthenticatedHttpRequest, obj) -> bool:
188 if "source" not in request.POST:
189 return False
191 try:
192 source = obj.translation.unit_set.get(pk=int(request.POST["source"]))
193 except (Unit.DoesNotExist, ValueError):
194 return False
196 obj.units.add(source)
197 return True
200class ScreenshotList(PathViewMixin, ListView):
201 paginate_by = 25
202 model = Screenshot
203 supported_path_types = (Component,)
204 _add_form = None
206 def get_queryset(self):
207 return (
208 Screenshot.objects.filter(translation__component=self.path_object)
209 .prefetch_related("translation__language")
210 .order()
211 )
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
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 )
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)
254class ScreenshotDetail(DetailView):
255 model = Screenshot
256 _edit_form = None
257 request: AuthenticatedHttpRequest
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
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
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)
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
306 obj.delete()
308 messages.success(request, gettext("Screenshot %s has been deleted.") % obj.name)
310 return redirect("screenshots", path=component.get_url_path())
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
320@require_POST
321@login_required
322def remove_source(request: AuthenticatedHttpRequest, pk):
323 obj = get_screenshot(request, pk)
325 obj.units.remove(request.POST["source"])
327 messages.success(request, gettext("Source has been removed."))
329 return redirect(obj)
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 )
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 )
358@login_required
359@require_POST
360def search_source(request: AuthenticatedHttpRequest, pk):
361 obj = get_screenshot(request, pk)
362 translation = obj.translation
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 )
378def ocr_get_strings(api, image: str, resolution: int = 72):
379 from tesserocr import RIL, iterate_level
381 try:
382 api.SetImageFile(image)
383 except RuntimeError:
384 pass
385 else:
386 api.SetSourceResolution(resolution)
388 with sentry_sdk.start_span(op="ocr.recognize", name=image):
389 api.Recognize()
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()
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)
412@contextmanager
413def get_tesseract(language: Language) -> PyTessBaseAPI:
414 from tesserocr import OEM, PSM, PyTessBaseAPI
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"
425 ensure_tesseract_language(tess_language)
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
436@login_required
437@require_POST
438def ocr_search(request: AuthenticatedHttpRequest, pk):
439 from PIL import Image
441 obj = get_screenshot(request, pk)
442 translation = obj.translation
444 # Find all our strings
445 sources = dict(translation.unit_set.values_list("source", "pk"))
446 strings = tuple(sources.keys())
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 }
456 return search_results(
457 request, 200, obj, translation.unit_set.filter(pk__in=results)
458 )
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})
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 )