Coverage for an_website / quotes / image.py: 83.929%

168 statements  

« prev     ^ index     » next       coverage.py v7.13.1, created at 2026-01-11 19:37 +0000

1# This program is free software: you can redistribute it and/or modify 

2# it under the terms of the GNU Affero General Public License as 

3# published by the Free Software Foundation, either version 3 of the 

4# License, or (at your option) any later version. 

5# 

6# This program is distributed in the hope that it will be useful, 

7# but WITHOUT ANY WARRANTY; without even the implied warranty of 

8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

9# GNU Affero General Public License for more details. 

10# 

11# You should have received a copy of the GNU Affero General Public License 

12# along with this program. If not, see <https://www.gnu.org/licenses/>. 

13 

14"""A module that generates an image from a wrong quote.""" 

15 

16from __future__ import annotations 

17 

18import asyncio 

19import io 

20import logging 

21import math 

22import os 

23import sys 

24import textwrap 

25import time 

26from collections import ChainMap 

27from collections.abc import Iterable, Mapping, Set 

28from itertools import pairwise 

29from tempfile import TemporaryDirectory 

30from typing import Any, ClassVar, Final 

31 

32import qoi_rs 

33from PIL import Image, ImageDraw, ImageFont 

34from PIL.Image import new as create_empty_image 

35from tornado.web import HTTPError 

36 

37from .. import EPOCH 

38from ..utils import static_file_handling 

39from .utils import ( 

40 DIR, 

41 QuoteReadyCheckHandler, 

42 get_wrong_quote, 

43 get_wrong_quotes, 

44) 

45 

46try: 

47 from unexpected_isaves.save_image import ( # type: ignore[import, unused-ignore] 

48 to_excel, 

49 ) 

50except ModuleNotFoundError: 

51 to_excel = None # pylint: disable=invalid-name 

52 

53LOGGER: Final = logging.getLogger(__name__) 

54 

55AUTHOR_MAX_WIDTH: Final[int] = 686 

56QUOTE_MAX_WIDTH: Final[int] = 900 

57DEBUG_COLOR: Final[tuple[int, int, int]] = 245, 53, 170 

58DEBUG_COLOR2: Final[tuple[int, int, int]] = 224, 231, 34 

59TEXT_COLOR: Final[tuple[int, int, int]] = 230, 230, 230 

60 

61 

62_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes() 

63 

64FONT: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=50) 

65FONT_SMALLER: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=44) 

66FONT_SMALLEST: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=32) 

67HOST_NAME_FONT: Final = ImageFont.truetype( 

68 font=io.BytesIO(_FONT_BYTES), size=23 

69) 

70 

71del _FONT_BYTES 

72 

73FILE_EXTENSIONS: Final[Mapping[str, str]] = { 

74 "bmp": "bmp", 

75 "gif": "gif", 

76 "jfif": "jpeg", 

77 "jpe": "jpeg", 

78 "jpeg": "jpeg", 

79 "jpg": "jpeg", 

80 "jxl": "jxl", 

81 "pdf": "pdf", 

82 "png": "png", 

83 # "ppm": "ppm", 

84 # "sgi": "sgi", 

85 "spi": "spider", 

86 "spider": "spider", 

87 "tga": "tga", 

88 "tiff": "tiff", 

89 "txt": "txt", 

90 "webp": "webp", 

91 "qoi": "qoi", 

92 **({"xlsx": "xlsx"} if to_excel else {}), 

93} 

94 

95CONTENT_TYPES: Final[Mapping[str, str]] = ChainMap( 

96 { 

97 "spider": "image/x-spider", 

98 "tga": "image/x-tga", 

99 "qoi": "image/qoi", 

100 }, 

101 static_file_handling.CONTENT_TYPES, # type: ignore[arg-type] 

102) 

103 

104CONTENT_TYPE_FILE_TYPE_MAPPING: Final[Mapping[str, str]] = { 

105 CONTENT_TYPES[ext]: ext for ext in FILE_EXTENSIONS.values() 

106} 

107IMAGE_CONTENT_TYPES: Final[Set[str]] = frozenset(CONTENT_TYPE_FILE_TYPE_MAPPING) 

