Coverage for an_website/quotes/image.py: 85.890%
163 statements
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-01 08:32 +0000
« prev ^ index » next coverage.py v7.8.2, created at 2025-06-01 08:32 +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
60with (DIR / "files/oswald.regular.ttf").open("rb") as data:
61 FONT: Final = ImageFont.truetype(font=data, size=50)
62with (DIR / "files/oswald.regular.ttf").open("rb") as data:
63 FONT_SMALLER: Final = ImageFont.truetype(font=data, size=44)
64with (DIR / "files/oswald.regular.ttf").open("rb") as data:
65 HOST_NAME_FONT: Final = ImageFont.truetype(font=data, size=23)
66del data
68FILE_EXTENSIONS: Final[Mapping[str, str]] = {
69 "bmp": "bmp",
70 "gif": "gif",
71 "jfif": "jpeg",
72 "jpe": "jpeg",
73 "jpeg": "jpeg",
74 "jpg": "jpeg",
75 "jxl": "jxl",
76 "pdf": "pdf",
77 "png": "png",
78 # "ppm": "ppm",
79 # "sgi": "sgi",
80 "spi": "spider",
81 "spider": "spider",
82 "tga": "tga",
83 "tiff": "tiff",
84 "txt": "txt",
85 "webp": "webp",
86 "qoi": "qoi",
87 **({"xlsx": "xlsx"} if to_excel else {}),
88}
90CONTENT_TYPES: Final[Mapping[str, str]] = ChainMap(
91 {
92 "spider": "image/x-spider",
93 "tga": "image/x-tga",
94 "qoi": "image/qoi",
95 },
96 static_file_handling.CONTENT_TYPES, # type: ignore[arg-type]
97)
99CONTENT_TYPE_FILE_TYPE_MAPPING: Final[Mapping[str, str]] = {
100 CONTENT_TYPES[ext]: ext for ext in FILE_EXTENSIONS.values()
101}
102IMAGE_CONTENT_TYPES: Final[Set[str]] = frozenset(CONTENT_TYPE_FILE_TYPE_MAPPING)
103IMAGE_CONTENT_TYPES_WITHOUT_TXT: Final[tuple[str, ...]] = tuple(
104 sorted(IMAGE_CONTENT_TYPES - {"text/plain"}, key="image/gif".__ne__)
105)
108def load_png(filename: str) -> Image.Image:
109 """Load a PNG image into memory."""
110 with (DIR / "files" / f"{filename}.png").open("rb") as file: # noqa: SIM117
111 with Image.open(file, formats=("PNG",)) as image:
112 return image.copy()
115BACKGROUND_IMAGE: Final = load_png("bg")
116IMAGE_WIDTH, IMAGE_HEIGHT = BACKGROUND_IMAGE.size
117WITZIG_IMAGE: Final = load_png("StempelWitzig")
118NICHT_WITZIG_IMAGE: Final = load_png("StempelNichtWitzig")
121def get_lines_and_max_height(
122 text: str,
123 max_width: int,
124 font: ImageFont.FreeTypeFont,
125) -> tuple[list[str], int]:
126 """Get the lines of the text and the max line height."""
127 column_count = 46
128 lines: list[str] = []
130 max_line_length: float = max_width + 1
131 while max_line_length > max_width: # pylint: disable=while-used
132 lines = textwrap.wrap(text, width=column_count)
133 max_line_length = max(font.getlength(line) for line in lines)
134 column_count -= 1
136 return lines, max(font.getbbox(line)[3] for line in lines)
139def draw_text( # pylint: disable=too-many-arguments
140 image: ImageDraw.ImageDraw,
141 text: str,
142 x: int,
143 y: int,
144 font: ImageFont.FreeTypeFont,
145 stroke_width: int = 0,
146 *,
147 display_bounds: bool = sys.flags.dev_mode,
148) -> None:
149 """Draw a text on an image."""
150 image.text(
151 (x, y),
152 text,
153 font=font,
154 fill=TEXT_COLOR,
155 align="right",
156 stroke_width=stroke_width,
157 spacing=54,
158 )
159 if display_bounds:
160 x_off, y_off, right, bottom = font.getbbox(
161 text, stroke_width=stroke_width
162 )
163 image.rectangle((x, y, x + right, y + bottom), outline=DEBUG_COLOR)
164 image.rectangle(
165 (x + x_off, y + y_off, x + right, y + bottom), outline=DEBUG_COLOR2
166 )
169def draw_lines( # pylint: disable=too-many-arguments
170 image: ImageDraw.ImageDraw,
171 lines: Iterable[str],
172 y_start: int,
173 max_w: int,
174 max_h: int,
175 font: ImageFont.FreeTypeFont,
176 padding_left: int = 0,
177 stroke_width: int = 0,
178) -> int:
179 """Draw the lines on the image and return the last y position."""
180 for line in lines:
181 width = font.getlength(line)
182 draw_text(
183 image,
184 line,
185 padding_left + math.ceil((max_w - width) / 2),
186 y_start,
187 font,
188 stroke_width,
189 )
190 y_start += max_h
191 return y_start
194def create_image( # noqa: C901 # pylint: disable=too-complex
195 # pylint: disable=too-many-arguments, too-many-branches
196 # pylint: disable=too-many-locals, too-many-statements
197 quote: str,
198 author: str,
199 rating: None | int,
200 source: None | str,
201 file_type: str = "png",
202 font: ImageFont.FreeTypeFont = FONT,
203 *,
204 include_kangaroo: bool = True,
205 wq_id: None | str = None,
206) -> bytes:
207 """Create an image with the given quote and author."""
208 image = (
209 BACKGROUND_IMAGE.copy()
210 if include_kangaroo
211 else create_empty_image("RGB", BACKGROUND_IMAGE.size, 0)
212 )
213 draw = ImageDraw.Draw(image, mode="RGB")
215 # draw quote
216 quote_str = f"»{quote}«"
217 width, max_line_height = font.getbbox(quote_str)[2:]
218 if width <= AUTHOR_MAX_WIDTH:
219 quote_lines = [quote_str]
220 else:
221 quote_lines, max_line_height = get_lines_and_max_height(
222 quote_str, QUOTE_MAX_WIDTH, font
223 )
224 if len(quote_lines) < 3:
225 y_start = 175
226 elif len(quote_lines) < 4:
227 y_start = 125
228 elif len(quote_lines) < 6:
229 y_start = 75
230 else:
231 y_start = 50
232 y_text = draw_lines(
233 draw,
234 quote_lines,
235 y_start,
236 QUOTE_MAX_WIDTH,
237 max_line_height,
238 font,
239 0,
240 1 if file_type == "4-color-gif" else 0,
241 )
243 # draw author
244 author_str = f"- {author}"
245 width, max_line_height = font.getbbox(author_str)[2:]
246 if width <= AUTHOR_MAX_WIDTH:
247 author_lines = [author_str]
248 else:
249 author_lines, max_line_height = get_lines_and_max_height(
250 author_str, AUTHOR_MAX_WIDTH, font
251 )
252 y_text = draw_lines(
253 draw,
254 author_lines,
255 max(
256 y_text + 20, IMAGE_HEIGHT - (220 if len(author_lines) < 3 else 280)
257 ),
258 AUTHOR_MAX_WIDTH,
259 max_line_height,
260 font,
261 10,
262 1 if file_type == "4-color-gif" else 0,
263 )
265 if y_text > IMAGE_HEIGHT and font is FONT:
266 LOGGER.info("Using smaller font for quote %s", source)
267 return create_image(
268 quote,
269 author,
270 rating,
271 source,
272 file_type,
273 FONT_SMALLER,
274 wq_id=wq_id,
275 )
277 # draw rating
278 if rating:
279 _, y_off, width, height = FONT_SMALLER.getbbox(str(rating))
280 y_rating = IMAGE_HEIGHT - 25 - height
281 draw_text(
282 draw,
283 str(rating),
284 25,
285 y_rating,
286 FONT_SMALLER, # always use same font for rating
287 1,
288 )
289 # draw rating image
290 icon = NICHT_WITZIG_IMAGE if rating < 0 else WITZIG_IMAGE
291 image.paste(
292 icon,
293 box=(
294 25 + 5 + width,
295 y_rating + y_off // 2,
296 ),
297 mask=icon,
298 )
300 # draw host name
301 if source:
302 width, height = HOST_NAME_FONT.getbbox(source)[2:]
303 draw_text(
304 draw,
305 source,
306 IMAGE_WIDTH - 5 - width,
307 IMAGE_HEIGHT - 5 - height,
308 HOST_NAME_FONT,
309 0,
310 )
312 if file_type == "qoi":
313 return qoi_rs.encode_pillow(image, mode="RGB")
315 if to_excel and file_type == "xlsx":
316 with TemporaryDirectory() as tempdir_name:
317 filepath = os.path.join(tempdir_name, f"{wq_id or '0-0'}.xlsx")
318 to_excel(image, filepath, lower_image_size_by=10)
319 with open(filepath, "rb") as file:
320 return file.read()
322 kwargs: dict[str, Any] = {
323 "format": file_type,
324 "optimize": True,
325 "save_all": False,
326 }
328 if file_type == "4-color-gif":
329 colors: list[tuple[int, tuple[int, int, int]]]
330 colors = image.getcolors(2**16) # type: ignore[assignment]
331 colors.sort(reverse=True)
332 palette = bytearray()
333 for _, color in colors[:4]:
334 palette.extend(color)
335 kwargs.update(format="gif", palette=palette)
336 elif file_type == "jxl":
337 kwargs.update(lossless=True)
338 elif file_type == "pdf":
339 timestamp = time.gmtime(EPOCH)
340 kwargs.update(
341 title=wq_id or "0-0",
342 author=author,
343 subject=quote,
344 creationDate=timestamp,
345 modDate=timestamp,
346 )
347 elif file_type == "tga":
348 kwargs.update(compression="tga_rle")
349 elif file_type == "tiff":
350 kwargs.update(compression="zlib")
351 elif file_type == "webp":
352 kwargs.update(lossless=True)
354 image.save(buffer := io.BytesIO(), **kwargs)
355 return buffer.getvalue()
358class QuoteAsImage(QuoteReadyCheckHandler):
359 """Quote as image request handler."""
361 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
362 RATELIMIT_GET_LIMIT: ClassVar[int] = 15
364 async def get(
365 self,
366 quote_id: str,
367 author_id: str,
368 file_extension: None | str = None,
369 *,
370 head: bool = False,
371 ) -> None:
372 """Handle GET requests to this page and render the quote as image."""
373 file_type: None | str
374 if file_extension is None:
375 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT)
376 assert self.content_type
377 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type]
378 file_extension = file_type
379 elif not (file_type := FILE_EXTENSIONS.get(file_extension.lower())):
380 reason = (
381 f"Unsupported file extension: {file_extension.lower()} (supported:"
382 f" {', '.join(sorted(set(FILE_EXTENSIONS.values())))})"
383 )
384 self.set_status(404, reason=reason)
385 self.write_error(404, reason=reason)
386 return
388 content_type = CONTENT_TYPES[file_type]
390 self.handle_accept_header((content_type,))
392 int_quote_id = int(quote_id)
393 wrong_quote = (
394 await get_wrong_quote(int_quote_id, int(author_id))
395 if author_id
396 else (
397 get_wrong_quotes(lambda wq: wq.id == int_quote_id) or (None,)
398 )[0]
399 )
400 if wrong_quote is None:
401 raise HTTPError(404, reason="Falsches Zitat nicht gefunden")
403 if file_type == "txt":
404 await self.finish(str(wrong_quote))
405 return
407 self.set_header(
408 "Content-Disposition",
409 (
410 f"inline; filename={self.request.host.replace('.', '-')}_z_"
411 f"{wrong_quote.get_id_as_str()}.{file_extension.lower()}"
412 ),
413 )
415 if head:
416 return
418 if file_type == "gif" and self.get_bool_argument("small", False):
419 file_type = "4-color-gif"
421 return await self.finish(
422 await asyncio.to_thread(
423 create_image,
424 wrong_quote.quote.quote,
425 wrong_quote.author.name,
426 rating=(
427 None
428 if self.get_bool_argument("no_rating", False)
429 else wrong_quote.rating
430 ),
431 source=(
432 None
433 if self.get_bool_argument("no_source", False)
434 else f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}"
435 ),
436 file_type=file_type,
437 include_kangaroo=not self.get_bool_argument(
438 "no_kangaroo", False
439 ),
440 wq_id=wrong_quote.get_id_as_str(),
441 )
442 )