放大图形验证码文字

This commit is contained in:
Certificate System
2026-08-13 15:46:50 +08:00
parent f72645225a
commit eac19ea593
3 changed files with 51 additions and 9 deletions

View File

@@ -4,10 +4,19 @@ import random
import secrets import secrets
import string import string
import time import time
from pathlib import Path
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont
CAPTCHA_TTL_SECONDS = 300 CAPTCHA_TTL_SECONDS = 300
CAPTCHA_WIDTH = 148
CAPTCHA_HEIGHT = 48
CAPTCHA_FONT_SIZE = 30
CAPTCHA_FONT_PATHS = (
Path("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf"),
Path("/System/Library/Fonts/SFNS.ttf"),
Path("/Library/Fonts/Arial Bold.ttf"),
)
_captchas: dict[str, tuple[str, float]] = {} _captchas: dict[str, tuple[str, float]] = {}
@@ -38,17 +47,29 @@ def clean_expired_captchas() -> None:
def draw_captcha_image(code: str) -> str: def draw_captcha_image(code: str) -> str:
width, height = 132, 44 image = Image.new("RGB", (CAPTCHA_WIDTH, CAPTCHA_HEIGHT), "#f8fafc")
image = Image.new("RGB", (width, height), "#f8fafc")
draw = ImageDraw.Draw(image) draw = ImageDraw.Draw(image)
font = ImageFont.load_default() font = load_captcha_font()
for i, char in enumerate(code): for i, char in enumerate(code):
draw.text((18 + i * 26, 13 + random.randint(-2, 2)), char, fill="#1f2937", font=font) bounds = draw.textbbox((0, 0), char, font=font)
text_height = bounds[3] - bounds[1]
y = (CAPTCHA_HEIGHT - text_height) // 2 - bounds[1] + random.randint(-2, 2)
draw.text((12 + i * 33, y), char, fill="#111827", font=font)
for _ in range(8): for _ in range(8):
x1, y1 = random.randint(0, width), random.randint(0, height) x1, y1 = random.randint(0, CAPTCHA_WIDTH), random.randint(0, CAPTCHA_HEIGHT)
x2, y2 = random.randint(0, width), random.randint(0, height) x2, y2 = random.randint(0, CAPTCHA_WIDTH), random.randint(0, CAPTCHA_HEIGHT)
draw.line((x1, y1, x2, y2), fill="#cbd5e1", width=1) draw.line((x1, y1, x2, y2), fill="#cbd5e1", width=1)
buffer = io.BytesIO() buffer = io.BytesIO()
image.save(buffer, format="PNG") image.save(buffer, format="PNG")
return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii") return "data:image/png;base64," + base64.b64encode(buffer.getvalue()).decode("ascii")
def load_captcha_font() -> ImageFont.FreeTypeFont | ImageFont.ImageFont:
for path in CAPTCHA_FONT_PATHS:
if path.exists():
return ImageFont.truetype(str(path), CAPTCHA_FONT_SIZE)
try:
return ImageFont.load_default(size=CAPTCHA_FONT_SIZE)
except TypeError:
return ImageFont.load_default()

View File

@@ -1,4 +1,16 @@
from app.services.captcha import make_captcha, verify_captcha import base64
import io
from PIL import Image
from app.services.captcha import (
CAPTCHA_FONT_SIZE,
CAPTCHA_HEIGHT,
CAPTCHA_WIDTH,
load_captcha_font,
make_captcha,
verify_captcha,
)
def test_captcha_can_only_be_used_once(): def test_captcha_can_only_be_used_once():
@@ -7,3 +19,12 @@ def test_captcha_can_only_be_used_once():
# The real code is intentionally not exposed. This test locks in one-time consumption behavior. # The real code is intentionally not exposed. This test locks in one-time consumption behavior.
assert not verify_captcha(captcha_id, "bad") assert not verify_captcha(captcha_id, "bad")
assert not verify_captcha(captcha_id, "bad") assert not verify_captcha(captcha_id, "bad")
def test_captcha_uses_large_font_and_expected_image_size():
captcha = make_captcha()
encoded_image = captcha["image"].split(",", 1)[1]
image = Image.open(io.BytesIO(base64.b64decode(encoded_image)))
assert image.size == (CAPTCHA_WIDTH, CAPTCHA_HEIGHT)
assert getattr(load_captcha_font(), "size", CAPTCHA_FONT_SIZE) == CAPTCHA_FONT_SIZE

View File

@@ -409,7 +409,7 @@ h1 {
.captcha-row { .captcha-row {
display: grid; display: grid;
grid-template-columns: 1fr 132px; grid-template-columns: minmax(0, 1fr) 148px;
gap: 10px; gap: 10px;
width: 100%; width: 100%;
} }
@@ -422,7 +422,7 @@ h1 {
} }
.captcha-button { .captcha-button {
height: 34px; height: 42px;
padding: 0; padding: 0;
border: 1px solid #dbe6ea; border: 1px solid #dbe6ea;
border-radius: 8px; border-radius: 8px;