ГЛАВНАЯ RU typewriter

older-tomato

Рисуем простую капчу

Криптография • Отрисовка шрифта • Поворот изображения 03.01.2023

Напишем алгоритм для отображения текста в виде картинки с использованием библиотеки Java AWT. Символы и шрифт могут быть любыми, но для этого примера будем использовать комбинацию заглавных латинских букв и цифр со шрифтом Comic Sans — будем рисовать простую капчу для сайта или блога.

Рисуем простую капчу

Спецсимволы тоже рассмотрим, но использовать их не будем, потому что угадать спецсимволы пользователю будет сложно с таким оформлением текста. Например, плюс + угадать ещё можно, а вот минус - или нижнее подчёркивание _ уже с трудом, и даже если угадаешь, тогда найти эти кнопки с трудом, особенно на телефоне. Поэтому для капчи будем использовать комбинацию только из заглавных латинских букв и цифр.

Отрисовка спецсимволов моноширинным шрифтом: Рисуем сердечко в консоли.

Описание алгоритма #

Подготавливаем массив символов, состоящий из заглавных латинских букв и цифр. Затем обходим этот массив и отрисовываем каждый символ отдельно — получаем картинку. Затем поворачиваем картинки поочерёдно на ±35 градусов — получаем массив картинок с символами. Второй раз обходим массив с картинками и собираем общее изображение — присоединяем картинки слева направо таким образом, чтобы последующая картинка наезжала на предыдущую на 40% её ширины.

Почему 35 градусов? Если взять угол больше, тогда пользователю будет сложно разгадать такую капчу. Например, буквы N и Z будут похожи друг на друга. Если взять угол меньше, то такую капчу будет легко разгадать с помощью машинного распознавания текста.

Наложение последующей картинки на предыдущую на 40% её ширины нужно, чтобы символы располагались очень близко или слегка касались друг друга — это также затрудняет машинное распознавание текста.

Отрисовка шрифта #

При отрисовке шрифта будем использовать сглаживание anti-aliasing, иначе буквы будут с зазубренными краями. Устанавливаем изображение с поддержкой прозрачности, цвет чёрный, шрифт Comic Sans.

