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