Let’s write an algorithm for displaying text as an image using the Java AWT library. Symbols and font can be any, but for this example we will use a combination of uppercase latin letters and digits with the Comic Sans font — we will draw a simple captcha for a website or blog.
We’ll also consider special characters, but we won’t use them, because it will be difficult for the
user to guess special characters with such a text decoration. For example, the plus +
is still
possible to guess, but the minus -
or the underscore _
is already with difficulty, and even if
you guess right, then to find these buttons with difficulty, especially on the phone. Therefore,
for captcha we’ll use a combination of only capital latin letters and digits.
Rendering special characters in a monospaced font: Drawing heart in console.
We prepare an array of symbols consisting of uppercase latin letters and numbers. Then bypass this array and draw each symbol separately — we get a picture. Then rotate the pictures alternately by ±35 degrees — we get an array of pictures with symbols. The second time we bypass the array with pictures and collect a common image — we attach pictures from left to right, so that the next picture runs over the previous one by 40% of its width.
Why 35 degrees? If we take a larger angle, then it will be difficult for the user to solve
such a captcha. For example, the letters N
and Z
will be similar to each other. If we
take a smaller angle, then such a captcha will be easy to solve using machine text recognition.
The imposition of the next picture on the previous one by 40% of its width is necessary, so that the symbols are located very close or slightly touch each other — it also complicates machine text recognition.
When rendering the font, we will use anti-aliasing, otherwise the letters will have jagged edges. Set the image with transparency support, color black, font Comic Sans.
// converting a string with text into a picture with text
private static BufferedImage stringToImage(String str, Font font) {
// font rendering context
FontRenderContext ctx = new FontRenderContext(font.getTransform(), true, true);
// get the dimensions of the picture with text when rendering
Rectangle bnd = font.getStringBounds(str, ctx).getBounds();
// create a new image with transparency support
BufferedImage image = new BufferedImage(bnd.width, bnd.height, BufferedImage.TYPE_INT_ARGB);
// turn on the editing mode of the new image
Graphics2D graphics = image.createGraphics();
// font for rendering
graphics.setFont(font);
// color, that we'll draw with
graphics.setColor(Color.BLACK);
// apply font smoothing when rendering text
graphics.setRenderingHint( // anti-aliasing pixels along the shape border
RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
// draw a picture with text
graphics.drawString(str, bnd.x, -bnd.y);
// disable the editing mode
graphics.dispose();
// return a picture with text
return image;
}
When rotating the image for smoothing, we will use bilinear interpolation, otherwise there will be a lot of unnecessary artifacts along the image borders. On the way, we recalculate the dimensions for the new image.
// rotate the picture by a given angle and change its dimensions
private static BufferedImage rotateImage(BufferedImage image, double angle) {
// converting degrees to radians
double radian = Math.toRadians(angle);
double sin = Math.abs(Math.sin(radian));
double cos = Math.abs(Math.cos(radian));
// get the dimensions of the current image
int width = image.getWidth();
int height = image.getHeight();
// calculate the dimensions of the new image
int nWidth = (int) Math.floor(width * cos + height * sin);
int nHeight = (int) Math.floor(height * cos + width * sin);
// create a new image with transparency support
BufferedImage rotated = new BufferedImage(nWidth, nHeight, BufferedImage.TYPE_INT_ARGB);
// turn on the editing mode of the new image
Graphics2D graphics = rotated.createGraphics();
// apply picture smoothing when rotating
graphics.setRenderingHint( // bilinear interpolation
RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
// shift the origin of the new image to its center
graphics.translate(nWidth / 2, nHeight / 2);
// rotate the new image with its coordinate system
graphics.rotate(radian);
// put the current image in the new one, so that their centers coincide
graphics.drawImage(image, -width / 2, -height / 2, null);
// disable the editing mode
graphics.dispose();
// return a new image
return rotated;
}
We bypass the array of symbols, draw and rotate each symbol separately, on the way calculate the dimensions for the common image. Create a common image and after that once again bypass the array of images and add them one by one from left to right to the common image. We return a pair of objects: the text value of the captcha and the picture with symbols.
// draw an array of symbols, rotate them and merge the results
private static Map.Entry<String, BufferedImage> drawSimpleCaptcha(String[] symbols)
throws IOException, FontFormatException {
Font font = Font // set the font file
.createFont(Font.TRUETYPE_FONT, new File("ComicSansMS.ttf"))
// set the font style and size
.deriveFont(Font.BOLD, 32);
// dimensions of the final image
int width = 0, height = 0;
// prepare an image array
BufferedImage[] images = new BufferedImage[symbols.length];
// bypass the array of symbols, get pictures and
// calculate the dimensions of the final image
for (int i = 0; i < symbols.length; i++) {
if (i % 2 == 0) // draw the symbols and rotate the images
images[i] = rotateImage(stringToImage(symbols[i], font), 35);
else
images[i] = rotateImage(stringToImage(symbols[i], font), -35);
// dimensions of the picture with the current symbol
int h = images[i].getHeight(), w = images[i].getWidth();
// height of the largest symbol
height = Math.max(height, h);
// we'll shift the next symbol by 40% of the previous one's width
if (i < symbols.length - 1)
width += w * 6 / 10; // take 60% of the current symbol's width
else // take the last symbol's width entirely
width += w;
}
// create a new image with transparency support
BufferedImage captcha = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
// turn on the editing mode of the new image
Graphics2D graphics = captcha.createGraphics();
// bypass the array of images and add them to the common image from left to right
for (BufferedImage image : images) {
// draw the current symbol at the origin
graphics.drawImage(image, 0, 0, null);
// shift the origin by 60% of the current symbol's width
graphics.translate(image.getWidth() * 6 / 10, 0);
}
// disable the editing mode
graphics.dispose();
// pair of objects: the text value of the captcha and the picture with symbols
return Map.entry(String.join("", symbols), captcha);
}
// method for outer calls, returns a random combination of 5 symbols
public static Map.Entry<String, BufferedImage> drawSimpleCaptcha()
throws IOException, FontFormatException {
return drawSimpleCaptcha(5);
}
// method for outer calls, string length required
public static Map.Entry<String, BufferedImage> drawSimpleCaptcha(int length)
throws IOException, FontFormatException {
// получаем случайную комбинацию символов указанной длины
String[] symbols = getRandomString(length);
return drawSimpleCaptcha(symbols);
}
// get a random combination of uppercase latin letters and numbers
private static String[] getRandomString(int length) {
String[] symbols = new String[length];
Random random = new Random();
for (int i = 0; i < length; i++) {
// 26 capital letters and 10 digits
int rnd = random.nextInt(36);
if (rnd < 26) // letters [A..Z]
symbols[i] = Character.toString('A' + rnd);
else // digits [0..9]
symbols[i] = Character.toString('0' + rnd - 26);
}
return symbols;
}
The algorithm turned out to be universal — it can render almost any string and in almost any font, but with a long list of exceptions, related to unicode symbol ranges and font types. There are many variants, testing takes a long time, and the simplified model suits me quite well.
To complete this example for visualization, let’s draw a line: SIMPLE+CAPTCHA+1+1
.
// start the program and output the result
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);
// save image to file, text to console
ImageIO.write(captcha.getValue(), "png", new File("captcha.png"));
// System.out.println(captcha.getKey());
}
See the picture from this code above: captcha.png.
© Golovin G.G., Code with comments, translation from Russian, 2023