PayrollManagementSystem/backend/app/core/captcha_image.py

161 lines
5.8 KiB
Python
Raw Permalink Normal View History

"""图形验证码渲染。
**为什么自绘而不用 captcha 库的默认渲染**:
- 该库默认输出随机彩色字符 + 重噪点 + 弧线,与界面视觉体系严重冲突
- 它最后更新于 2025-03,内部还引用了 Pillow 的旧别名(`PIL.ImageDraw.ImageDraw`)
自绘后配色与界面统一,而识别难度由**逐字旋转 + 垂直抖动 + 干扰曲线 + 噪点**
共同保证,强度与原实现相当且可调(见 `_draw_characters` 的注释)。
仅沿用该库内置的 DroidSansMono 字体文件:自带字体可避开
在 Linux 容器里找不到系统字体的经典问题。
"""
import base64
import io
import math
import random
import secrets
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont
from app.core.constants import CAPTCHA_CHARSET, CAPTCHA_LENGTH
# 画布尺寸与前端展示槽位对齐(登录页为 116×40)
_WIDTH, _HEIGHT = 120, 40
_FONT_SIZE = 25
_PADDING = 8
# 与前端配色呼应:墨色字符、青灰干扰、素纸底
_INK = (15, 54, 70)
_INTERFERENCE = (126, 156, 170)
_SPECKLE = (168, 183, 192)
_PAPER = (247, 246, 242)
_SPECKLE_COUNT = 46
_INTERFERENCE_LINES = 2
# 备选字体路径:优先用 captcha 包内置字体(跨平台一致),其余为回退
_FONT_CANDIDATES = (
"C:/Windows/Fonts/consola.ttf",
"/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf",
)
def _font_path() -> Path | None:
"""定位可用的等宽字体文件。"""
import captcha
package_file = getattr(captcha, "__file__", None)
if package_file:
bundled = Path(package_file).parent / "data" / "DroidSansMono.ttf"
if bundled.is_file():
return bundled
for candidate in _FONT_CANDIDATES:
path = Path(candidate)
if path.is_file():
return path
return None
def _load_font(size: int) -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
"""加载字体。找不到任何字体文件时退回 PIL 内置位图字体(仍可用,只是较粗糙)。"""
path = _font_path()
if path is None:
return ImageFont.load_default(size)
return ImageFont.truetype(str(path), size)
def generate_text() -> str:
"""生成验证码文本。
用 `secrets` 而非 `random`:验证码是安全凭据,
`random` 的 Mersenne Twister 状态可被观测样本反推。
"""
return "".join(secrets.choice(CAPTCHA_CHARSET) for _ in range(CAPTCHA_LENGTH))
def _draw_speckles(draw: ImageDraw.ImageDraw, count: int) -> None:
"""撒噪点。
用单像素点与 2×2 方块,**不用 `ellipse`** —— 3×3 包围盒画出的椭圆
恰好填充中心与四个边中点,形状就是「+」,会形成一种规则的十字纹理,
既显眼又容易被整体滤波抹掉,起不到干扰作用。
"""
for _ in range(count):
x = random.randint(0, _WIDTH - 2)
y = random.randint(0, _HEIGHT - 2)
if random.random() < 0.75:
draw.point((x, y), fill=_SPECKLE)
else:
draw.rectangle((x, y, x + 1, y + 1), fill=_SPECKLE)
def _draw_interference(draw: ImageDraw.ImageDraw) -> None:
"""绘制干扰曲线。
用低对比度的正弦折线而非粗直线:粗直线会把字符主干割断、
显著提高正常用户的识错率,而低对比细线对分割算法的干扰并不更差。
"""
for _ in range(_INTERFERENCE_LINES):
amplitude = random.uniform(5.0, 11.0)
phase = random.uniform(0, math.tau)
base_y = random.uniform(_HEIGHT * 0.25, _HEIGHT * 0.75)
points = [
(x, base_y + amplitude * math.sin(phase + x / 34.0)) for x in range(0, _WIDTH + 1, 4)
]
draw.line(points, fill=_INTERFERENCE, width=1)
def _draw_characters(image: Image.Image, text: str) -> None:
"""逐字绘制。
**每个字符单独渲染后旋转再贴回**,而不是整行一起旋转:
整行旋转只是把同一个倾斜做一次仿射变换,对齐后即可还原;
逐字随机旋转会破坏字符间的版式一致性,模板匹配难以复用。
再叠加 ±2px 的垂直抖动,进一步打散基线。
"""
font = _load_font(_FONT_SIZE)
cell = (_WIDTH - 2 * _PADDING) / len(text)
for index, char in enumerate(text):
# 每个字形画在独立画布上以便单独旋转;留出余量避免旋转后切角
glyph = Image.new("RGBA", (int(cell) + 16, _HEIGHT), (0, 0, 0, 0))
ImageDraw.Draw(glyph).text(
(8, 3),
char,
font=font,
fill=(*_INK, 255),
# 加 1 像素描边:等宽细体在小字号下笔画过细,
# 而本系统的用户是营销人员、屏幕与年龄跨度都大,
# 验证码过难会直接变成支持负担。加粗的代价是连通像素略多,
# 但相对于"用户反复输错"的代价是划算的。
stroke_width=1,
stroke_fill=(*_INK, 255),
)
glyph = glyph.rotate(random.uniform(-14, 14), resample=Image.BICUBIC, expand=False)
image.paste(glyph, (int(_PADDING + index * cell) - 8, random.randint(-2, 2)), glyph)
def render_png_base64(text: str) -> str:
"""把验证码文本渲染为 PNG 并返回 base64(**不含** `data:` 前缀)。
不含前缀是为了让前端自行拼接;服务端返回纯数据,
浏览器 / 小程序 / 桌面端可各自决定呈现方式。
"""
image = Image.new("RGB", (_WIDTH, _HEIGHT), _PAPER)
draw = ImageDraw.Draw(image)
_draw_speckles(draw, _SPECKLE_COUNT // 2)
_draw_interference(draw)
_draw_characters(image, text)
# 字符之上再撒一层,干扰基于连通域的分割
_draw_speckles(draw, _SPECKLE_COUNT // 2)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return base64.b64encode(buffer.getvalue()).decode("ascii")