108IMAGE_CONTENT_TYPES_WITHOUT_TXT: Final[tuple[str, ...]] = tuple( 

109 sorted(IMAGE_CONTENT_TYPES - {"text/plain"}, key="image/gif".__ne__) 

110) 

111 

112 

113def load_png(filename: str) -> Image.Image: 

114 """Load a PNG image into memory.""" 

115 with (DIR / "files" / f"{filename}.png").open("rb") as file: # noqa: SIM117 

116 with Image.open(file, formats=("PNG",)) as image: 

117 return image.copy() 

118 

119 

120BACKGROUND_IMAGE: Final = load_png("bg") 

121IMAGE_WIDTH, IMAGE_HEIGHT = BACKGROUND_IMAGE.size 

122WITZIG_IMAGE: Final = load_png("StempelWitzig") 

123NICHT_WITZIG_IMAGE: Final = load_png("StempelNichtWitzig") 

124 

125 

126def get_lines_and_max_height( 

127 text: str, 

128 max_width: int, 

129 font: ImageFont.FreeTypeFont, 

130) -> tuple[list[str], int]: 

131 """Get the lines of the text and the max line height.""" 

132 column_count = 80 

133 lines: list[str] = [] 

134 

135 max_line_length: float = max_width + 1 

136 while max_line_length > max_width: # pylint: disable=while-used 

137 lines = textwrap.wrap(text, width=column_count) 

138 max_line_length = max(font.getlength(line) for line in lines) 

139 column_count -= 1 

140 

141 return lines, int(max(font.getbbox(line)[3] for line in lines)) 

142 

143 

144def draw_text( # pylint: disable=too-many-arguments 

145 image: ImageDraw.ImageDraw, 

146 text: str, 

147 x: int, 

148 y: int, 

149 font: ImageFont.FreeTypeFont, 

150 stroke_width: int = 0, 

151 *, 

152 display_bounds: bool = sys.flags.dev_mode, 

153) -> None: 

154 """Draw a text on an image.""" 

155 image.text( 

156 (x, y), 

157 text, 

158 font=font, 

159 fill=TEXT_COLOR, 

160 align="right", 

161 stroke_width=stroke_width, 

162 spacing=54, 

163 ) 

164 if display_bounds: 

165 x_off, y_off, right, bottom = font.getbbox( 

166 text, stroke_width=stroke_width 

167 ) 

168 image.rectangle((x, y, x + right, y + bottom), outline=DEBUG_COLOR) 

169 image.rectangle( 

170 (x + x_off, y + y_off, x + right, y + bottom), outline=DEBUG_COLOR2 

171 ) 

172 

173 

174def draw_lines( # pylint: disable=too-many-arguments 

175 image: ImageDraw.ImageDraw, 

176 lines: Iterable[str], 

177 y_start: int, 

178 max_w: int, 

179 max_h: int, 

180 font: ImageFont.FreeTypeFont, 

181 padding_left: int = 0, 

182 stroke_width: int = 0, 

183) -> int: 

184 """Draw the lines on the image and return the last y position.""" 

185 for line in lines: 

186 width = font.getlength(line) 

187 draw_text( 

188 image, 

189 line, 

190 padding_left + math.ceil((max_w - width) / 2), 

191 y_start, 

192 font, 

193 stroke_width, 

194 ) 

195 y_start += max_h 

196 return y_start 

197 

198 

199def create_image( # noqa: C901 # pylint: disable=too-complex 

200 # pylint: disable=too-many-arguments, too-many-branches 

201 # pylint: disable=too-many-locals, too-many-statements 

202 quote: str, 

203 author: str, 

204 rating: None | int, 

205 source: None | str, 

206 file_type: str = "png", 

207 font: ImageFont.FreeTypeFont = FONT, 

208 *, 

209 include_kangaroo: bool = True, 

210 wq_id: None | str = None, 

211) -> bytes: 

212 """Create an image with the given quote and author.""" 

