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

167 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-24 17:35 +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 

16import asyncio 

17import io 

18import logging 

19import math 

20import os 

21import sys 

22import textwrap 

23import time 

24from collections import ChainMap 

25from collections.abc import Iterable, Mapping, Set 

26from itertools import pairwise 

27from tempfile import TemporaryDirectory 

28from typing import Any, ClassVar, Final 

29 

30import qoi_rs 

31from PIL import Image, ImageDraw, ImageFont 

32from PIL.Image import new as create_empty_image 

33from tornado.web import HTTPError 

34 

35from .. import EPOCH 

36from ..utils import static_file_handling 

37from .utils import ( 

38 DIR, 

39 QuoteReadyCheckHandler, 

40 get_wrong_quote, 

41 get_wrong_quotes, 

42) 

43 

44try: 

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

46 to_excel, 

47 ) 

48except ModuleNotFoundError: 

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

50 

51LOGGER: Final = logging.getLogger(__name__) 

52 

53AUTHOR_MAX_WIDTH: Final[int] = 686 

54QUOTE_MAX_WIDTH: Final[int] = 900 

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

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

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

58 

59 

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

61 

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

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

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

65HOST_NAME_FONT: Final = ImageFont.truetype( 

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

67) 

68 

69del _FONT_BYTES 

70 

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

72 "bmp": "bmp", 

73 "gif": "gif", 

74 "jfif": "jpeg", 

75 "jpe": "jpeg", 

76 "jpeg": "jpeg", 

77 "jpg": "jpeg", 

78 "jxl": "jxl", 

79 "pdf": "pdf", 

80 "png": "png", 

81 # "ppm": "ppm", 

82 # "sgi": "sgi", 

83 "spi": "spider", 

84 "spider": "spider", 

85 "tga": "tga", 

86 "tiff": "tiff", 

87 "txt": "txt", 

88 "webp": "webp", 

89 "qoi": "qoi", 

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

91} 

92 

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

94 { 

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

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

97 "qoi": "image/qoi", 

98 }, 

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

100) 

101 

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

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

104} 

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

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

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

108) 

109 

110 

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

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

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

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

115 return image.copy() 

116 

117 

118BACKGROUND_IMAGE: Final = load_png("bg") 

119IMAGE_WIDTH, IMAGE_HEIGHT = BACKGROUND_IMAGE.size 

120WITZIG_IMAGE: Final = load_png("StempelWitzig") 

121NICHT_WITZIG_IMAGE: Final = load_png("StempelNichtWitzig") 

122 

123 

124def get_lines_and_max_height( 

125 text: str, 

126 max_width: int, 

127 font: ImageFont.FreeTypeFont, 

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

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

130 column_count = 80 

131 lines: list[str] = [] 

132 

133 max_line_length: float = max_width + 1 

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

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

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

137 column_count -= 1 

138 

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

140 

141 

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

143 image: ImageDraw.ImageDraw, 

144 text: str, 

145 x: int, 

146 y: int, 

147 font: ImageFont.FreeTypeFont, 

148 stroke_width: int = 0, 

149 *, 

150 display_bounds: bool = sys.flags.dev_mode, 

151) -> None: 

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

153 image.text( 

154 (x, y), 

155 text, 

156 font=font, 

157 fill=TEXT_COLOR, 

158 align="right", 

159 stroke_width=stroke_width, 

160 spacing=54, 

161 ) 

162 if display_bounds: 

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

164 text, stroke_width=stroke_width 

165 ) 

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

167 image.rectangle( 

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

169 ) 

170 

171 

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

173 image: ImageDraw.ImageDraw, 

174 lines: Iterable[str], 

175 y_start: int, 

176 max_w: int, 

177 max_h: int, 

178 font: ImageFont.FreeTypeFont, 

179 padding_left: int = 0, 

180 stroke_width: int = 0, 

181) -> int: 

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

183 for line in lines: 

184 width = font.getlength(line) 

185 draw_text( 

186 image, 

187 line, 

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

189 y_start, 

190 font, 

191 stroke_width, 

192 ) 

193 y_start += max_h 

194 return y_start 

195 

196 

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

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

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

200 quote: str, 

201 author: str, 

202 rating: None | int, 

203 source: None | str, 

204 file_type: str = "png", 

205 font: ImageFont.FreeTypeFont = FONT, 

206 *, 

207 include_kangaroo: bool = True, 

208 wq_id: None | str = None, 

209) -> bytes: 

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

211 image = ( 

212 BACKGROUND_IMAGE.copy() 

213 if include_kangaroo 

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

215 ) 

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

217 

218 max_width = IMAGE_WIDTH if font is FONT_SMALLEST else QUOTE_MAX_WIDTH 

219 

220 # draw quote 

221 quote_str = f"»{quote}«" 

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

223 if width <= AUTHOR_MAX_WIDTH: 

224 quote_lines = [quote_str] 

225 else: 

226 quote_lines, max_line_height = get_lines_and_max_height( 

227 quote_str, max_width, font 

228 ) 

229 if len(quote_lines) < 3: 

230 y_start = 175 

231 elif len(quote_lines) < 4: 

232 y_start = 125 

233 elif len(quote_lines) < 6: 

234 y_start = 75 

235 else: 

236 y_start = 50 

