Напишем алгоритм для отображения текста в виде картинки с использованием библиотеки 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