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

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_pillow(image, mode="RGB") 

314 

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

321 

322 kwargs: dict[str, Any] = { 

323 "format": file_type, 

324 "optimize": True, 

325 "save_all": False, 

326 } 

327 

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) 

353 

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

355 return buffer.getvalue() 

356 

357 

358class QuoteAsImage(QuoteReadyCheckHandler): 

359 """Quote as image request handler.""" 

360 

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

362 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

363 

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 

387 

388 content_type = CONTENT_TYPES[file_type] 

389 

390 self.handle_accept_header((content_type,)) 

391 

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

402 

403 if file_type == "txt": 

404 await self.finish(str(wrong_quote)) 

405 return 

406 

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 ) 

414 

415 if head: 

416 return 

417 

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

419 file_type = "4-color-gif" 

420 

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 )