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

190 statements  

« prev     ^ index     » next       coverage.py v7.15.2, created at 2026-08-01 07:01 +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 tempfile import TemporaryDirectory 

27from typing import Any, ClassVar, Final 

28 

29import openmoji_dist 

30import qoi_rs 

31from openmoji_dist import get_openmoji_font_data 

32from PIL import Image, ImageDraw, ImageFont 

33from PIL.Image import new as create_empty_image 

34from tornado.web import HTTPError 

35from typed_stream import Stream 

36 

37from .. import EPOCH 

38from ..utils import static_file_handling 

39from ..utils.emoji import ( 

40 split_text_into_emoji_and_non_emoji_parts, 

41 text_contains_emoji, 

42) 

43from .utils import ( 

44 DIR, 

45 QuoteReadyCheckHandler, 

46 get_wrong_quote, 

47 get_wrong_quotes, 

48) 

49 

50try: 

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

52 to_excel, 

53 ) 

54except ModuleNotFoundError: 

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

56 

57LOGGER: Final = logging.getLogger(__name__) 

58 

59AUTHOR_MAX_WIDTH: Final[int] = 686 

60QUOTE_MAX_WIDTH: Final[int] = 900 

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

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

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

64 

65FONT_SIZES: Final[tuple[int, ...]] = (50, 44, 32) 

66 

67_TEXT_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes() 

68_EMOJI_FONT_BYTES = (get_openmoji_font_data() / "glyf_colr0.ttf").read_bytes() 

69 

70TEXT_FONT: Final = ImageFont.truetype( 

71 font=io.BytesIO(_TEXT_FONT_BYTES), size=FONT_SIZES[0] 

72) 

73EMOJI_FONT: Final = ImageFont.truetype( 

74 font=io.BytesIO(_EMOJI_FONT_BYTES), size=FONT_SIZES[0] 

75) 

76 

77del _TEXT_FONT_BYTES, _EMOJI_FONT_BYTES 

78 

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

80 "bmp": "bmp", 

81 "gif": "gif", 

82 "jfif": "jpeg", 

83 "jpe": "jpeg", 

84 "jpeg": "jpeg", 

85 "jpg": "jpeg", 

86 "jxl": "jxl", 

87 "pdf": "pdf", 

88 "png": "png", 

89 # "ppm": "ppm", 

90 # "sgi": "sgi", 

91 "spi": "spider", 

92 "spider": "spider", 

93 "tga": "tga", 

94 "tiff": "tiff", 

95 "txt": "txt", 

96 "webp": "webp", 

97 "qoi": "qoi", 

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

99} 

100 

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

102 { 

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

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

105 "qoi": "image/qoi", 

106 }, 

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

108) 

109 

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

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

112} 

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

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

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

116) 

117 

118 

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

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

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

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

123 return image.copy() 

124 

125 

126BACKGROUND_IMAGE: Final = load_png("bg") 

127IMAGE_WIDTH, IMAGE_HEIGHT = BACKGROUND_IMAGE.size 

128WITZIG_IMAGE: Final = load_png("StempelWitzig") 

129NICHT_WITZIG_IMAGE: Final = load_png("StempelNichtWitzig") 

130 

131 

132def get_line_width(text: str, font: ImageFont.FreeTypeFont) -> float: 

133 """Get the width of a line.""" 

134 width = 0.0 

135 for token, is_emoji in split_text_into_emoji_and_non_emoji_parts(text): 

136 token_font = ( 

137 EMOJI_FONT.font_variant(size=font.size) if is_emoji else font 

138 ) 

139 width += token_font.getlength(token) 

140 

141 return width 

142 

143 

