Coverage for an_website/quotes/image.py: 85.714%

161 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-02 20:57 +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 

60 

61_FONT_BYTES = (DIR / "files/oswald.regular.ttf").read_bytes() 

62 

63FONT: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=50) 

64FONT_SMALLER: Final = ImageFont.truetype(font=io.BytesIO(_FONT_BYTES), size=44) 

65HOST_NAME_FONT: Final = ImageFont.truetype( 

66 font=io.BytesIO(_FONT_BYTES), size=23 

67) 

68 

69del _FONT_BYTES 

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 "qoi": "qoi", 

90 **({"xlsx": "xlsx"} if to_excel else {}), 

91} 

92 

93CONTENT_TYPES: Final[Mapping[str, str]] = ChainMap( 

94 { 

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

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

97 "qoi": "image/qoi", 

98 }, 

99 static_file_handling.CONTENT_TYPES, # type: ignore[arg-type] 

100) 

101 

102CONTENT_TYPE_FILE_TYPE_MAPPING: Final[Mapping[str, str]] = { 

103 CONTENT_TYPES[ext]: ext for ext in FILE_EXTENSIONS.values() 

104} 

105IMAGE_CONTENT_TYPES: Final[Set[str]] = frozenset(CONTENT_TYPE_FILE_TYPE_MAPPING) 

106IMAGE_CONTENT_TYPES_WITHOUT_TXT: Final[tuple[str, ...]] = tuple( 

107 sorted(IMAGE_CONTENT_TYPES - {"text/plain"}, key="image/gif".__ne__) 

108) 

109 

110 

111def load_png(filename: str) -> Image.Image: 

112 """Load a PNG image into memory.""" 

113 with (DIR / "files" / f"{filename}.png").open("rb") as file: # noqa: SIM117 

114 with Image.open(file, formats=("PNG",)) as image: 

115 return image.copy() # type: ignore[no-any-return] 

116 

117 

118BACKGROUND_IMAGE: Final = load_png("bg") 

119IMAGE_WIDTH, IMAGE_HEIGHT = BACKGROUND_IMAGE.size 

120WITZIG_IMAGE: Final = load_png("StempelWitzig") 

121NICHT_WITZIG_IMAGE: Final = load_png("StempelNichtWitzig") 

122 

123 

124def get_lines_and_max_height( 

125 text: str, 

126 max_width: int, 

127 font: ImageFont.FreeTypeFont, 

128) -> tuple[list[str], int]: 

129 """Get the lines of the text and the max line height.""" 

130 column_count = 46 

131 lines: list[str] = [] 

132 

133 max_line_length: float = max_width + 1 

134 while max_line_length > max_width: # pylint: disable=while-used 

135 lines = textwrap.wrap(text, width=column_count) 

136 max_line_length = max(font.getlength(line) for line in lines) 

137 column_count -= 1 

138 

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

140 

141 

142def draw_text( # pylint: disable=too-many-arguments 

143 image: ImageDraw.ImageDraw, 

144 text: str, 

145 x: int, 

146 y: int, 

147 font: ImageFont.FreeTypeFont, 

148 stroke_width: int = 0, 

149 *, 

150 display_bounds: bool = sys.flags.dev_mode, 

151) -> None: 

152 """Draw a text on an image.""" 

153 image.text( 

154 (x, y), 

155 text, 

156 font=font, 

157 fill=TEXT_COLOR, 

158 align="right", 

159 stroke_width=stroke_width, 

160 spacing=54, 

161 ) 

162 if display_bounds: 

163 x_off, y_off, right, bottom = font.getbbox( 

164 text, stroke_width=stroke_width 

165 ) 

166 image.rectangle((x, y, x + right, y + bottom), outline=DEBUG_COLOR) 

167 image.rectangle( 

168 (x + x_off, y + y_off, x + right, y + bottom), outline=DEBUG_COLOR2 

169 ) 

170 

171 

172def draw_lines( # pylint: disable=too-many-arguments 

173 image: ImageDraw.ImageDraw, 

174 lines: Iterable[str], 

175 y_start: int, 

176 max_w: int, 

177 max_h: int, 

178 font: ImageFont.FreeTypeFont, 

179 padding_left: int = 0, 

180 stroke_width: int = 0, 

181) -> int: 

182 """Draw the lines on the image and return the last y position.""" 

183 for line in lines: 

184 width = font.getlength(line) 