// преобразовываем строку с текстом в картинку с текстом
private static BufferedImage stringToImage(String str, Font font) {
    // контекст отображения шрифта
    FontRenderContext ctx = new FontRenderContext(font.getTransform(), true, true);
    // получаем размеры картинки с текстом при отрисовке
    Rectangle bnd = font.getStringBounds(str, ctx).getBounds();
    // создаём новое изображение с поддержкой прозрачности
    BufferedImage image = new BufferedImage(bnd.width, bnd.height, BufferedImage.TYPE_INT_ARGB);
    // включаем режим редактирования нового изображения
    Graphics2D graphics = image.createGraphics();
    // шрифт для отрисовки
    graphics.setFont(font);
    // цвет, которым будем рисовать
    graphics.setColor(Color.BLACK);
    // применяем сглаживание шрифта при отрисовке текста
    graphics.setRenderingHint( // сглаживание пикселей вдоль границы фигуры
            RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    // рисуем картинку с текстом
    graphics.drawString(str, bnd.x, -bnd.y);
    // отключаем режим редактирования
    graphics.dispose();
    // возвращаем картинку с текстом
    return image;
}

Поворот изображения #

При повороте изображения для сглаживания будем использовать билинейную интерполяцию, иначе будет много лишних артефактов по границам изображения. По дороге пересчитываем размеры для нового изображения.

// поворачиваем картинку на заданный угол и изменяем её размеры
private static BufferedImage rotateImage(BufferedImage image, double angle) {
    // переводим градусы в радианы
    double radian = Math.toRadians(angle);
    double sin = Math.abs(Math.sin(radian));
    double cos = Math.abs(Math.cos(radian));
    // получаем размеры текущего изображения
    int width = image.getWidth();
    int height = image.getHeight();
    // вычисляем размеры нового изображения
    int nWidth = (int) Math.floor(width * cos + height * sin);
    int nHeight = (int) Math.floor(height * cos + width * sin);
    // создаём новое изображение с поддержкой прозрачности
    BufferedImage rotated = new BufferedImage(nWidth, nHeight, BufferedImage.TYPE_INT_ARGB);
    // включаем режим редактирования нового изображения
    Graphics2D graphics = rotated.createGraphics();
    // применяем сглаживание изображения при повороте
    graphics.setRenderingHint( // билинейная интерполяция
            RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
    // сдвигаем начало координат нового изображения в его центр
    graphics.translate(nWidth / 2, nHeight / 2);
    // поворачиваем новое изображение вместе с его системой координат
    graphics.rotate(radian);
    // помещаем текущее изображение в новое, чтобы их центры совпали
    graphics.drawImage(image, -width / 2, -height / 2, null);
    // отключаем режим редактирования
    graphics.dispose();
    // возвращаем новое изображение
    return rotated;
}

Рисуем простую капчу #

Обходим массив символов, отрисовываем и поворачиваем каждый символ в отдельности, по дороге вычисляем размеры для общего изображения. Создаём общее изображение и после этого ещё раз обходим массив картинок и дорисовываем их по одной слева направо к общему изображению. Возвращаем пару объектов: текстовое значение капчи и картинку с символами.

// отрисовываем массив символов, поворачиваем их и объединяем результаты
private static Map.Entry<String, BufferedImage> drawSimpleCaptcha(String[] symbols)
        throws IOException, FontFormatException {
    Font font = Font // устанавливаем файл шрифта
            .createFont(Font.TRUETYPE_FONT, new File("ComicSansMS.ttf"))
            // устанавливаем стиль и размер шрифта
            .deriveFont(Font.BOLD, 32);
    // размеры итогового изображения
    int width = 0, height = 0;
    // подготавливаем массив картинок
    BufferedImage[] images = new BufferedImage[symbols.length];
    // обходим массив символов, получаем картинки
    // и вычисляем размеры итогового изображения
    for (int i = 0; i < symbols.length; i++) {
        if (i % 2 == 0) // отрисовываем символы и поворачиваем изображения
            images[i] = rotateImage(stringToImage(symbols[i], font), 35);
        else
            images[i] = rotateImage(stringToImage(symbols[i], font), -35);
        // размеры картинки с текущим символом
        int h = images[i].getHeight(), w = images[i].getWidth();
        // высота самого большого символа
        height = Math.max(height, h);
        // последующий символ будем сдвигать на 40% ширины предыдущего
        if (i < symbols.length - 1)
            width += w * 6 / 10; // берём 60% ширины текущего символа
        else // ширину последнего символа берём целиком
            width += w;
    }
    // создаём новое изображение с поддержкой прозрачности
    BufferedImage captcha = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
    // включаем режим редактирования нового изображения
    Graphics2D graphics = captcha.createGraphics();
    // обходим массив картинок и дорисовываем их к общему изображению слева направо
    for (BufferedImage image : images) {
        // отрисовываем текущий символ в начале координат
        graphics.drawImage(image, 0, 0, null);
        // сдвигаем начало координат на 60% ширины текущего символа
        graphics.translate(image.getWidth() * 6 / 10, 0);
    }
    // отключаем режим редактирования
    graphics.dispose();
    // возвращаем пару объектов: текстовое значение капчи и картинку с символами
    return Map.entry(String.join("", symbols), captcha);
}
Дополнительные методы
// метод для внешних вызовов, возвращает случайную комбинацию из 5 символов
public static Map.Entry<String, BufferedImage> drawSimpleCaptcha()
        throws IOException, FontFormatException {
    return drawSimpleCaptcha(5);
}
// метод для внешних вызовов, требуется указать длину строки
public static Map.Entry<String, BufferedImage> drawSimpleCaptcha(int length)
        throws IOException, FontFormatException {
    // получаем случайную комбинацию символов указанной длины
    String[] symbols = getRandomString(length);
    return drawSimpleCaptcha(symbols);
}
// получаем случайную комбинацию заглавных латинских букв и цифр
private static String[] getRandomString(int length) {
    String[] symbols = new String[length];
    Random random = new Random();
    for (int i = 0; i < length; i++) {
        // 26 заглавных букв и 10 цифр
        int rnd = random.nextInt(36);
        if (rnd < 26) // буквы [A..Z]
            symbols[i] = Character.toString('A' + rnd);
        else // цифры [0..9]
            symbols[i] = Character.toString('0' + rnd - 26);
    }
    return symbols;
}

Тестирование и запуск #

Алгоритм получился универсальный — отрисовать можно почти любую строку и почти любым шрифтом, но с длинным списком исключений, связанных с диапазонами символов юникода и типами шрифтов. Вариантов много, тестировать долго, а упрощенная модель меня вполне устраивает.

В завершение этого примера для визуализации нарисуем строку: SIMPLE+CAPTCHA+1+1.

// запускаем программу и выводим результат
public static void main(String[] args) throws IOException, FontFormatException {
    String[] symbols = "simple+captcha+1+1".toUpperCase().split("");
    // Map.Entry<String, BufferedImage> captcha = drawSimpleCaptcha(18);
    Map.Entry<String, BufferedImage> captcha = drawSimpleCaptcha(symbols);
    // сохраняем картинку в файл, текст в консоль
    ImageIO.write(captcha.getValue(), "png", new File("captcha.png"));
    // System.out.println(captcha.getKey());
}

Картинку из этого кода см. выше: captcha.png.


© Головин Г.Г., Код с комментариями, 2023