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

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

2# 

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

4 

5from __future__ import annotations 

6 

7import os.path 

8from io import StringIO 

9from typing import TYPE_CHECKING, ClassVar, Literal, TypedDict 

10 

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) 

26 

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 

43 

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 

47 

48 from weblate.utils.stats import ( 

49 BaseStats, 

50 ) 

51 

52gi.require_version("PangoCairo", "1.0") 

53gi.require_version("Pango", "1.0") 

54gi.require_version("Rsvg", "2.0") 

55 

56from gi.repository import Pango, PangoCairo, Rsvg # noqa: E402 

57 

58COLOR_DATA = { 

59 "grey": (0, 0, 0), 

60 "white": (0, 0, 0), 

61 "black": (255, 255, 255), 

62 "graph": (255, 255, 255), 

63} 

64 

65WIDGETS: dict[str, type[Widget]] = {} 

66WIDGET_FONT = "Source Sans 3" 

67 

68 

69def register_widget(widget): 

70 """Register widget in dictionary.""" 

71 WIDGETS[widget.name] = widget 

72 return widget 

73 

74 

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 

83 

84 

85class Widget: 

86 """Generic widget class.""" 

87 

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]] = [] 

95 

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 ) 

113 

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 

119 

120 def get_percent_text(self): 

121 return pgettext("Translated percents", "%(percent)s%%") % { 

122 "percent": int(self.percent) 

123 } 

124 

125 def render(self, request: HttpRequest, response: HttpResponse) -> None: 

126 raise NotImplementedError 

127 

128 

129class BitmapWidget(Widget): 

130 """Base class for bitmap rendering widgets.""" 

131 

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 

143 

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() 

154 

155 # Set rendering variables 

156 self.draw = None 

157 self.width = 0 

158 

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 } 

167 

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 ) 

176 

177 def get_columns(self) -> list[list[str]]: 

178 raise NotImplementedError 

179 

180 def get_column_width(self, surface, columns): 

181 return surface.get_width() // len(columns) 

182 

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 ] 

188 

189 def render_additional(self, ctx) -> None: 

190 return 

191 

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) 

198 

199 columns = self.get_columns() 

200 column_width = self.get_column_width(surface, columns) 

201 

202 fonts = self.get_column_fonts() 

203 

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]) 

209 

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]) 

213 

214 # Add text 

215 layout.set_markup(text) 

216 layout.set_alignment(Pango.Alignment.CENTER) 

217 layout.set_width(column_width * Pango.SCALE) 

218 

219 offset += layout.get_pixel_size().height * self.line_spacing 

220 

221 # Render to cairo context 

222 PangoCairo.show_layout(ctx, layout) 

223 

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() 

232 

233 self.render_additional(ctx) 

234 

235 surface.write_to_png(response) 

236 

237 

238class SVGWidget(Widget): 

239 """Base class for SVG rendering widgets.""" 

240 

241 extension = "svg" 

242 content_type = "image/svg+xml; charset=utf-8" 

243 template_name = "" 

244 

245 def render(self, request: HttpRequest, response: HttpResponse) -> None: 

246 raise NotImplementedError 

247 

248 

249class PNGWidget(SVGWidget): 

250 extension = "png" 

251 content_type = "image/png" 

252 

253 def render(self, request: HttpRequest, response: HttpResponse) -> None: 

254 with StringIO() as output: 

255 super().render(request, output) 

256 svgdata = output.getvalue() 

257 

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) 

282 

283 

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") 

291 

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 ] 

317 

318 

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") 

328 

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 ] 

336 

337 

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") 

350 

351 def get_column_width(self, surface, columns) -> int: 

352 return 230 

353 

354 def get_column_fonts(self): 

355 return [ 

356 Pango.FontDescription(f"{WIDGET_FONT} {42}"), 

357 Pango.FontDescription(f"{WIDGET_FONT} {18}"), 

358 ] 

359 

360 def get_name(self) -> str: 

361 if self.obj is None: 

362 return settings.SITE_TITLE 

363 return str(self.obj) 

364 

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 {}") 

374 

375 return format_html(template, format_html("<b>{}</b>{}", name, suffix)) 

376 

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)) 

383 

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 

397 

398 PangoCairo.show_layout(ctx, layout) 

399 

400 

401class BaseSVGBadgeWidget(SVGWidget): 

402 colors: tuple[str, ...] = ("badge",) 

403 template_name = "svg/badge.svg" 

404 

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 

410 

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 ) 

428 

429 

430@register_widget 

431class SVGBadgeWidget(BaseSVGBadgeWidget): 

432 name = "svg" 

433 order = 80 

434 # Translators: status widget name 

435 verbose = gettext_lazy("SVG status badge") 

436 

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) 

447 

448 

449@register_widget 

450class PNGBadgeWidget(PNGWidget, SVGBadgeWidget): 

451 name = "status" 

452 # Translators: status widget name 

453 verbose = gettext_lazy("PNG status badge") 

454 

455 

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") 

463 

464 COLOR_MAP: ClassVar[dict[str, str | None]] = { 

465 "red": "#fa3939", 

466 "green": "#3fed48", 

467 "blue": "#3f85ed", 

468 "auto": None, 

469 } 

470 

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) 

494 

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 

524 

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 ) 

541 

542 

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") 

549 

550 

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 ] 

568 

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 

582 

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() 

592 

593 language_count = sum( 

594 1 for lang in languages if lang.translated_percent >= threshold 

595 ) 

596 languages_text = ngettext("language", "languages", language_count) 

597 

598 self.render_badge(response, languages_text, str(language_count), "#3fed48")