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

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/>. 

13 

14"""A module that generates an image from a wrong quote.""" 

15 

16from __future__ import annotations 

17 

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 

29 

30from PIL import Image, ImageDraw, ImageFont 

31from PIL.Image import new as create_empty_image 

32from tornado.web import HTTPError 

33 

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) 

42 

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 

49 

50LOGGER: Final = logging.getLogger(__name__) 

51 

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 

57 

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 

65 

66CONTENT_TYPES: Final[Mapping[str, str]] = { 

67 "spider": "image/x-spider", 

68 "tga": "image/x-tga", 

69} | dict(static_file_handling.CONTENT_TYPES) 

70 

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) 

98 

99 

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() 

105 

106 

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") 

111 

112 

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] = [] 

121 

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 

127 

128 return lines, max(font.getbbox(line)[3] for line in lines) 

129 

130 

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 ) 

159 

160 

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 

184 

185 

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") 

206 

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 ) 

234 

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 ) 

256 

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 ) 

268 

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 ) 

291 

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 ) 

303 

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() 

310 

311 kwargs: dict[str, Any] = { 

312 "format": file_type, 

313 "optimize": True, 

314 "save_all": False, 

315 } 

316 

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) 

342 

343 image.save(buffer := io.BytesIO(), **kwargs) 

344 return buffer.getvalue() 

345 

346 

347class QuoteAsImage(QuoteReadyCheckHandler): 

348 """Quote as image request handler.""" 

349 

350 POSSIBLE_CONTENT_TYPES: ClassVar[tuple[str, ...]] = () 

351 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

352 

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 

376 

377 content_type = CONTENT_TYPES[file_type] 

378 

379 self.handle_accept_header((content_type,)) 

380 

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") 

391 

392 if file_type == "txt": 

393 await self.finish(str(wrong_quote)) 

394 return 

395 

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 ) 

403 

404 if head: 

405 return 

406 

407 if file_type == "gif" and self.get_bool_argument("small", False): 

408 file_type = "4-color-gif" 

409 

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 )