用Java写的一个滑块验证码组件

358 阅读4分钟

本人结合其它文章中的思路而实现的滑块验证码组件,优点:

  1. 开箱即用:无需除了lombok之外的其它依赖,可直接复制到项目中
  2. 使用方式简单:通过静态的from()方法即可自动生成滑块验证码信息
  3. 易于扩展:可以通过自定义的SlideCanvas组件来自行绘制滑块的形状
import lombok.AllArgsConstructor;
import lombok.Getter;

import javax.imageio.ImageIO;
import java.awt.*;
import java.awt.image.BufferedImage;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.util.Base64;
import java.util.function.BiFunction;
import java.util.function.IntBinaryOperator;

/**
 * 滑块验证码生成器
 *
 * @author NightDW 2023/6/20 14:33
 */
@Getter
public class SlideCaptcha {

    /**
     * 滑块的画布;可以通过该组件来自定义滑块的形状
     */
    @AllArgsConstructor
    public static abstract class SlideCanvas {
        protected static final int BLANK = 0, SLIDE = 1, BORDER = 2;

        /**
         * 画布的宽度和高度
         */
        @Getter
        private final int width, height;

        /**
         * 获取该画布中指定坐标的像素的状态(横轴代表x,纵轴代表y):
         * 如果是BLANK,则代表该像素不是滑块的一部分
         * 如果是SLIDE,则代表该像素是滑块的内部
         * 如果是BORDER,则代表该像素是滑块的边界
         */
        public abstract int getSlideState(int x, int y);
    }

    /**
     * 简单的滑块画布;整个画布都是滑块内容,且边界宽度为1px
     */
    public static class SimpleSlideCanvas extends SlideCanvas {
        public SimpleSlideCanvas(int sideLength) {
            this(sideLength, sideLength);
        }

        public SimpleSlideCanvas(int width, int height) {
            super(width, height);
        }

        @Override
        public int getSlideState(int x, int y) {
            return x == 0 || y == 0 || x == getWidth() - 1 || y == getHeight() - 1 ? BORDER : SLIDE;
        }
    }

    /**
     * 拼图形状的滑块画布;滑块是矩形的,且矩形的边会有半圆形的凸起/凹陷
     */
    public static class PuzzleSlideCanvas extends SlideCanvas {
        public static final int TOP = 1, BUTTON = 2, LEFT = 4, RIGHT = 8;

        /**
         * 圆形凸起(或凹陷)的半径及其平方,以及有哪些边需要有凸起
         * 假设protrudeSides为5,则上边和左边有凸起,下边和右边有凹陷
         */
        private final int radius, radius2, protrudeSides;

        /**
         * 上下左右四条边各自对应的圆的圆心
         */
        private final int[][] circleCenters = new int[RIGHT + 1][];

        /**
         * 简单的构造器;画布为正方形,且圆形的半径为画布边长的13/84
         */
        public PuzzleSlideCanvas(int sideLength, int protrudeSides) {
            this(sideLength, sideLength, sideLength * 13 / 84, protrudeSides);
        }

        /**
         * 全参构造器;需指定画布的宽高、圆形的半径、有半圆形的凸起的边
         */
        private PuzzleSlideCanvas(int width, int height, int radius, int protrudeSides) {
            super(width, height);
            this.radius = radius;
            this.radius2 = radius * radius;
            this.protrudeSides = protrudeSides;
            this.circleCenters[TOP] = new int[] { width >> 1, radius };
            this.circleCenters[BUTTON] = new int[] { width >> 1, height - radius };
            this.circleCenters[LEFT] = new int[] { radius, height >> 1 };
            this.circleCenters[RIGHT] = new int[] { width - radius, height >> 1 };
        }

        @Override
        public int getSlideState(int x, int y) {

            // 判断当前点是否在圆形中;如果是,则获取到对应的圆形的下标
            int circleIdx = getCircleIdx(x, y);

            // 如果不在圆形上,则只需判断该点是否在滑块的矩形部分中即可
            if (circleIdx == 0) {
                return !isInInnerRec(x, y) ? BLANK : isInInnerBorder(x, y) ? BORDER : SLIDE;
            }

            // 判断该点是否位于圆形的边界,并获取到真正的圆形的下标
            boolean isBorder = circleIdx < 0;
            circleIdx = Math.abs(circleIdx);

            // 如果该点所在的圆形位于凸起的那一侧
            // 那么当该点在圆形的边界且不在内部矩形中时,该点位于滑块边界,否则位于滑块内部
            if ((protrudeSides & circleIdx) != 0) {
                return isBorder && (isInInnerBorder(x, y) || !isInInnerRec(x, y)) ? BORDER : SLIDE;
            }

            // 如果该点所在的圆形位于凹陷的那一侧
            // 那么当该点在圆形的边界且在内部矩形中时,该点位于滑块边界,否则位于滑块外部
            return isBorder && isInInnerRec(x, y) ? BORDER : BLANK;
        }

        /**
         * 判断指定坐标是否位于上下左右四个圆形上
         * 如果不是,则返回0;否则返回圆形的下标
         * 特别地,如果正好位于圆的边上,则返回圆形的下标的负数形式
         */
        private int getCircleIdx(int x, int y) {
            for (int i = 0; i < circleCenters.length; i++) {
                if (circleCenters[i] != null) {
                    int dx = circleCenters[i][0] - x;
                    int dy = circleCenters[i][1] - y;
                    int tem = dx * dx + dy * dy;
                    if (tem <= radius2) {
                        return tem == radius2 ? -i : i;
                    }
                }
            }
            return 0;
        }

