| from datetime import datetime |
| from enum import Enum |
| from io import BytesIO |
| from os import fdopen |
| from pathlib import Path |
| from typing import Literal, Optional, cast |
|
|
| from PIL import Image |
| from pydantic import AnyHttpUrl, BaseModel, Field, validate_arguments |
| from pydantic.color import Color |
| from qrcode import constants |
| from qrcode.image.pil import PilImage |
| from qrcode.main import QRCode |
|
|
| from hibiapi.utils.config import APIConfig |
| from hibiapi.utils.decorators import ToAsync, enum_auto_doc |
| from hibiapi.utils.exceptions import ClientSideException |
| from hibiapi.utils.net import BaseNetClient |
| from hibiapi.utils.routing import BaseHostUrl |
| from hibiapi.utils.temp import TempFile |
|
|
| Config = APIConfig("qrcode") |
|
|
|
|
| class HostUrl(BaseHostUrl): |
| allowed_hosts = Config["qrcode"]["icon-site"].get(list[str]) |
|
|
|
|
| @enum_auto_doc |
| class QRCodeLevel(str, Enum): |
| """二维码容错率""" |
|
|
| LOW = "L" |
| """最低容错率""" |
| MEDIUM = "M" |
| """中等容错率""" |
| QUARTILE = "Q" |
| """高容错率""" |
| HIGH = "H" |
| """最高容错率""" |
|
|
|
|
| @enum_auto_doc |
| class ReturnEncode(str, Enum): |
| """二维码返回的编码方式""" |
|
|
| raw = "raw" |
| """直接重定向到二维码图片""" |
| json = "json" |
| """返回JSON格式的二维码信息""" |
| js = "js" |
| jsc = "jsc" |
|
|
|
|
| COLOR_WHITE = Color("FFFFFF") |
| COLOR_BLACK = Color("000000") |
|
|
|
|
| class QRInfo(BaseModel): |
| url: Optional[AnyHttpUrl] = None |
| path: Path |
| time: datetime = Field(default_factory=datetime.now) |
| data: str |
| logo: Optional[HostUrl] = None |
| level: QRCodeLevel = QRCodeLevel.MEDIUM |
| size: int = 200 |
| code: Literal[0] = 0 |
| status: Literal["success"] = "success" |
|
|
| @classmethod |
| @validate_arguments |
| async def new( |
| cls, |
| text: str, |
| *, |
| size: int = Field( |
| 200, |
| gt=Config["qrcode"]["min-size"].as_number(), |
| lt=Config["qrcode"]["max-size"].as_number(), |
| ), |
| logo: Optional[HostUrl] = None, |
| level: QRCodeLevel = QRCodeLevel.MEDIUM, |
| bgcolor: Color = COLOR_WHITE, |
| fgcolor: Color = COLOR_BLACK, |
| ): |
| icon_stream = None |
| if logo is not None: |
| async with BaseNetClient() as client: |
| response = await client.get( |
| logo, headers={"user-agent": "HibiAPI@GitHub"}, timeout=6 |
| ) |
| response.raise_for_status() |
| icon_stream = BytesIO(response.content) |
| return cls( |
| data=text, |
| logo=logo, |
| level=level, |
| size=size, |
| path=await cls._generate( |
| text, |
| size=size, |
| level=level, |
| icon_stream=icon_stream, |
| bgcolor=bgcolor.as_hex(), |
| fgcolor=fgcolor.as_hex(), |
| ), |
| ) |
|
|
| @classmethod |
| @ToAsync |
| def _generate( |
| cls, |
| text: str, |
| *, |
| size: int = 200, |
| level: QRCodeLevel = QRCodeLevel.MEDIUM, |
| icon_stream: Optional[BytesIO] = None, |
| bgcolor: str = "#FFFFFF", |
| fgcolor: str = "#000000", |
| ) -> Path: |
| qr = QRCode( |
| error_correction={ |
| QRCodeLevel.LOW: constants.ERROR_CORRECT_L, |
| QRCodeLevel.MEDIUM: constants.ERROR_CORRECT_M, |
| QRCodeLevel.QUARTILE: constants.ERROR_CORRECT_Q, |
| QRCodeLevel.HIGH: constants.ERROR_CORRECT_H, |
| }[level], |
| border=2, |
| box_size=8, |
| ) |
| qr.add_data(text) |
| image = cast( |
| Image.Image, |
| qr.make_image( |
| PilImage, |
| back_color=bgcolor, |
| fill_color=fgcolor, |
| ).get_image(), |
| ) |
| image = image.resize((size, size)) |
| if icon_stream is not None: |
| try: |
| icon = Image.open(icon_stream) |
| except ValueError as e: |
| raise ClientSideException("Invalid image format.") from e |
| icon_width, icon_height = icon.size |
| image.paste( |
| icon, |
| box=( |
| int(size / 2 - icon_width / 2), |
| int(size / 2 - icon_height / 2), |
| int(size / 2 + icon_width / 2), |
| int(size / 2 + icon_height / 2), |
| ), |
| mask=icon if icon.mode == "RGBA" else None, |
| ) |
| descriptor, path = TempFile.create(".png") |
| with fdopen(descriptor, "wb") as f: |
| image.save(f, format="PNG") |
| return path |
|
|