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
« 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/>.
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 itertools import pairwise
27from tempfile import TemporaryDirectory
28from typing import Any, ClassVar, Final
30import qoi_rs
31from PIL import Image, ImageDraw, ImageFont
32from PIL.Image import new as create_empty_image
33from tornado.web import HTTPError
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)
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
51LOGGER: Final = logging.getLogger(__name__)
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
60_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes()
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)
69del _FONT_BYTES
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}
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)
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)
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()
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")
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] = []
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
139 return lines, int(max(font.getbbox(line)[3] for line in lines))
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 )
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
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")
218 max_width = IMAGE_WIDTH if font is FONT_SMALLEST else QUOTE_MAX_WIDTH
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 )
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 )
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
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 )
288 LOGGER.error("Quote doesn't fit on the image %r", quote)
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 )
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 )
325 if file_type == "qoi":
326 return qoi_rs.encode_pillow(image)
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()
335 kwargs: dict[str, Any] = {
336 "format": file_type,
337 "optimize": True,
338 "save_all": False,
339 }
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)
367 image.save(buffer := io.BytesIO(), **kwargs)
368 return buffer.getvalue()
371class QuoteAsImage(QuoteReadyCheckHandler):
372 """Quote as image request handler."""
374 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
375 RATELIMIT_GET_LIMIT: ClassVar[int] = 15
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
401 content_type = CONTENT_TYPES[file_type]
403 self.handle_accept_header((content_type,))
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")
416 if file_type == "txt":
417 await self.finish(str(wrong_quote))
418 return
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 )
428 if head:
429 return
431 if file_type == "gif" and self.get_bool_argument("small", False):
432 file_type = "4-color-gif"
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 )