Coverage for an_website/quotes/image.py: 85.714%
161 statements
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-02 20:57 +0000
« prev ^ index » next coverage.py v7.11.0, created at 2025-11-02 20:57 +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 tempfile import TemporaryDirectory
29from typing import Any, ClassVar, Final
31import qoi_rs
32from PIL import Image, ImageDraw, ImageFont
33from PIL.Image import new as create_empty_image
34from tornado.web import HTTPError
36from .. import EPOCH
37from ..utils import static_file_handling
38from .utils import (
39 DIR,
40 QuoteReadyCheckHandler,
41 get_wrong_quote,
42 get_wrong_quotes,
43)
45try:
46 from unexpected_isaves.save_image import ( # type: ignore[import, unused-ignore]
47 to_excel,
48 )
49except ModuleNotFoundError:
50 to_excel = None # pylint: disable=invalid-name
52LOGGER: Final = logging.getLogger(__name__)
54AUTHOR_MAX_WIDTH: Final[int] = 686
55QUOTE_MAX_WIDTH: Final[int] = 900
56DEBUG_COLOR: Final[tuple[int, int, int]] = 245, 53, 170
57DEBUG_COLOR2: Final[tuple[int, int, int]] = 224, 231, 34
58TEXT_COLOR: Final[tuple[int, int, int]] = 230, 230, 230
61_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes()
63FONT: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=50)
64FONT_SMALLER: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=44)
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() # type: ignore[no-any-return]
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 = 46
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 # draw quote
219 quote_str = f"»{quote}«"
220 width, max_line_height = font.getbbox(quote_str)[2:]
221 if width <= AUTHOR_MAX_WIDTH:
222 quote_lines = [quote_str]
223 else:
224 quote_lines, max_line_height = get_lines_and_max_height(
225 quote_str, QUOTE_MAX_WIDTH, font
226 )
227 if len(quote_lines) < 3:
228 y_start = 175
229 elif len(quote_lines) < 4:
230 y_start = 125
231 elif len(quote_lines) < 6:
232 y_start = 75
233 else:
234 y_start = 50
235 y_text = draw_lines(
236 draw,
237 quote_lines,
238 y_start,
239 QUOTE_MAX_WIDTH,
240 int(max_line_height),
241 font,
242 0,
243 1 if file_type == "4-color-gif" else 0,
244 )
246 # draw author
247 author_str = f"- {author}"
248 width, max_line_height = font.getbbox(author_str)[2:]
249 if width <= AUTHOR_MAX_WIDTH:
250 author_lines = [author_str]
251 else:
252 author_lines, max_line_height = get_lines_and_max_height(
253 author_str, AUTHOR_MAX_WIDTH, font
254 )
255 y_text = draw_lines(
256 draw,
257 author_lines,
258 max(
259 y_text + 20, IMAGE_HEIGHT - (220 if len(author_lines) < 3 else 280)
260 ),
261 AUTHOR_MAX_WIDTH,
262 int(max_line_height),
263 font,
264 10,
265 1 if file_type == "4-color-gif" else 0,
266 )
268 if y_text > IMAGE_HEIGHT and font is FONT:
269 LOGGER.info("Using smaller font for quote %s", source)
270 return create_image(
271 quote,
272 author,
273 rating,
274 source,
275 file_type,
276 FONT_SMALLER,
277 wq_id=wq_id,
278 )
280 # draw rating
281 if rating:
282 _, y_off, width, height = FONT_SMALLER.getbbox(str(rating))
283 y_rating = IMAGE_HEIGHT - 25 - int(height)
284 draw_text(
285 draw,
286 str(rating),
287 25,
288 y_rating,
289 FONT_SMALLER, # always use same font for rating
290 1,
291 )
292 # draw rating image
293 icon = NICHT_WITZIG_IMAGE if rating < 0 else WITZIG_IMAGE
294 image.paste(
295 icon,
296 box=(
297 25 + 5 + int(width),
298 y_rating + int(y_off / 2),
299 ),
300 mask=icon,
301 )
303 # draw host name
304 if source:
305 width, height = HOST_NAME_FONT.getbbox(source)[2:]
306 draw_text(
307 draw,
308 source,
309 IMAGE_WIDTH - 5 - int(width),
310 IMAGE_HEIGHT - 5 - int(height),
311 HOST_NAME_FONT,
312 0,
313 )
315 if file_type == "qoi":
316 return qoi_rs.encode_pillow(image)
318 if to_excel and file_type == "xlsx":
319 with TemporaryDirectory() as tempdir_name:
320 filepath = os.path.join(tempdir_name, f"{wq_id or '0-0'}.xlsx")
321 to_excel(image, filepath, lower_image_size_by=10)
322 with open(filepath, "rb") as file:
323 return file.read()
325 kwargs: dict[str, Any] = {
326 "format": file_type,
327 "optimize": True,
328 "save_all": False,
329 }
331 if file_type == "4-color-gif":
332 colors: list[tuple[int, tuple[int, int, int]]]
333 colors = image.getcolors(2**16) # type: ignore[assignment]
334 colors.sort(reverse=True)
335 palette = bytearray()
336 for _, color in colors[:4]:
337 palette.extend(color)
338 kwargs.update(format="gif", palette=palette)
339 elif file_type == "jxl":
340 kwargs.update(lossless=True)
341 elif file_type == "pdf":
342 timestamp = time.gmtime(EPOCH)
343 kwargs.update(
344 title=wq_id or "0-0",
345 author=author,
346 subject=quote,
347 creationDate=timestamp,
348 modDate=timestamp,
349 )
350 elif file_type == "tga":
351 kwargs.update(compression="tga_rle")
352 elif file_type == "tiff":
353 kwargs.update(compression="zlib")
354 elif file_type == "webp":
355 kwargs.update(lossless=True)
357 image.save(buffer := io.BytesIO(), **kwargs)
358 return buffer.getvalue()
361class QuoteAsImage(QuoteReadyCheckHandler):
362 """Quote as image request handler."""
364 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
365 RATELIMIT_GET_LIMIT: ClassVar[int] = 15
367 async def get(
368 self,
369 quote_id: str,
370 author_id: str,
371 file_extension: None | str = None,
372 *,
373 head: bool = False,
374 ) -> None:
375 """Handle GET requests to this page and render the quote as image."""
376 file_type: None | str
377 if file_extension is None:
378 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT)
379 assert self.content_type
380 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type]
381 file_extension = file_type
382 elif not (file_type := FILE_EXTENSIONS.get(file_extension.lower())):
383 reason = (
384 f"Unsupported file extension: {file_extension.lower()} (supported:"
385 f" {', '.join(sorted(set(FILE_EXTENSIONS.values())))})"
386 )
387 self.set_status(404, reason=reason)
388 self.write_error(404, reason=reason)
389 return
391 content_type = CONTENT_TYPES[file_type]
393 self.handle_accept_header((content_type,))
395 int_quote_id = int(quote_id)
396 wrong_quote = (
397 await get_wrong_quote(int_quote_id, int(author_id))
398 if author_id
399 else (
400 get_wrong_quotes(lambda wq: wq.id == int_quote_id) or (None,)
401 )[0]
402 )
403 if wrong_quote is None:
404 raise HTTPError(404, reason="Falsches Zitat nicht gefunden")
406 if file_type == "txt":
407 await self.finish(str(wrong_quote))
408 return
410 self.set_header(
411 "Content-Disposition",
412 (
413 f"inline; filename={self.request.host.replace('.', '-')}_z_"
414 f"{wrong_quote.get_id_as_str()}.{file_extension.lower()}"
415 ),
416 )
418 if head:
419 return
421 if file_type == "gif" and self.get_bool_argument("small", False):
422 file_type = "4-color-gif"
424 return await self.finish(
425 await asyncio.to_thread(
426 create_image,
427 wrong_quote.quote.quote,
428 wrong_quote.author.name,
429 rating=(
430 None
431 if self.get_bool_argument("no_rating", False)
432 else wrong_quote.rating
433 ),
434 source=(
435 None
436 if self.get_bool_argument("no_source", False)
437 else f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}"
438 ),
439 file_type=file_type,
440 include_kangaroo=not self.get_bool_argument(
441 "no_kangaroo", False
442 ),
443 wq_id=wrong_quote.get_id_as_str(),
444 )
445 )