Coverage for app/venv/lib/python3.14/site-packages/weblate/fonts/utils.py: 56%
109 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
5"""Font handling wrapper."""
7from __future__ import annotations
9import os
10from functools import cache, lru_cache
11from io import BytesIO
12from typing import NamedTuple
14import cairo
15import gi
16from django.core.cache import cache as django_cache
17from django.db.models.fields.files import FieldFile
19from weblate.utils.data import data_path
20from weblate.utils.hash import calculate_hash
21from weblate.utils.icons import find_static_file
23gi.require_version("PangoCairo", "1.0")
24gi.require_version("Pango", "1.0")
25from gi.repository import Pango, PangoCairo # noqa: E402
28class Dimensions(NamedTuple):
29 width: int
30 height: int
33FONTCONFIG_CONFIG = """<?xml version="1.0"?>
34<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
35<fontconfig>
36 <cachedir>{}</cachedir>
37 <dir>{}</dir>
38 <dir>{}</dir>
39 <dir>{}</dir>
40 <config>
41 <rescan>
42 <int>30</int>
43 </rescan>
44 </config>
46 <alias>
47 <family>sans-serif</family>
48 <prefer>
49 <family>Source Sans 3</family>
50 <family>Kurinto Sans</family>
51 </prefer>
52 </alias>
54 <alias>
55 <family>Source Sans 3</family>
56 <default><family>sans-serif</family></default>
57 </alias>
59 <alias>
60 <family>Kurinto Sans</family>
61 <default><family>sans-serif</family></default>
62 </alias>
64 <!--
65 Synthetic emboldening for fonts that do not have bold face available
66 -->
67 <match target="font">
68 <test name="weight" compare="less_eq">
69 <const>medium</const>
70 </test>
71 <test target="pattern" name="weight" compare="more_eq">
72 <const>bold</const>
73 </test>
74 <edit name="embolden" mode="assign">
75 <bool>true</bool>
76 </edit>
77 <edit name="weight" mode="assign">
78 <const>bold</const>
79 </edit>
80 </match>
81 <!--
82 Enable slight hinting for better sub-pixel rendering
83 -->
84 <match target="pattern">
85 <edit name="hintstyle" mode="append"><const>hintslight</const></edit>
86 </match>
87</fontconfig>
88"""
90FONT_WEIGHTS = {
91 "normal": Pango.Weight.NORMAL,
92 "light": Pango.Weight.LIGHT,
93 "bold": Pango.Weight.BOLD,
94 "": None,
95}
98@cache
99def configure_fontconfig() -> None:
100 """Configure fontconfig to use custom configuration."""
101 fonts_dir = data_path("fonts")
102 cache_dir = data_path("cache") / "fonts"
103 fonts_dir.mkdir(parents=True, exist_ok=True)
104 config_file = fonts_dir / "fonts.conf"
106 # Generate the configuration
107 config_file.write_text(
108 FONTCONFIG_CONFIG.format(
109 cache_dir.as_posix(),
110 fonts_dir.as_posix(),
111 os.path.dirname(
112 find_static_file(
113 "js/vendor/fonts/font-source/TTF/SourceSans3-Regular.ttf"
114 )
115 ),
116 os.path.dirname(find_static_file("vendor/font-kurinto/KurintoSans-Rg.ttf")),
117 )
118 )
120 # Inject into environment
121 os.environ["FONTCONFIG_FILE"] = config_file.as_posix()
124def get_font_weight(weight: str) -> Pango.Weight | None:
125 return FONT_WEIGHTS[weight]
128@lru_cache(maxsize=32)
129def _render_size(
130 text: str,
131 *,
132 font: str = "Kurinto Sans",
133 weight: int | Pango.Weight | None = Pango.Weight.NORMAL,
134 size: int = 11,
135 spacing: int = 0,
136 width: int = 1000,
137 lines: int = 1,
138 needs_output: bool = False,
139 surface_height: int | None = None,
140 surface_width: int | None = None,
141) -> tuple[Dimensions, int, bytes]:
142 """Check whether rendered text fits."""
143 configure_fontconfig()
145 # Setup Pango/Cairo
146 if surface_height is None: 146 ↛ 148line 146 didn't jump to line 148 because the condition on line 146 was always true
147 surface_height = int(lines * size * 1.5)
148 if surface_width is None: 148 ↛ 150line 148 didn't jump to line 150 because the condition on line 148 was always true
149 surface_width = width
150 surface = cairo.ImageSurface(cairo.FORMAT_RGB24, surface_width, surface_height)
151 context = cairo.Context(surface)
153 layout = PangoCairo.create_layout(context)
155 # Load and configure font
156 fontdesc = Pango.FontDescription.from_string(font)
157 fontdesc.set_absolute_size(size * Pango.SCALE)
158 if weight: 158 ↛ 160line 158 didn't jump to line 160 because the condition on line 158 was always true
159 fontdesc.set_weight(weight)
160 layout.set_font_description(fontdesc)
162 # Configure spacing
163 if spacing: 163 ↛ 164line 163 didn't jump to line 164 because the condition on line 163 was never true
164 letter_spacing_attr = Pango.attr_letter_spacing_new(Pango.SCALE * spacing)
165 attr_list = Pango.AttrList()
166 attr_list.insert(letter_spacing_attr)
167 layout.set_attributes(attr_list)
169 # Set the actual text
170 layout.set_text(text)
172 # Set width and line wrapping
173 layout.set_width(width * Pango.SCALE)
174 layout.set_wrap(Pango.WrapMode.WORD)
176 # Calculate dimensions
177 line_count = layout.get_line_count()
178 pixel_size = Dimensions(*layout.get_pixel_size())
180 buffer = b""
182 if needs_output: 182 ↛ 184line 182 didn't jump to line 184 because the condition on line 182 was never true
183 # Adjust surface dimensions if we're actually rendering
184 if pixel_size.height > surface_height or pixel_size.width > surface_width:
185 return _render_size(
186 text,
187 font=font,
188 weight=weight,
189 size=size,
190 spacing=spacing,
191 width=width,
192 lines=lines,
193 needs_output=needs_output,
194 surface_height=pixel_size.height,
195 surface_width=pixel_size.width,
196 )
198 # Render background
199 context.save()
200 # This matches .img-check CSS style
201 context.set_source_rgb(0.8, 0.8, 0.8)
202 context.paint()
203 context.restore()
205 # Show text
206 PangoCairo.show_layout(context, layout)
208 # Render box around desired size
209 expected_height = lines * pixel_size.height / line_count
210 context.new_path()
211 context.set_source_rgb(0.1, 0.1, 0.1)
212 context.set_line_width(1)
213 context.move_to(1, 1)
214 context.line_to(width - 1, 1)
215 context.line_to(width - 1, expected_height - 1)
216 context.line_to(1, expected_height - 1)
217 context.line_to(1, 1)
218 context.stroke()
220 # Render box about actual size if it does not fit
221 if pixel_size.width > width or line_count > lines:
222 context.new_path()
223 context.set_source_rgb(246 / 255, 102 / 255, 76 / 255)
224 context.set_line_width(1)
225 context.move_to(1, 1)
226 context.line_to(pixel_size.width - 1, 1)
227 context.line_to(pixel_size.width - 1, pixel_size.height - 1)
228 context.line_to(1, pixel_size.height - 1)
229 context.line_to(1, 1)
230 context.stroke()
232 with BytesIO() as buff:
233 surface.write_to_png(buff)
234 buffer = buff.getvalue()
236 return pixel_size, line_count, buffer
239def render_size(
240 text: str,
241 *,
242 font: str = "Kurinto Sans",
243 weight: int | None = Pango.Weight.NORMAL,
244 size: int = 11,
245 spacing: int = 0,
246 width: int = 1000,
247 lines: int = 1,
248 cache_key: str | None = None,
249 surface_height: int | None = None,
250 surface_width: int | None = None,
251) -> tuple[Dimensions, int]:
252 render_cache_key = f"render:{calculate_hash(text)}:{calculate_hash(font)}:{int(weight) if weight is not None else ''}:{size}:{spacing}:{width}:{lines}:{cache_key}:{surface_height}:{surface_width}"
253 cached: tuple[Dimensions, int] | None = django_cache.get(render_cache_key)
254 if cached and (cache_key is None or django_cache.get(cache_key)):
255 return cached
256 pixel_size, line_count, buffer = _render_size(
257 text,
258 font=font,
259 weight=weight,
260 size=size,
261 spacing=spacing,
262 width=width,
263 lines=lines,
264 needs_output=cache_key is not None,
265 surface_height=surface_height,
266 surface_width=surface_width,
267 )
268 if cache_key: 268 ↛ 270line 268 didn't jump to line 270 because the condition on line 268 was never true
269 # Longer expiry for rendered results so that it can be recalculated
270 django_cache.set(cache_key, buffer, timeout=4200)
271 result = pixel_size, line_count
272 django_cache.set(render_cache_key, result, timeout=3600)
273 return result
276def check_render_size(
277 *,
278 font: str,
279 weight: int | None,
280 size: int,
281 spacing: int,
282 text: str,
283 width: int,
284 lines: int,
285 cache_key: str | None = None,
286) -> bool:
287 """Check whether rendered text fits."""
288 rendered_size, actual_lines = render_size(
289 font=font,
290 weight=weight,
291 size=size,
292 spacing=spacing,
293 text=text,
294 width=width,
295 lines=lines,
296 cache_key=cache_key,
297 )
298 return rendered_size.width <= width and actual_lines <= lines
301def get_font_name(filelike):
302 """Return tuple of font family and style, for example ('Ubuntu', 'Regular')."""
303 from PIL import ImageFont
305 # Form uploaded file
306 if isinstance(filelike, FieldFile):
307 filelike = filelike.file
309 if not hasattr(filelike, "loaded_font"):
310 filelike.loaded_font = ImageFont.truetype(filelike)
311 return filelike.loaded_font.getname()