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

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

2# 

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

4 

5"""Font handling wrapper.""" 

6 

7from __future__ import annotations 

8 

9import os 

10from functools import cache, lru_cache 

11from io import BytesIO 

12from typing import NamedTuple 

13 

14import cairo 

15import gi 

16from django.core.cache import cache as django_cache 

17from django.db.models.fields.files import FieldFile 

18 

19from weblate.utils.data import data_path 

20from weblate.utils.hash import calculate_hash 

21from weblate.utils.icons import find_static_file 

22 

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

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

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

26 

27 

28class Dimensions(NamedTuple): 

29 width: int 

30 height: int 

31 

32 

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> 

45 

46 <alias> 

47 <family>sans-serif</family> 

48 <prefer> 

49 <family>Source Sans 3</family> 

50 <family>Kurinto Sans</family> 

51 </prefer> 

52 </alias> 

53 

54 <alias> 

55 <family>Source Sans 3</family> 

56 <default><family>sans-serif</family></default> 

57 </alias> 

58 

59 <alias> 

60 <family>Kurinto Sans</family> 

61 <default><family>sans-serif</family></default> 

62 </alias> 

63 

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

89 

90FONT_WEIGHTS = { 

91 "normal": Pango.Weight.NORMAL, 

92 "light": Pango.Weight.LIGHT, 

93 "bold": Pango.Weight.BOLD, 

94 "": None, 

95} 

96 

97 

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" 

105 

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 ) 

119 

120 # Inject into environment 

121 os.environ["FONTCONFIG_FILE"] = config_file.as_posix() 

122 

123 

124def get_font_weight(weight: str) -> Pango.Weight | None: 

125 return FONT_WEIGHTS[weight] 

126 

127 

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

144 

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) 

152 

153 layout = PangoCairo.create_layout(context) 

154 

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) 

161 

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) 

168 

169 # Set the actual text 

170 layout.set_text(text) 

171 

172 # Set width and line wrapping 

173 layout.set_width(width * Pango.SCALE) 

174 layout.set_wrap(Pango.WrapMode.WORD) 

175 

176 # Calculate dimensions 

177 line_count = layout.get_line_count() 

178 pixel_size = Dimensions(*layout.get_pixel_size()) 

179 

180 buffer = b"" 

181 

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 ) 

197 

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

204 

205 # Show text 

206 PangoCairo.show_layout(context, layout) 

207 

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

219 

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

231 

232 with BytesIO() as buff: 

233 surface.write_to_png(buff) 

234 buffer = buff.getvalue() 

235 

236 return pixel_size, line_count, buffer 

237 

238 

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 

274 

275 

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 

299 

300 

301def get_font_name(filelike): 

302 """Return tuple of font family and style, for example ('Ubuntu', 'Regular').""" 

303 from PIL import ImageFont 

304 

305 # Form uploaded file 

306 if isinstance(filelike, FieldFile): 

307 filelike = filelike.file 

308 

309 if not hasattr(filelike, "loaded_font"): 

310 filelike.loaded_font = ImageFont.truetype(filelike) 

311 return filelike.loaded_font.getname()