        private boolean isInInnerRec(int x, int y) {
            return x >= radius && y >= radius && x <= getWidth() - radius && y <= getHeight() - radius;
        }

        private boolean isInInnerBorder(int x, int y) {
            boolean xIsBorder = x == radius || x == getWidth() - radius;
            boolean yIsBorder = y == radius || y == getHeight() - radius;
            return (xIsBorder && y >= radius && y <= getHeight() - radius) || (yIsBorder && x >= radius && x <= getWidth() - radius);
        }
    }

    /**
     * 默认的用于计算滑块画布的坐标的组件:x轴随机,y轴垂直居中
     */
    private static final IntBinaryOperator DEFAULT_X = (imgW, slideW) -> slideW + (int) (Math.random() * (imgW - slideW * 3));
    private static final IntBinaryOperator DEFAULT_Y = (imgH, slideH) -> (imgH - slideH) >> 1;

    /**
     * 背景图片和滑块图片(Base64形式),以及滑块画布的坐标
     */
    private final String backgroundImg;
    private final String slideImg;
    private final int x, y;

    /**
     * 私有的全参构造器
     */
    private SlideCaptcha(String backgroundImg, String slideImg, int x, int y) {
        this.backgroundImg = backgroundImg;
        this.slideImg = slideImg;
        this.x = x;
        this.y = y;
    }

    /**
     * 根据图片文件流、滑块画布生成器来生成滑块验证码信息
     */
    public static SlideCaptcha from(InputStream file, BiFunction<Integer, Integer, SlideCanvas> slideCanvasGen) throws IOException {
        return from(file, slideCanvasGen, DEFAULT_X, DEFAULT_Y);
    }

    /**
     * 根据图片文件流、滑块画布生成器和滑块坐标生成器来生成滑块验证码信息
     */
    public static SlideCaptcha from(InputStream file, BiFunction<Integer, Integer, SlideCanvas> slideCanvasGen,
            IntBinaryOperator xGen, IntBinaryOperator yGen) throws IOException {

        // 获取到原始图片(即背景图片)信息
        BufferedImage background = ImageIO.read(file);

        // 根据原始图片的大小来创建相应大小的画布
        SlideCanvas slideCanvas = slideCanvasGen.apply(background.getWidth(), background.getHeight());

        // 计算滑块画布坐标
        int x = xGen.applyAsInt(background.getWidth(), slideCanvas.getWidth());
        int y = yGen.applyAsInt(background.getHeight(), slideCanvas.getHeight());

        // 创建一个空白的滑块图片信息
        BufferedImage slide = new BufferedImage(slideCanvas.getWidth(), slideCanvas.getHeight(), BufferedImage.TYPE_4BYTE_ABGR);

        // 根据滑块画布、滑块坐标来处理背景图片信息和滑块图片信息
        apply(background, slide, slideCanvas, x, y);

        // 将处理后的背景图片和滑块图片转成Base64形式,并返回相应的SlideCaptcha实例
        return new SlideCaptcha(toBase64(background), toBase64(slide), x, y);
    }

    /**
     * 根据滑块画布、滑块坐标来处理背景图片信息和滑块图片信息
     */
    private static void apply(BufferedImage background, BufferedImage slide, SlideCanvas slideCanvas, int x, int y) {

        // 遍历滑块画布中的每一个像素
        for (int slideX = 0, width = slideCanvas.getWidth(); slideX < width; slideX++) {
            for (int slideY = 0, height = slideCanvas.getHeight(); slideY < height; slideY++) {

                // 获取到当前像素在背景图片中的坐标,并获取当前像素的状态
                int bgX = x + slideX;
                int bgY = y + slideY;
                int slideState = slideCanvas.getSlideState(slideX, slideY);

                // 如果当前位置是滑块内部,则将背景图中的该像素的颜色复制到滑块图中,并将背景图中该像素变为灰度图
                if (slideState == SlideCanvas.SLIDE) {
                    slide.setRGB(slideX, slideY, background.getRGB(bgX, bgY));
                    background.setRGB(bgX, bgY, toGray(background.getRGB(bgX, bgY)));

                // 如果当前位置是滑块边界,则将滑块图的该像素置为白色,并将背景图中该像素置灰
                } else if (slideState == SlideCanvas.BORDER) {
                    slide.setRGB(slideX, slideY, Color.WHITE.getRGB());
                    background.setRGB(bgX, bgY, Color.GRAY.getRGB());

                // 如果当前位置不属于滑块,则将滑块图的该像素置为透明
                } else {
                    slide.setRGB(slideX, slideY, background.getRGB(bgX, bgY) & 0x00ffffff);
                }
            }
        }
    }

    private static String toBase64(BufferedImage image) throws IOException {
        ByteArrayOutputStream out = new ByteArrayOutputStream();
        ImageIO.write(image, "png", out);
        return Base64.getEncoder().encodeToString(out.toByteArray());
    }

    private static int toGray(int rgb) {
        int b = rgb & 255;
        int g = (rgb >> 8) & 255;
        int r = (rgb >> 16) & 255;
        int avg = (g + b + r) / 8;
        return avg | (avg << 8) | (avg << 16);
    }
}