Coverage for app/venv/lib/python3.14/site-packages/weblate/trans/widgets.py: 40%
317 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
5from __future__ import annotations
7import os.path
8from io import StringIO
9from typing import TYPE_CHECKING, ClassVar, Literal, TypedDict
11import cairo
12import gi
13from django.conf import settings
14from django.http import HttpResponse
15from django.template.loader import render_to_string
16from django.utils.html import format_html
17from django.utils.translation import (
18 get_language,
19 gettext,
20 gettext_lazy,
21 ngettext,
22 npgettext,
23 pgettext,
24 pgettext_lazy,
25)
27from weblate.fonts.utils import configure_fontconfig, render_size
28from weblate.lang.models import Language
29from weblate.trans.models import Project
30from weblate.trans.templatetags.translations import number_format
31from weblate.trans.util import sort_unicode, translation_percent
32from weblate.utils import messages
33from weblate.utils.icons import find_static_file
34from weblate.utils.site import get_site_url
35from weblate.utils.stats import (
36 GlobalStats,
37 ProjectLanguage,
38 ProjectLanguageStats,
39 TranslationStats,
40 get_non_glossary_stats,
41)
42from weblate.utils.views import get_percent_color
44if TYPE_CHECKING: 44 ↛ 45line 44 didn't jump to line 45 because the condition on line 44 was never true
45 from django.http import HttpRequest, HttpResponse
46 from django_stubs_ext import StrOrPromise
48 from weblate.utils.stats import (
49 BaseStats,
50 )
52gi.require_version("PangoCairo", "1.0")
53gi.require_version("Pango", "1.0")
54gi.require_version("Rsvg", "2.0")
56from gi.repository import Pango, PangoCairo, Rsvg # noqa: E402
58COLOR_DATA = {
59 "grey": (0, 0, 0),
60 "white": (0, 0, 0),
61 "black": (255, 255, 255),
62 "graph": (255, 255, 255),
63}
65WIDGETS: dict[str, type[Widget]] = {}
66WIDGET_FONT = "Source Sans 3"
69def register_widget(widget):
70 """Register widget in dictionary."""
71 WIDGETS[widget.name] = widget
72 return widget
75class ExtraParametersDict(TypedDict):
76 name: str
77 label: StrOrPromise
78 type: Literal["number"]
79 default: int
80 min: int
81 max: int
82 step: int
85class Widget:
86 """Generic widget class."""
88 name = ""
89 verbose: StrOrPromise = ""
90 colors: tuple[str, ...] = ()
91 extension = "png"
92 content_type = "image/png"
93 order = 100
94 extra_parameters: ClassVar[list[ExtraParametersDict]] = []
96 def __init__(self, obj, color=None, lang=None) -> None:
97 """Create Widget object."""
98 # Get object and related params
99 self.obj = obj
100 self.color = self.get_color_name(color)
101 self.lang = lang
102 if obj is None:
103 stats = GlobalStats()
104 elif lang:
105 stats = obj.stats.get_single_language_stats(lang)
106 else:
107 stats = obj.stats
108 self.stats = stats
109 self.non_glossary_stats = get_non_glossary_stats(stats)
110 self.percent = translation_percent(
111 self.non_glossary_stats["translated"], self.non_glossary_stats["all"]
112 )
114 def get_color_name(self, color):
115 """Return color name based on allowed ones."""
116 if color not in self.colors:
117 return self.colors[0]
118 return color
120 def get_percent_text(self):
121 return pgettext("Translated percents", "%(percent)s%%") % {
122 "percent": int(self.percent)
123 }
125 def render(self, request: HttpRequest, response: HttpResponse) -> None:
126 raise NotImplementedError
129class BitmapWidget(Widget):
130 """Base class for bitmap rendering widgets."""
132 colors: tuple[str, ...] = ("grey", "white", "black")
133 extension = "png"
134 content_type = "image/png"
135 order = 100
136 head_template = '<span letter_spacing="-500"><b>{}</b></span>'
137 foot_template = '<span letter_spacing="1000">{}</span>'
138 font_size = 10
139 line_spacing = 1.0
140 offset = 0
141 column_offset = 0
142 lines = True
144 def __init__(self, obj, color=None, lang=None) -> None:
145 """Create Widget object."""
146 super().__init__(obj, color, lang)
147 # Get object and related params
148 if isinstance(self.stats, TranslationStats):
149 self.total = self.non_glossary_stats["all"]
150 else:
151 self.total = self.non_glossary_stats["source_strings"]
152 self.languages = self.stats.languages
153 self.params = self.get_text_params()
155 # Set rendering variables
156 self.draw = None
157 self.width = 0
159 def get_text_params(self):
160 """Create dictionary used for text formatting."""
161 return {
162 "name": self.obj.name if self.obj else settings.SITE_TITLE,
163 "count": self.total,
164 "languages": self.languages,
165 "percent": self.percent,
166 }
168 def get_filename(self):
169 """Return widgets filename."""
170 return find_static_file(
171 os.path.join(
172 "widget-images",
173 f"{self.name}-{self.color}.png",
174 )
175 )
177 def get_columns(self) -> list[list[str]]:
178 raise NotImplementedError
180 def get_column_width(self, surface, columns):
181 return surface.get_width() // len(columns)
183 def get_column_fonts(self):
184 return [
185 Pango.FontDescription(f"{WIDGET_FONT} {self.font_size * 1.5}"),
186 Pango.FontDescription(f"{WIDGET_FONT} {self.font_size}"),
187 ]
189 def render_additional(self, ctx) -> None:
190 return
192 def render(self, request: HttpRequest, response: HttpResponse) -> None:
193 """Render widget."""
194 configure_fontconfig()
195 surface = cairo.ImageSurface.create_from_png(self.get_filename())
196 height = surface.get_height()
197 ctx = cairo.Context(surface)
199 columns = self.get_columns()
200 column_width = self.get_column_width(surface, columns)
202 fonts = self.get_column_fonts()
204 for i, column in enumerate(columns):
205 offset = self.offset
206 for row, text in enumerate(column):
207 layout = PangoCairo.create_layout(ctx)
208 layout.set_font_description(fonts[row])
210 # Set color and position
211 ctx.move_to(self.column_offset + column_width * i, offset)
212 ctx.set_source_rgb(*COLOR_DATA[self.color])
214 # Add text
215 layout.set_markup(text)
216 layout.set_alignment(Pango.Alignment.CENTER)
217 layout.set_width(column_width * Pango.SCALE)
219 offset += layout.get_pixel_size().height * self.line_spacing
221 # Render to cairo context
222 PangoCairo.show_layout(ctx, layout)
224 # Render column separators
225 if self.lines and i > 0:
226 ctx.new_path()
227 ctx.set_source_rgb(*COLOR_DATA[self.color])
228 ctx.set_line_width(0.5)
229 ctx.move_to(column_width * i, self.offset)
230 ctx.line_to(column_width * i, height - self.offset)
231 ctx.stroke()
233 self.render_additional(ctx)
235 surface.write_to_png(response)
238class SVGWidget(Widget):
239 """Base class for SVG rendering widgets."""
241 extension = "svg"
242 content_type = "image/svg+xml; charset=utf-8"
243 template_name = ""
245 def render(self, request: HttpRequest, response: HttpResponse) -> None:
246 raise NotImplementedError
249class PNGWidget(SVGWidget):
250 extension = "png"
251 content_type = "image/png"
253 def render(self, request: HttpRequest, response: HttpResponse) -> None:
254 with StringIO() as output:
255 super().render(request, output)
256 svgdata = output.getvalue()
258 handle = Rsvg.Handle.new_from_data(svgdata.encode())
259 if hasattr(handle, "get_intrinsic_size_in_pixels"):
260 # librsvg 2.52 and newer
261 out_dimensions = handle.get_intrinsic_size_in_pixels()
262 width = int(out_dimensions.out_width)
263 height = int(out_dimensions.out_height)
264 else:
265 dimensions = handle.get_dimensions()
266 width = int(dimensions.width)
267 height = int(dimensions.height)
268 surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, width, height)
269 context = cairo.Context(surface)
270 if hasattr(Rsvg, "Rectangle"):
271 # librsvg 2.46 and newer
272 viewport = Rsvg.Rectangle()
273 viewport.x = 0
274 viewport.y = 0
275 viewport.width = width
276 viewport.height = height
277 handle.render_document(context, viewport)
278 else:
279 # librsvg before 2.46, this method is now deprecated
280 handle.render_cairo(context)
281 surface.write_to_png(response)
284@register_widget
285class NormalWidget(BitmapWidget):
286 name = "287x66"
287 order = 110
288 offset = 10
289 # Translators: status widget name
290 verbose = gettext_lazy("Big status badge")
292 def get_columns(self):
293 return [
294 [
295 format_html(self.head_template, number_format(self.total)),
296 format_html(
297 self.foot_template,
298 npgettext(
299 "Label on engage page", "String", "Strings", self.total
300 ).upper(),
301 ),
302 ],
303 [
304 format_html(self.head_template, number_format(self.languages)),
305 format_html(
306 self.foot_template,
307 npgettext(
308 "Label on engage page", "Language", "Languages", self.languages
309 ).upper(),
310 ),
311 ],
312 [
313 format_html(self.head_template, self.get_percent_text()),
314 format_html(self.foot_template, gettext("Translated").upper()),
315 ],
316 ]
319@register_widget
320class SmallWidget(BitmapWidget):
321 name = "88x31"
322 order = 111
323 font_size = 7
324 line_spacing = 0.8
325 offset = -1
326 # Translators: status widget name
327 verbose = gettext_lazy("Small status badge")
329 def get_columns(self):
330 return [
331 [
332 format_html(self.head_template, self.get_percent_text()),
333 format_html(self.foot_template, gettext("Translated").upper()),
334 ]
335 ]
338@register_widget
339class OpenGraphWidget(NormalWidget):
340 name = "open"
341 colors: tuple[str, ...] = ("graph",)
342 order = 120
343 lines = False
344 offset = 300
345 font_size = 20
346 column_offset = 265
347 head_template = '<span letter_spacing="-1000">{}</span>'
348 foot_template = '<span letter_spacing="2000">{}</span>'
349 verbose = pgettext_lazy("Status widget name", "Panel")
351 def get_column_width(self, surface, columns) -> int:
352 return 230
354 def get_column_fonts(self):
355 return [
356 Pango.FontDescription(f"{WIDGET_FONT} {42}"),
357 Pango.FontDescription(f"{WIDGET_FONT} {18}"),
358 ]
360 def get_name(self) -> str:
361 if self.obj is None:
362 return settings.SITE_TITLE
363 return str(self.obj)
365 def get_title(self, name: str, suffix: str = "") -> str:
366 if self.obj is None:
367 template = "{}"
368 elif isinstance(self.obj, Project):
369 # Translators: Text on OpenGraph image
370 template = gettext("Project {}")
371 else:
372 # Translators: Text on OpenGraph image
373 template = gettext("Component {}")
375 return format_html(template, format_html("<b>{}</b>{}", name, suffix))
377 def render_additional(self, ctx) -> None:
378 ctx.move_to(280, 170)
379 layout = PangoCairo.create_layout(ctx)
380 layout.set_font_description(Pango.FontDescription(f"{WIDGET_FONT} {52}"))
381 name = self.get_name()
382 layout.set_markup(self.get_title(name))
384 max_width = 1200 - 280
385 while layout.get_size().width / Pango.SCALE > max_width:
386 if " " in name:
387 name = name.rsplit(" ", 1)[0]
388 elif "-" in name:
389 name = name.rsplit("-", 1)[0]
390 elif "_" in name:
391 name = name.rsplit("_", 1)[0]
392 else:
393 name = name[:-1]
394 layout.set_markup(self.get_title(f"{name}", "…"))
395 if not name:
396 break
398 PangoCairo.show_layout(ctx, layout)
401class BaseSVGBadgeWidget(SVGWidget):
402 colors: tuple[str, ...] = ("badge",)
403 template_name = "svg/badge.svg"
405 def render_badge(
406 self, response: HttpResponse, label: str, value: str, color: str
407 ) -> None:
408 label_width = render_size(f" {label} ")[0].width
409 value_width = render_size(f" {value} ")[0].width
411 response.write(
412 render_to_string(
413 self.template_name,
414 {
415 "label": label,
416 "value": value,
417 "label_width": label_width,
418 "value_width": value_width,
419 "width": label_width + value_width,
420 "color": color,
421 "translated_offset": label_width // 2,
422 "percent_offset": label_width + value_width // 2,
423 "lang": get_language(),
424 "fonts_cdn_url": settings.FONTS_CDN_URL,
425 },
426 )
427 )
430@register_widget
431class SVGBadgeWidget(BaseSVGBadgeWidget):
432 name = "svg"
433 order = 80
434 # Translators: status widget name
435 verbose = gettext_lazy("SVG status badge")
437 def render(self, request: HttpRequest, response: HttpResponse) -> None:
438 translated_text = gettext("translated")
439 percent_text = self.get_percent_text()
440 if self.percent >= 90:
441 color = "#4c1"
442 elif self.percent >= 75:
443 color = "#dfb317"
444 else:
445 color = "#e05d44"
446 self.render_badge(response, translated_text, percent_text, color)
449@register_widget
450class PNGBadgeWidget(PNGWidget, SVGBadgeWidget):
451 name = "status"
452 # Translators: status widget name
453 verbose = gettext_lazy("PNG status badge")
456@register_widget
457class MultiLanguageWidget(SVGWidget):
458 name = "multi"
459 order = 81
460 colors: tuple[str, ...] = ("auto", "red", "green", "blue")
461 template_name = "svg/multi-language-badge.svg"
462 verbose = pgettext_lazy("Status widget name", "Vertical language bar chart")
464 COLOR_MAP: ClassVar[dict[str, str | None]] = {
465 "red": "#fa3939",
466 "green": "#3fed48",
467 "blue": "#3f85ed",
468 "auto": None,
469 }
471 def render(self, request: HttpRequest, response: HttpResponse) -> None:
472 translations = []
473 offset = 20
474 color = self.COLOR_MAP[self.color]
475 language_width = 190
476 languages: list[BaseStats | ProjectLanguage]
477 if isinstance(self.stats, (ProjectLanguageStats, TranslationStats)):
478 languages = [self.stats]
479 elif isinstance(self.obj, ProjectLanguage):
480 languages = [self.obj]
481 elif isinstance(self.obj, Language):
482 languages = [self.obj.stats]
483 else:
484 languages = self.stats.get_language_stats()
485 for stats in sort_unicode(languages, lambda x: str(x.language)):
486 # Skip empty translations
487 if stats.translated == 0:
488 continue
489 language = stats.language
490 percent = stats.translated_percent
491 if self.color == "auto" or color is None:
492 color = get_percent_color(percent)
493 language_name = str(language)
495 language_width = max(
496 language_width,
497 (render_size(text=language_name)[0].width + 10),
498 )
499 if self.obj is None:
500 project_language = language
501 else:
502 project_language = ProjectLanguage(self.obj, language)
503 translations.append(
504 (
505 # Language name
506 language_name,
507 # Translation percent
508 int(percent),
509 # Text y offset
510 offset,
511 # Bar y offset
512 offset - 6,
513 # Bar width
514 int(percent * 1.5),
515 # Bar color
516 color,
517 # Row URL
518 get_site_url(project_language.get_absolute_url()),
519 # Top offset for horizontal
520 10 + int((100 - percent) * 1.5),
521 )
522 )
523 offset += 15
525 response.write(
526 render_to_string(
527 self.template_name,
528 {
529 "height": len(translations) * 15 + 15,
530 "width": language_width + 210,
531 "language_offset": language_width,
532 "bar_offset": language_width + 10,
533 "text_offset": language_width + 170,
534 "translations": translations,
535 "site_url": get_site_url(),
536 "horizontal_height": language_width + 130,
537 "fonts_cdn_url": settings.FONTS_CDN_URL,
538 },
539 )
540 )
543@register_widget
544class HorizontalMultiLanguageWidget(MultiLanguageWidget):
545 name = "horizontal"
546 order = 82
547 template_name = "svg/multi-language-badge-horizontal.svg"
548 verbose = pgettext_lazy("Status widget name", "Horizontal language bar chart")
551@register_widget
552class LanguageBadgeWidget(BaseSVGBadgeWidget):
553 name = "language"
554 order = 83
555 # Translators: status widget name
556 verbose = gettext_lazy("Language count badge")
557 extra_parameters: ClassVar[list[ExtraParametersDict]] = [
558 {
559 "name": "threshold",
560 "label": gettext("Threshold"),
561 "type": "number",
562 "default": 0,
563 "min": 0,
564 "max": 100,
565 "step": 1,
566 }
567 ]
569 def render(self, request: HttpRequest, response: HttpResponse) -> None:
570 try:
571 threshold = int(request.GET.get("threshold", 0))
572 except ValueError as e:
573 messages.error(
574 request,
575 gettext("Error in parameter %(field)s: %(error)s")
576 % {
577 "field": "threshold",
578 "error": str(e),
579 },
580 )
581 threshold = 0
583 languages: list[BaseStats | ProjectLanguage]
584 if isinstance(self.stats, (ProjectLanguageStats, TranslationStats)):
585 languages = [self.stats]
586 elif isinstance(self.obj, ProjectLanguage):
587 languages = [self.obj]
588 elif isinstance(self.obj, Language):
589 languages = [self.obj.stats]
590 else:
591 languages = self.stats.get_language_stats()
593 language_count = sum(
594 1 for lang in languages if lang.translated_percent >= threshold
595 )
596 languages_text = ngettext("language", "languages", language_count)
598 self.render_badge(response, languages_text, str(language_count), "#3fed48")