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

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 import ChainMap 

27from collections.abc import Iterable, Mapping, Set 

28from tempfile import TemporaryDirectory 

29from typing import Any, ClassVar, Final 

30 

31import qoi_rs 

32from PIL import Image, ImageDraw, ImageFont 

33from PIL.Image import new as create_empty_image 

34from tornado.web import HTTPError 

35 

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) 

44 

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 

51 

52LOGGER: Final = logging.getLogger(__name__) 

53 

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 

59 

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 

67 

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} 

89 

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) 

98 

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) 

106 

107 

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

113 

114 

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

119 

120 

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

129 

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 

135 

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

137 

138 

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 ) 

167 

168 

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 

192 

193 

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

214 

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 ) 

242 

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 ) 

264 

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 ) 

276 

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 ) 

299 

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 ) 

311 

312 if file_type == "qoi": 

313 return qoi_rs.encode( 

314 image.getdata(), width=image.width, height=image.height 

315 ) 

316 

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

323 

324 kwargs: dict[str, Any] = { 

325 "format": file_type, 

326 "optimize": True, 

327 "save_all": False, 

328 } 

329 

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) 

355 

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

357 return buffer.getvalue() 

358 

359 

360class QuoteAsImage(QuoteReadyCheckHandler): 

361 """Quote as image request handler.""" 

362 

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

364 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

365 

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 

389 

390 content_type = CONTENT_TYPES[file_type] 

391 

392 self.handle_accept_header((content_type,)) 

393 

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

404 

405 if file_type == "txt": 

406 await self.finish(str(wrong_quote)) 

407 return 

408 

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 ) 

416 

417 if head: 

418 return 

419 

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

421 file_type = "4-color-gif" 

422 

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 )