185 draw_text( 

186 image, 

187 line, 

188 padding_left + math.ceil((max_w - width) / 2), 

189 y_start, 

190 font, 

191 stroke_width, 

192 ) 

193 y_start += max_h 

194 return y_start 

195 

196 

197def create_image( # noqa: C901 # pylint: disable=too-complex 

198 # pylint: disable=too-many-arguments, too-many-branches 

199 # pylint: disable=too-many-locals, too-many-statements 

200 quote: str, 

201 author: str, 

202 rating: None | int, 

203 source: None | str, 

204 file_type: str = "png", 

205 font: ImageFont.FreeTypeFont = FONT, 

206 *, 

207 include_kangaroo: bool = True, 

208 wq_id: None | str = None, 

209) -> bytes: 

210 """Create an image with the given quote and author.""" 

211 image = ( 

212 BACKGROUND_IMAGE.copy() 

213 if include_kangaroo 

214 else create_empty_image("RGB", BACKGROUND_IMAGE.size, 0) 

215 ) 

216 draw = ImageDraw.Draw(image, mode="RGB") 

217 

218 # draw quote 

219 quote_str = f"»{quote}«" 

220 width, max_line_height = font.getbbox(quote_str)[2:] 

221 if width <= AUTHOR_MAX_WIDTH: 

222 quote_lines = [quote_str] 

223 else: 

224 quote_lines, max_line_height = get_lines_and_max_height( 

225 quote_str, QUOTE_MAX_WIDTH, font 

226 ) 

227 if len(quote_lines) < 3: 

228 y_start = 175 

229 elif len(quote_lines) < 4: 

230 y_start = 125 

231 elif len(quote_lines) < 6: 

232 y_start = 75 

233 else: 

234 y_start = 50 

235 y_text = draw_lines( 

236 draw, 

237 quote_lines, 

238 y_start, 

239 QUOTE_MAX_WIDTH, 

240 int(max_line_height), 

241 font, 

242 0, 

243 1 if file_type == "4-color-gif" else 0, 

244 ) 

245 

246 # draw author 

247 author_str = f"- {author}" 

248 width, max_line_height = font.getbbox(author_str)[2:] 

249 if width <= AUTHOR_MAX_WIDTH: 

250 author_lines = [author_str] 

251 else: 

252 author_lines, max_line_height = get_lines_and_max_height( 

253 author_str, AUTHOR_MAX_WIDTH, font 

254 ) 

255 y_text = draw_lines( 

256 draw, 

257 author_lines, 

258 max( 

259 y_text + 20, IMAGE_HEIGHT - (220 if len(author_lines) < 3 else 280) 

260 ), 

261 AUTHOR_MAX_WIDTH, 

262 int(max_line_height), 

263 font, 

264 10, 

265 1 if file_type == "4-color-gif" else 0, 

266 ) 

267 

268 if y_text > IMAGE_HEIGHT and font is FONT: 

269 LOGGER.info("Using smaller font for quote %s", source) 

270 return create_image( 

271 quote, 

272 author, 

273 rating, 

274 source, 

275 file_type, 

276 FONT_SMALLER, 

277 wq_id=wq_id, 

278 ) 

279 

280 # draw rating 

281 if rating: 

282 _, y_off, width, height = FONT_SMALLER.getbbox(str(rating)) 

283 y_rating = IMAGE_HEIGHT - 25 - int(height) 

284 draw_text( 

285 draw, 

286 str(rating), 

287 25, 

288 y_rating, 

289 FONT_SMALLER, # always use same font for rating 

290 1, 

291 ) 

292 # draw rating image 

293 icon = NICHT_WITZIG_IMAGE if rating < 0 else WITZIG_IMAGE 

294 image.paste( 

295 icon, 

296 box=( 

297 25 + 5 + int(width), 

298 y_rating + int(y_off / 2), 

299 ), 

300 mask=icon, 

301 ) 

302 

303 # draw host name 

304 if source: 

305 width, height = HOST_NAME_FONT.getbbox(source)[2:] 

306 draw_text( 

307 draw, 

308 source, 

309 IMAGE_WIDTH - 5 - int(width), 

310 IMAGE_HEIGHT - 5 - int(height), 

311 HOST_NAME_FONT, 

312 0, 

313 ) 

314 

315 if file_type == "qoi": 

316 return qoi_rs.encode_pillow(image) 

317 

318 if to_excel and file_type == "xlsx": 

319 with TemporaryDirectory() as tempdir_name: 

