""" 算式验证码生成器 生成形如 A op B = ? 的算式图片: - A, B 范围: 1-30 的整数 - op: +, -, × (除法只生成能整除的) - 确保结果为非负整数 - 标签格式: "3+8" (存储算式本身,不存结果) - 视觉风格: 浅色背景、深色字符、干扰线 """ import random from PIL import Image, ImageDraw, ImageFilter, ImageFont from config import GENERATE_CONFIG from generators.base import BaseCaptchaGenerator # 字体 _FONT_PATHS = [ "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSerif-Bold.ttf", "/usr/share/fonts/TTF/DejaVuSansMono-Bold.ttf", "/usr/share/fonts/liberation/LiberationSans-Bold.ttf", "/usr/share/fonts/liberation/LiberationMono-Bold.ttf", "/usr/share/fonts/gnu-free/FreeSansBold.otf", ] # 深色调色板 _DARK_COLORS = [ (0, 0, 180), (180, 0, 0), (0, 130, 0), (130, 0, 130), (120, 60, 0), (0, 0, 0), (50, 50, 150), ] # 运算符显示映射(用于渲染) _OP_DISPLAY = { "+": "+", "-": "-", "×": "×", "÷": "÷", } class MathCaptchaGenerator(BaseCaptchaGenerator): """算式验证码生成器。""" def __init__(self, seed: int | None = None): from config import RANDOM_SEED super().__init__(seed=seed if seed is not None else RANDOM_SEED) self.cfg = GENERATE_CONFIG["math"] self.width, self.height = self.cfg["image_size"] self.operators = self.cfg["operators"] self.op_lo, self.op_hi = self.cfg["operand_range"] # 预加载可用字体 self._fonts: list[str] = [] for p in _FONT_PATHS: try: ImageFont.truetype(p, 20) self._fonts.append(p) except OSError: continue if not self._fonts: raise RuntimeError("未找到任何可用字体,无法生成验证码") # ---------------------------------------------------------- # 公共接口 # ---------------------------------------------------------- def generate(self, text: str | None = None) -> tuple[Image.Image, str]: rng = self.rng # 1. 生成算式 if text is None: a, op, b = self._random_expression(rng) text = f"{a}{op}{b}" else: a, op, b = self._parse_expression(text) # 显示文本: "3+8=?" display = f"{a}{_OP_DISPLAY.get(op, op)}{b}=?" # 2. 浅色背景 bg_lo, bg_hi = self.cfg["bg_color_range"] bg = tuple(rng.randint(bg_lo, bg_hi) for _ in range(3)) img = Image.new("RGB", (self.width, self.height), bg) # 3. 绘制算式文本 self._draw_expression(img, display, rng) # 4. 干扰线 self._draw_noise_lines(img, rng) # 5. 轻微模糊 img = img.filter(ImageFilter.GaussianBlur(radius=0.6)) return img, text # ---------------------------------------------------------- # 私有方法 # ---------------------------------------------------------- def _random_expression(self, rng: random.Random) -> tuple[int, str, int]: """随机生成一个合法算式 (a, op, b),确保结果为非负整数。""" while True: op = rng.choice(self.operators) a = rng.randint(self.op_lo, self.op_hi) b = rng.randint(self.op_lo, self.op_hi) if op == "+": return a, op, b elif op == "-": # 确保 a >= b,结果非负 if a < b: a, b = b, a return a, op, b elif op == "×": # 限制乘积不过大,保持合理 if a * b <= 900: return a, op, b elif op == "÷": # 只生成能整除的 if b != 0 and a % b == 0: return a, op, b @staticmethod def _parse_expression(text: str) -> tuple[int, str, int]: """解析标签文本,如 '3+8' -> (3, '+', 8)。""" for op in ["×", "÷", "+", "-"]: if op in text: parts = text.split(op, 1) return int(parts[0]), op, int(parts[1]) raise ValueError(f"无法解析算式: {text}") def _draw_expression(self, img: Image.Image, display: str, rng: random.Random) -> None: """将算式文本绘制到图片上,每个字符单独渲染并带轻微旋转。""" n = len(display) slot_w = self.width // n font_size = int(min(slot_w * 0.85, self.height * 0.65)) font_size = max(font_size, 14) for i, ch in enumerate(display): font_path = rng.choice(self._fonts) # 对于 × 等特殊符号,某些字体可能不支持,回退到 DejaVu try: font = ImageFont.truetype(font_path, font_size) bbox = font.getbbox(ch) if bbox[2] - bbox[0] <= 0: raise ValueError except (OSError, ValueError): font = ImageFont.truetype(self._fonts[0], font_size) bbox = font.getbbox(ch) color = rng.choice(_DARK_COLORS) cw = bbox[2] - bbox[0] + 4 ch_h = bbox[3] - bbox[1] + 4 char_img = Image.new("RGBA", (cw, ch_h), (0, 0, 0, 0)) ImageDraw.Draw(char_img).text((-bbox[0] + 2, -bbox[1] + 2), ch, fill=color, font=font) # 轻微旋转 angle = rng.randint(*self.cfg["rotation_range"]) char_img = char_img.rotate(angle, resample=Image.BICUBIC, expand=True) x = slot_w * i + (slot_w - char_img.width) // 2 y = (self.height - char_img.height) // 2 + rng.randint(-2, 2) x = max(0, min(x, self.width - char_img.width)) y = max(0, min(y, self.height - char_img.height)) img.paste(char_img, (x, y), char_img) def _draw_noise_lines(self, img: Image.Image, rng: random.Random) -> None: """绘制浅色干扰线。""" draw = ImageDraw.Draw(img) lo, hi = self.cfg["noise_line_range"] num = rng.randint(lo, hi) for _ in range(num): x1, y1 = rng.randint(0, self.width), rng.randint(0, self.height) x2, y2 = rng.randint(0, self.width), rng.randint(0, self.height) color = tuple(rng.randint(150, 220) for _ in range(3)) draw.line([(x1, y1), (x2, y2)], fill=color, width=rng.randint(1, 2))