"""图形验证码渲染。 **为什么自绘而不用 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")