144def get_lines_and_max_height( 

145 text: str, 

146 max_width: int, 

147 font: ImageFont.FreeTypeFont, 

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

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

150 column_count = 80 

151 lines: list[str] = [] 

152 

153 max_line_length: float = max_width + 1 

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

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

156 max_line_length = max(get_line_width(line, font) for line in lines) 

157 column_count -= 1 

158 

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

160 

161 

162def draw_text( # pylint: disable=too-many-arguments, too-many-locals 

163 image: ImageDraw.ImageDraw, 

164 text: str, 

165 x: int, 

166 y: int, 

167 font: ImageFont.FreeTypeFont, 

168 stroke_width: int = 0, 

169 *, 

170 display_bounds: bool = sys.flags.dev_mode, 

171) -> None: 

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

173 curr_x: float = x 

174 

175 for token, is_emoji in split_text_into_emoji_and_non_emoji_parts(text): 

176 token_font: ImageFont.FreeTypeFont 

177 delta_y: int 

178 if is_emoji: 

179 token_font = EMOJI_FONT.font_variant(size=font.size) 

180 delta_y = int(0.067 * font.size) 

181 else: 

182 token_font = font 

183 delta_y = 0 

184 

185 image.text( 

186 (curr_x, y + delta_y), 

187 token, 

188 font=token_font, 

189 fill=TEXT_COLOR, 

190 align="right", 

191 stroke_width=stroke_width, 

192 spacing=54, 

193 embedded_color=is_emoji, 

194 ) 

195 

196 if display_bounds: 

197 x_off, y_off, right, bottom = token_font.getbbox( 

198 token, 

199 stroke_width=stroke_width, 

200 ) 

201 image.rectangle( 

202 (curr_x, y + delta_y, curr_x + right, y + delta_y + bottom), 

203 outline=DEBUG_COLOR, 

204 ) 

205 image.rectangle( 

206 ( 

207 curr_x + x_off, 

208 y + delta_y + y_off, 

209 curr_x + right, 

210 y + delta_y + bottom, 

211 ), 

212 outline=DEBUG_COLOR2, 

213 ) 

214 

215 curr_x += token_font.getlength(token) 

216 

217 

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

219 image: ImageDraw.ImageDraw, 

220 lines: Iterable[str], 

221 y_start: int, 

222 max_w: int, 

223 max_h: int, 

224 font: ImageFont.FreeTypeFont, 

225 padding_left: int = 0, 

226 stroke_width: int = 0, 

227) -> int: 

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

229 for line in lines: 

230 width = get_line_width(line, font) 

231 draw_text( 

232 image, 

233 line, 

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

235 y_start, 

236 font, 

237 stroke_width, 

238 ) 

239 y_start += max_h 

240 return y_start 

241 

242 

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

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

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

246 quote: str, 

247 author: str, 

248 rating: None | int, 

249 source: None | str, 

250 file_type: str = "png", 

251 font_size: int = FONT_SIZES[0], 

252 *, 

253 include_kangaroo: bool = True, 

254 wq_id: None | str = None, 

255) -> bytes: 

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

257 image = ( 

258 BACKGROUND_IMAGE.copy() 

259 if include_kangaroo 

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

261 ) 

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

263 

264 max_width = IMAGE_WIDTH if font_size <= FONT_SIZES[-1] else QUOTE_MAX_WIDTH 

265 

266 font = ( 

267 TEXT_FONT.font_variant(size=font_size) 

268 if TEXT_FONT.size != font_size 

269 else TEXT_FONT 

270 ) 

271 

272 # draw quote 

273 quote_str = f"»{quote}«" 

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

275 if width <= AUTHOR_MAX_WIDTH: 

276 quote_lines = [quote_str] 

277 else: 

278 quote_lines, max_line_height = get_lines_and_max_height( 

279 quote_str, max_width, font 

280 ) 

281 if len(quote_lines) < 3: 

282 y_start = 175 

283 elif len(quote_lines) < 4: 

284 y_start = 125 

285 elif len(quote_lines) < 6: 

286 y_start = 75 

287 else: 

288 y_start = 50 

289 y_text = draw_lines( 

290 draw, 

291 quote_lines, 

292 y_start, 

293 max_width, 

294 int(max_line_height), 

295 font, 

296 padding_left=0, 

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

298 ) 

299 

300 # draw author 

301 author_str = f"- {author}" 

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

303 if width <= AUTHOR_MAX_WIDTH: 

304 author_lines = [author_str] 

305 else: 

306 author_lines, max_line_height = get_lines_and_max_height( 

307 author_str, AUTHOR_MAX_WIDTH, font 

308 ) 

309 y_text = draw_lines( 

310 draw, 

311 author_lines, 

312 max( 

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

314 ), 

315 AUTHOR_MAX_WIDTH, 

316 int(max_line_height), 

317 font, 

318 padding_left=10, 

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

320 ) 

321 

322 if y_text > IMAGE_HEIGHT: 

323 for smaller in Stream(FONT_SIZES).drop_while( 

324 lambda size: size >= font_size 

325 ): 

326 LOGGER.info("Using smaller font (%s) for quote %s", smaller, source) 

327 return create_image( 

328 quote, 

329 author, 

330 rating, 

331 source, 

332 file_type, 

333 font_size=smaller, 

334 wq_id=wq_id, 

335 ) 

336 

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

338 

339 # draw rating 

340 if rating: 

341 font_smaller = TEXT_FONT.font_variant(size=44) 

342 _, y_off, width, height = font_smaller.getbbox(str(rating)) 

343 y_rating = IMAGE_HEIGHT - 25 - int(height) 

344 draw_text( 

345 draw, 

346 str(rating), 

347 25, 

348 y_rating, 

349 font_smaller, # always use same font for rating 

350 1, 

351 ) 