213 image = ( 

214 BACKGROUND_IMAGE.copy() 

215 if include_kangaroo 

216 else create_empty_image("RGB", BACKGROUND_IMAGE.size, 0) 

217 ) 

218 draw = ImageDraw.Draw(image, mode="RGB") 

219 

220 max_width = IMAGE_WIDTH if font is FONT_SMALLEST else QUOTE_MAX_WIDTH 

221 

222 # draw quote 

223 quote_str = f"»{quote}«" 

224 width, max_line_height = font.getbbox(quote_str)[2:] 

225 if width <= AUTHOR_MAX_WIDTH: 

226 quote_lines = [quote_str] 

227 else: 

228 quote_lines, max_line_height = get_lines_and_max_height( 

229 quote_str, max_width, font 

230 ) 

231 if len(quote_lines) < 3: 

232 y_start = 175 

233 elif len(quote_lines) < 4: 

234 y_start = 125 

235 elif len(quote_lines) < 6: 

236 y_start = 75 

237 else: 

238 y_start = 50 

239 y_text = draw_lines( 

240 draw, 

241 quote_lines, 

242 y_start, 

243 max_width, 

244 int(max_line_height), 

245 font, 

246 padding_left=0, 

247 stroke_width=1 if file_type == "4-color-gif" else 0, 

248 ) 

249 

250 # draw author 

251 author_str = f"- {author}" 

252 width, max_line_height = font.getbbox(author_str)[2:] 

253 if width <= AUTHOR_MAX_WIDTH: 

254 author_lines = [author_str] 

255 else: 

256 author_lines, max_line_height = get_lines_and_max_height( 

257 author_str, AUTHOR_MAX_WIDTH, font 

258 ) 

259 y_text = draw_lines( 

260 draw, 

261 author_lines, 

262 max( 

263 y_text + 20, IMAGE_HEIGHT - (220 if len(author_lines) < 3 else 280) 

264 ), 

265 AUTHOR_MAX_WIDTH, 

266 int(max_line_height), 

267 font, 

268 padding_left=10, 

269 stroke_width=1 if file_type == "4-color-gif" else 0, 

270 ) 

271 

272 if y_text > IMAGE_HEIGHT: 

273 for prev, smaller in pairwise((FONT, FONT_SMALLER, FONT_SMALLEST)): 

274 if font is not prev: 

275 continue 

276 

277 LOGGER.info( 

278 "Using smaller font (%s) for quote %s", smaller.size, source 

279 ) 

280 return create_image( 

281 quote, 

282 author, 

283 rating, 

284 source, 

285 file_type, 

286 smaller, 

287 wq_id=wq_id, 

288 ) 

289 

290 LOGGER.error("Quote doesn't fit on the image %r", quote) 

291 

292 # draw rating 

293 if rating: 

294 _, y_off, width, height = FONT_SMALLER.getbbox(str(rating)) 

295 y_rating = IMAGE_HEIGHT - 25 - int(height) 

296 draw_text( 

297 draw, 

298 str(rating), 

299 25, 

300 y_rating, 

301 FONT_SMALLER, # always use same font for rating 

302 1, 

303 ) 

304 # draw rating image 

305 icon = NICHT_WITZIG_IMAGE if rating < 0 else WITZIG_IMAGE 

306 image.paste( 

307 icon, 

308 box=( 

309 25 + 5 + int(width), 

310 y_rating + int(y_off / 2), 

311 ), 

312 mask=icon, 

313 ) 

314 

315 # draw host name 

316 if source: 

317 width, height = HOST_NAME_FONT.getbbox(source)[2:] 

318 draw_text( 

319 draw, 

320 source, 

321 IMAGE_WIDTH - 5 - int(width), 

322 IMAGE_HEIGHT - 5 - int(height), 

323 HOST_NAME_FONT, 

324 0, 

325 ) 

326 

327 if file_type == "qoi": 

328 return qoi_rs.encode_pillow(image) 

329 

330 if to_excel and file_type == "xlsx": 

331 with TemporaryDirectory() as tempdir_name: 

332 filepath = os.path.join(tempdir_name, f"{wq_id or '0-0'}.xlsx") 

333 to_excel(image, filepath, lower_image_size_by=10) 

