Coverage for an_website/quotes/image.py: 85.890%
163 statements
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 13:44 +0000
« prev ^ index » next coverage.py v7.8.0, created at 2025-05-07 13:44 +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(
314 image.getdata(), width=image.width, height=image.height
315 )
317 if to_excel and file_type == "xlsx":
318 with TemporaryDirectory() as tempdir_name:
319 filepath = os.path.join(tempdir_name, f"{wq_id or '0-0'}.xlsx")
320 to_excel(image, filepath, lower_image_size_by=10)
321 with open(filepath, "rb") as file:
322 return file.read()
324 kwargs: dict[str, Any] = {
325 "format": file_type,
326 "optimize": True,
327 "save_all": False,
328 }
330 if file_type == "4-color-gif":
331 colors: list[tuple[int, tuple[int, int, int]]]
332 colors = image.getcolors(2**16) # type: ignore[assignment]
333 colors.sort(reverse=True)
334 palette = bytearray()
335 for _, color in colors[:4]:
336 palette.extend(color)
337 kwargs.update(format="gif", palette=palette)
338 elif file_type == "jxl":
339 kwargs.update(lossless=True)
340 elif file_type == "pdf":
341 timestamp = time.gmtime(EPOCH)
342 kwargs.update(
343 title=wq_id or "0-0",
344 author=author,
345 subject=quote,
346 creationDate=timestamp,
347 modDate=timestamp,
348 )
349 elif file_type == "tga":
350 kwargs.update(compression="tga_rle")
351 elif file_type == "tiff":
352 kwargs.update(compression="zlib")
353 elif file_type == "webp":
354 kwargs.update(lossless=True)
356 image.save(buffer := io.BytesIO(), **kwargs)
357 return buffer.getvalue()
360class QuoteAsImage(QuoteReadyCheckHandler):
361 """Quote as image request handler."""
363 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = ()
364 RATELIMIT_GET_LIMIT: ClassVar[int] = 15
366 async def get(
367 self,
368 quote_id: str,
369 author_id: str,
370 file_extension: None | str = None,
371 *,
372 head: bool = False,
373 ) -> None:
374 """Handle GET requests to this page and render the quote as image."""
375 file_type: None | str
376 if file_extension is None:
377 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT)
378 assert self.content_type
379 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type]
380 file_extension = file_type
381 elif not (file_type := FILE_EXTENSIONS.get(file_extension.lower())):
382 reason = (
383 f"Unsupported file extension: {file_extension.lower()} (supported:"
384 f" {', '.join(sorted(set(FILE_EXTENSIONS.values())))})"
385 )
386 self.set_status(404, reason=reason)
387 self.write_error(404, reason=reason)
388 return
390 content_type = CONTENT_TYPES[file_type]
392 self.handle_accept_header((content_type,))
394 int_quote_id = int(quote_id)
395 wrong_quote = (
396 await get_wrong_quote(int_quote_id, int(author_id))
397 if author_id
398 else (
399 get_wrong_quotes(lambda wq: wq.id == int_quote_id) or (None,)
400 )[0]
401 )
402 if wrong_quote is None:
403 raise HTTPError(404, reason="Falsches Zitat nicht gefunden")
405 if file_type == "txt":
406 await self.finish(str(wrong_quote))
407 return
409 self.set_header(
410 "Content-Disposition",
411 (
412 f"inline; filename={self.request.host.replace('.', '-')}_z_"
413 f"{wrong_quote.get_id_as_str()}.{file_extension.lower()}"
414 ),
415 )
417 if head:
418 return
420 if file_type == "gif" and self.get_bool_argument("small", False):
421 file_type = "4-color-gif"
423 return await self.finish(
424 await asyncio.to_thread(
425 create_image,
426 wrong_quote.quote.quote,
427 wrong_quote.author.name,
428 rating=(
429 None
430 if self.get_bool_argument("no_rating", False)
431 else wrong_quote.rating
432 ),
433 source=(
434 None
435 if self.get_bool_argument("no_source", False)
436 else f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}"
437 ),
438 file_type=file_type,
439 include_kangaroo=not self.get_bool_argument(
440 "no_kangaroo", False
441 ),
442 wq_id=wrong_quote.get_id_as_str(),
443 )
444 )