352 # draw rating image 

353 icon = NICHT_WITZIG_IMAGE if rating < 0 else WITZIG_IMAGE 

354 image.paste( 

355 icon, 

356 box=( 

357 25 + 5 + int(width), 

358 y_rating + int(y_off / 2), 

359 ), 

360 mask=icon, 

361 ) 

362 

363 # draw host name 

364 if source: 

365 host_name_font = TEXT_FONT.font_variant(size=23) 

366 width, height = host_name_font.getbbox(source)[2:] 

367 draw_text( 

368 draw, 

369 source, 

370 IMAGE_WIDTH - 5 - int(width), 

371 IMAGE_HEIGHT - 5 - int(height), 

372 host_name_font, 

373 0, 

374 ) 

375 

376 if text_contains_emoji(quote) or text_contains_emoji(author): 

377 host_name_font = TEXT_FONT.font_variant(size=12) 

378 attribution = openmoji_dist.ATTRIBUTION 

379 width, _height = host_name_font.getbbox(attribution)[2:] 

380 draw_text( 

381 draw, 

382 attribution, 

383 IMAGE_WIDTH - 5 - int(width), 

384 5, 

385 host_name_font, 

386 0, 

387 ) 

388 

389 if file_type == "qoi": 

390 return qoi_rs.encode_pillow(image) 

391 

392 if to_excel and file_type == "xlsx": 

393 with TemporaryDirectory() as tempdir_name: 

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

395 to_excel(image, filepath, lower_image_size_by=10) 

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

397 return file.read() 

398 

399 kwargs: dict[str, Any] = { 

400 "format": file_type, 

401 "optimize": True, 

402 "save_all": False, 

403 } 

404 

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

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

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

408 colors.sort(reverse=True) 

409 palette = bytearray() 

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

411 palette.extend(color) 

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

413 elif file_type == "jxl": 

414 kwargs.update(lossless=True) 

415 elif file_type == "pdf": 

416 timestamp = time.gmtime(EPOCH) 

417 kwargs.update( 

418 title=wq_id or "0-0", 

419 author=author, 

420 subject=quote, 

421 creationDate=timestamp, 

422 modDate=timestamp, 

423 ) 

424 elif file_type == "tga": 

425 kwargs.update(compression="tga_rle") 

426 elif file_type == "tiff": 

427 kwargs.update(compression="zlib") 

428 elif file_type == "webp": 

429 kwargs.update(lossless=True) 

430 

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

432 return buffer.getvalue() 

433 

434 

435class QuoteAsImage(QuoteReadyCheckHandler): 

436 """Quote as image request handler.""" 

437 

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

439 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

440 

441 async def get( 

442 self, 

443 quote_id: str, 

444 author_id: str, 

445 file_extension: None | str = None, 

446 *, 

447 head: bool = False, 

448 ) -> None: 

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

450 file_type: None | str 

451 if file_extension is None: 

452 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT) 

453 assert self.content_type 

454 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type] 

455 file_extension = file_type 

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

457 reason = ( 

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

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

460 ) 

461 self.set_status(404, reason=reason) 

462 self.write_error(404, reason=reason) 

463 return 

464 

465 content_type = CONTENT_TYPES[file_type] 

466 

467 self.handle_accept_header((content_type,)) 

468 

469 int_quote_id = int(quote_id) 

470 wrong_quote = ( 

471 await get_wrong_quote(int_quote_id, int(author_id)) 

472 if author_id 

473 else ( 

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

475 )[0] 

476 ) 

477 if wrong_quote is None: 

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

479 

480 if file_type == "txt": 

481 await self.finish(str(wrong_quote)) 

482 return 

483 

484 self.set_header( 

485 "Content-Disposition", 

486 ( 

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

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

489 ), 

490 ) 

491 

492 if head: 

493 return 

494 

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

496 file_type = "4-color-gif" 

497 

498 return await self.finish( 

499 await asyncio.to_thread( 

500 create_image, 

501 ( 

502 self.sub_stanley(wrong_quote.quote.quote) 

503 if self.stanley() 

504 else wrong_quote.quote.quote 

505 ), 

506 ( 

507 self.sub_stanley(wrong_quote.author.name) 

508 if self.stanley() 

509 else wrong_quote.author.name 

510 ), 

511 rating=( 

512 None 

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

514 else wrong_quote.rating 

515 ), 

516 source=( 

517 None 

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

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

520 ), 

521 file_type=file_type, 

522 include_kangaroo=not self.get_bool_argument( 

523 "no_kangaroo", False 

524 ), 

525 wq_id=wrong_quote.get_id_as_str(), 

526 ) 

527 )