237 y_text = draw_lines( 

238 draw, 

239 quote_lines, 

240 y_start, 

241 max_width, 

242 int(max_line_height), 

243 font, 

244 padding_left=0, 

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

246 ) 

247 

248 # draw author 

249 author_str = f"- {author}" 

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

251 if width <= AUTHOR_MAX_WIDTH: 

252 author_lines = [author_str] 

253 else: 

254 author_lines, max_line_height = get_lines_and_max_height( 

255 author_str, AUTHOR_MAX_WIDTH, font 

256 ) 

257 y_text = draw_lines( 

258 draw, 

259 author_lines, 

260 max( 

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

262 ), 

263 AUTHOR_MAX_WIDTH, 

264 int(max_line_height), 

265 font, 

266 padding_left=10, 

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

268 ) 

269 

270 if y_text > IMAGE_HEIGHT: 

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

272 if font is not prev: 

273 continue 

274 

275 LOGGER.info( 

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

277 ) 

278 return create_image( 

279 quote, 

280 author, 

281 rating, 

282 source, 

283 file_type, 

284 smaller, 

285 wq_id=wq_id, 

286 ) 

287 

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

289 

290 # draw rating 

291 if rating: 

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

293 y_rating = IMAGE_HEIGHT - 25 - int(height) 

294 draw_text( 

295 draw, 

296 str(rating), 

297 25, 

298 y_rating, 

299 FONT_SMALLER, # always use same font for rating 

300 1, 

301 ) 

302 # draw rating image 

303 icon = NICHT_WITZIG_IMAGE if rating < 0 else WITZIG_IMAGE 

304 image.paste( 

305 icon, 

306 box=( 

307 25 + 5 + int(width), 

308 y_rating + int(y_off / 2), 

309 ), 

310 mask=icon, 

311 ) 

312 

313 # draw host name 

314 if source: 

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

316 draw_text( 

317 draw, 

318 source, 

319 IMAGE_WIDTH - 5 - int(width), 

320 IMAGE_HEIGHT - 5 - int(height), 

321 HOST_NAME_FONT, 

322 0, 

323 ) 

324 

325 if file_type == "qoi": 

326 return qoi_rs.encode_pillow(image) 

327 

328 if to_excel and file_type == "xlsx": 

329 with TemporaryDirectory() as tempdir_name: 

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

331 to_excel(image, filepath, lower_image_size_by=10) 

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

333 return file.read() 

334 

335 kwargs: dict[str, Any] = { 

336 "format": file_type, 

337 "optimize": True, 

338 "save_all": False, 

339 } 

340 

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

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

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

344 colors.sort(reverse=True) 

345 palette = bytearray() 

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

347 palette.extend(color) 

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

349 elif file_type == "jxl": 

350 kwargs.update(lossless=True) 

351 elif file_type == "pdf": 

352 timestamp = time.gmtime(EPOCH) 

353 kwargs.update( 

354 title=wq_id or "0-0", 

355 author=author, 

356 subject=quote, 

357 creationDate=timestamp, 

358 modDate=timestamp, 

359 ) 

360 elif file_type == "tga": 

361 kwargs.update(compression="tga_rle") 

362 elif file_type == "tiff": 

363 kwargs.update(compression="zlib") 

364 elif file_type == "webp": 

365 kwargs.update(lossless=True) 

366 

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

368 return buffer.getvalue() 

369 

370 

371class QuoteAsImage(QuoteReadyCheckHandler): 

372 """Quote as image request handler.""" 

373 

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

375 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

376 

377 async def get( 

378 self, 

379 quote_id: str, 

380 author_id: str, 

381 file_extension: None | str = None, 

382 *, 

383 head: bool = False, 

384 ) -> None: 

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

386 file_type: None | str 

387 if file_extension is None: 

388 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT) 

389 assert self.content_type 

390 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type] 

391 file_extension = file_type 

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

393 reason = ( 

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

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

396 ) 

397 self.set_status(404, reason=reason) 

398 self.write_error(404, reason=reason) 

399 return 

400 

401 content_type = CONTENT_TYPES[file_type] 

402 

403 self.handle_accept_header((content_type,)) 

404 

405 int_quote_id = int(quote_id) 

406 wrong_quote = ( 

407 await get_wrong_quote(int_quote_id, int(author_id)) 

408 if author_id 

409 else ( 

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

411 )[0] 

412 ) 

413 if wrong_quote is None: 

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

415 

416 if file_type == "txt": 

417 await self.finish(str(wrong_quote)) 

418 return 

419 

420 self.set_header( 

421 "Content-Disposition", 

422 ( 

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

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

425 ), 

426 ) 

427 

428 if head: 

429 return 

430 

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

432 file_type = "4-color-gif" 

433 

434 return await self.finish( 

435 await asyncio.to_thread( 

436 create_image, 

437 wrong_quote.quote.quote, 

438 wrong_quote.author.name, 

439 rating=( 

440 None 

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

442 else wrong_quote.rating 

443 ), 

444 source=( 

445 None 

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

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

448 ), 

449 file_type=file_type, 

450 include_kangaroo=not self.get_bool_argument( 

451 "no_kangaroo", False 

452 ), 

453 wq_id=wrong_quote.get_id_as_str(), 

454 ) 

455 )