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
« 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/>.
14"""A module that generates an image from a wrong quote."""
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
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
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)
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
57LOGGER: Final = logging.getLogger(__name__)
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
65FONT_SIZES: Final[tuple[int, ...]] = (50, 44, 32)
67_TEXT_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes()
68_EMOJI_FONT_BYTES = (get_openmoji_font_data() / "glyf_colr0.ttf").read_bytes()
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)
77del _TEXT_FONT_BYTES, _EMOJI_FONT_BYTES
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}
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)
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)
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()
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")
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)
141 return width
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] = []
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
159 return lines, int(max(font.getbbox(line)[3] for line in lines))
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
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
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 )
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 )
215 curr_x += token_font.getlength(token)
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
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")
264 max_width = IMAGE_WIDTH if font_size <= FONT_SIZES[-1] else QUOTE_MAX_WIDTH
266 font = (
267 TEXT_FONT.font_variant(size=font_size)
268 if TEXT_FONT.size != font_size
269 else TEXT_FONT
270 )
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 )
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 )
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 )
337 LOGGER.error("Quote doesn't fit on the image %r", quote)
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 )
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 )
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 )
389 if file_type == "qoi":
390 return qoi_rs.encode_pillow(image)
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()
399 kwargs: dict[str, Any] = {
400 "format": file_type,
401 "optimize": True,
402 "save_all": False,
403 }
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)
431 image.save(buffer := io.BytesIO(), **kwargs)
432 return buffer.getvalue()
435class QuoteAsImage(QuoteReadyCheckHandler):
436 """Quote as image request handler."""
438 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
439 RATELIMIT_GET_LIMIT: ClassVar[int] = 15
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
465 content_type = CONTENT_TYPES[file_type]
467 self.handle_accept_header((content_type,))
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")
480 if file_type == "txt":
481 await self.finish(str(wrong_quote))
482 return
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 )
492 if head:
493 return
495 if file_type == "gif" and self.get_bool_argument("small", False):
496 file_type = "4-color-gif"
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 )