320 filepath = os.path.join(tempdir_name, f"{wq_id or '0-0'}.xlsx") 

321 to_excel(image, filepath, lower_image_size_by=10) 

322 with open(filepath, "rb") as file: 

323 return file.read() 

324 

325 kwargs: dict[str, Any] = { 

326 "format": file_type, 

327 "optimize": True, 

328 "save_all": False, 

329 } 

330 

331 if file_type == "4-color-gif": 

332 colors: list[tuple[int, tuple[int, int, int]]] 

333 colors = image.getcolors(2**16) # type: ignore[assignment] 

334 colors.sort(reverse=True) 

335 palette = bytearray() 

336 for _, color in colors[:4]: 

337 palette.extend(color) 

338 kwargs.update(format="gif", palette=palette) 

339 elif file_type == "jxl": 

340 kwargs.update(lossless=True) 

341 elif file_type == "pdf": 

342 timestamp = time.gmtime(EPOCH) 

343 kwargs.update( 

344 title=wq_id or "0-0", 

345 author=author, 

346 subject=quote, 

347 creationDate=timestamp, 

348 modDate=timestamp, 

349 ) 

350 elif file_type == "tga": 

351 kwargs.update(compression="tga_rle") 

352 elif file_type == "tiff": 

353 kwargs.update(compression="zlib") 

354 elif file_type == "webp": 

355 kwargs.update(lossless=True) 

356 

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

358 return buffer.getvalue() 

359 

360 

361class QuoteAsImage(QuoteReadyCheckHandler): 

362 """Quote as image request handler.""" 

363 

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

365 RATELIMIT_GET_LIMIT: ClassVar[int] = 15 

366 

367 async def get( 

368 self, 

369 quote_id: str, 

370 author_id: str, 

371 file_extension: None | str = None, 

372 *, 

373 head: bool = False, 

374 ) -> None: 

375 """Handle GET requests to this page and render the quote as image.""" 

376 file_type: None | str 

377 if file_extension is None: 

378 self.handle_accept_header(IMAGE_CONTENT_TYPES_WITHOUT_TXT) 

379 assert self.content_type 

380 file_type = CONTENT_TYPE_FILE_TYPE_MAPPING[self.content_type] 

381 file_extension = file_type 

382 elif not (file_type := FILE_EXTENSIONS.get(file_extension.lower())): 

383 reason = ( 

384 f"Unsupported file extension: {file_extension.lower()} (supported:" 

385 f" {', '.join(sorted(set(FILE_EXTENSIONS.values())))})" 

386 ) 

387 self.set_status(404, reason=reason) 

388 self.write_error(404, reason=reason) 

389 return 

390 

391 content_type = CONTENT_TYPES[file_type] 

392 

393 self.handle_accept_header((content_type,)) 

394 

395 int_quote_id = int(quote_id) 

396 wrong_quote = ( 

397 await get_wrong_quote(int_quote_id, int(author_id)) 

398 if author_id 

399 else ( 

400 get_wrong_quotes(lambda wq: wq.id == int_quote_id) or (None,) 

401 )[0] 

402 ) 

403 if wrong_quote is None: 

404 raise HTTPError(404, reason="Falsches Zitat nicht gefunden") 

405 

406 if file_type == "txt": 

407 await self.finish(str(wrong_quote)) 

408 return 

409 

410 self.set_header( 

411 "Content-Disposition", 

412 ( 

413 f"inline; filename={self.request.host.replace('.', '-')}_z_" 

414 f"{wrong_quote.get_id_as_str()}.{file_extension.lower()}" 

415 ), 

416 ) 

417 

418 if head: 

419 return 

420 

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

422 file_type = "4-color-gif" 

423 

424 return await self.finish( 

425 await asyncio.to_thread( 

426 create_image, 

427 wrong_quote.quote.quote, 

428 wrong_quote.author.name, 

429 rating=( 

430 None 

431 if self.get_bool_argument("no_rating", False) 

432 else wrong_quote.rating 

433 ), 

434 source=( 

435 None 

436 if self.get_bool_argument("no_source", False) 

437 else f"{self.request.host_name}/z/{wrong_quote.get_id_as_str(True)}" 

438 ), 

439 file_type=file_type, 

440 include_kangaroo=not self.get_bool_argument( 

441 "no_kangaroo", False 

442 ), 

443 wq_id=wrong_quote.get_id_as_str(), 

444 ) 

445 )