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