MAIN EN typewriter

older-tomato

Drawing simple captcha

Cryptography • Font rendering • Image rotation 04.01.2023

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.

Drawing simple captcha

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.

Algorithm description #

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.

Font rendering #

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;
}

Image rotation #

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;
}

Drawing simple captcha #

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);
}
Additional methods
// 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;
}

Testing and launching #

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