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
« 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/>.
14"""A module that generates an image from a wrong quote."""
16from __future__ import annotations
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
32import qoi_rs
33from PIL import Image, ImageDraw, ImageFont
34from PIL.Image import new as create_empty_image
35from tornado.web import HTTPError
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)
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
53LOGGER: Final = logging.getLogger(__name__)
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
62_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes()
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)
71del _FONT_BYTES
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}
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)
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)
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()
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")
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] = []
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
141 return lines, int(max(font.getbbox(line)[3] for line in lines))
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 )
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
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")
220 max_width = IMAGE_WIDTH if font is FONT_SMALLEST else QUOTE_MAX_WIDTH
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 )
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 )
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
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 )
290 LOGGER.error("Quote doesn't fit on the image %r", quote)
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 )
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 )
327 if file_type == "qoi":
328 return qoi_rs.encode_pillow(image)
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()
337 kwargs: dict[str, Any] = {
338 "format": file_type,
339 "optimize": True,
340 "save_all": False,
341 }
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)
369 image.save(buffer := io.BytesIO(), **kwargs)
370 return buffer.getvalue()
373class QuoteAsImage(QuoteReadyCheckHandler):
374 """Quote as image request handler."""
376 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
377 RATELIMIT_GET_LIMIT: ClassVar[int] = 15
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
403 content_type = CONTENT_TYPES[file_type]
405 self.handle_accept_header((content_type,))
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")
418 if file_type == "txt":
419 await self.finish(str(wrong_quote))
420 return
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 )
430 if head:
431 return
433 if file_type == "gif" and self.get_bool_argument("small", False):
434 file_type = "4-color-gif"
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 )