334 with open(filepath, "rb") as file: 

335 return file.read() 

336 

337 kwargs: dict[str, Any] = { 

338 "format": file_type, 

339 "optimize": True, 

340 "save_all": False, 

341 } 

342 

343 if file_type == "4-color-gif": 

344 colors: list[tuple[int, tuple[int, int, int]]] 

345 colors = image.getcolors(2**16) # type: ignore[assignment] 

346 colors.sort(reverse=True) 

347 palette = bytearray() 

348 for _, color in colors[:4]: 

349 palette.extend(color) 

350 kwargs.update(format="gif", palette=palette) 

351 elif file_type == "jxl": 

352 kwargs.update(lossless=True) 

353 elif file_type == "pdf": 

354 timestamp = time.gmtime(EPOCH) 

355 kwargs.update( 

356 title=wq_id or "0-0", 

357 author=author, 

358 subject=quote, 

359 creationDate=timestamp, 

360 modDate=timestamp, 

361 ) 

362 elif file_type == "tga": 

363 kwargs.update(compression="tga_rle") 

364 elif file_type == "tiff": 

365 kwargs.update(compression="zlib") 

366 elif file_type == "webp": 

367 kwargs.update(lossless=True) 

368 

369 image.save(buffer := io.BytesIO(), **kwargs) 

370 return buffer.getvalue() 

371 

372 

373class QuoteAsImage(QuoteReadyCheckHandler): 

374 """Quote as image request handler.""" 

375 

376 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = () 

377 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

378 

379 async def get( 

380 self, 

381 quote_id: str, 

382 author_id: str, 

383 file_extension: None | str = None, 

384 *, 

385 head: bool = False, 

386 ) -> None: 

387 """Handle GET requests to this page and render the quote as image.""" 

388 file_type: None | str 

389 if file_extension is None: 

390 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT) 

391 assert self.content_type 

392 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type] 

393 file_extension = file_type 

394 elif not (file_type := FILE_EXTENSIONS.get(file_extension.lower())): 

395 reason = ( 

396 f"Unsupported file extension: {file_extension.lower()} (supported:" 

397 f" {', '.join(sorted(set(FILE_EXTENSIONS.values())))})" 

398 ) 

399 self.set_status(404, reason=reason) 

400 self.write_error(404, reason=reason) 

401 return 

402 

403 content_type = CONTENT_TYPES[file_type] 

404 

405 self.handle_accept_header((content_type,)) 

406 

407 int_quote_id = int(quote_id) 

408 wrong_quote = ( 

409 await get_wrong_quote(int_quote_id, int(author_id)) 

410 if author_id 

411 else ( 

412 get_wrong_quotes(lambda wq: wq.id == int_quote_id) or (None,) 

413 )[0] 

414 ) 

415 if wrong_quote is None: 

416 raise HTTPError(404, reason="Falsches Zitat nicht gefunden") 

417 

418 if file_type == "txt": 

419 await self.finish(str(wrong_quote)) 

420 return 

421 

422 self.set_header( 

423 "Content-Disposition", 

424 ( 

425 f"inline; filename={self.request.host.replace('.', '-')}_z_" 

426 f"{wrong_quote.get_id_as_str()}.{file_extension.lower()}" 

427 ), 

428 ) 

429 

430 if head: 

431 return 

432 

433 if file_type == "gif" and self.get_bool_argument("small", False): 

434 file_type = "4-color-gif" 

435 

436 return await self.finish( 

437 await asyncio.to_thread( 

438 create_image, 

439 wrong_quote.quote.quote, 

440 wrong_quote.author.name, 

441 rating=( 

442 None 

443 if self.get_bool_argument("no_rating", False) 

444 else wrong_quote.rating 

445 ), 

446 source=( 

447 None 

448 if self.get_bool_argument("no_source", False) 

449 else f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}" 

450 ), 

451 file_type=file_type, 

452 include_kangaroo=not self.get_bool_argument( 

453 "no_kangaroo", False 

454 ), 

455 wq_id=wrong_quote.get_id_as_str(), 

456 ) 

457 )