24
\$\begingroup\$

I wanted to render the textures that comprise a bitmap font glyph onto the screen directly as an Image in libGDX. When you make a bitmap font using a program (such as Hiero), it generates a text readable .fnt file along with a .png file that is the sprite sheet for the font. The only thing missing is a matching .atlas file to tell the location of the textures in that .png.

This program takes a .fnt file as input and outputs a .atlas file that can be used with libGDX (and any engines that use the same type of atlas file). It parses the font file to find the names of the textures and their location on the sprite sheet.

One reason I am seeking feedback is that this is the first program/code that I have put on Github with the intention of other people using it. It would be interesting to hear whether there are enough comments and enough documentation for others to understand and use software.

Launcher.java

public class Launcher {

    /**
     * The file name for the atlas generator must be passed in
     * without a file extension.
     */
    public static void main(String[] args) throws IOException {
        String fileName = "test_dos437";
        new FntToAtlasGenerator(fileName);
    }
}

FntToAtlasGenerator.java

/**
 * The idea is to pass in the name of a .fnt file generated by Hiero
 * This program will generate a .atlas file that is compatible with libGDX
 * Next put the .atlas file and the .png that comes along with the .fnt file
 * into the android/assets folder of your libGDX project.
 * 
 * @author baz
 *
 */
public class FntToAtlasGenerator {

    List<GlyphData> glyphs = new ArrayList<GlyphData>();

    public FntToAtlasGenerator(String fileName) throws IOException {

        //String fileName = "test_dos437";
        String inputDir = "input/";
        String outputDir = "output/";
        String extension = ".fnt";
        String atlasExtension = ".atlas";

        FileReader fontReader = new FileReader(inputDir + fileName + extension);
        BufferedReader reader = new BufferedReader(fontReader);

        reader.readLine(); //info line
        String commonLine = reader.readLine();
        String pageLine = reader.readLine();
        reader.readLine(); //chars line

        String line = reader.readLine();
        while(line != null) {
            this.addLineToGlyphs(line);
            line = reader.readLine();
        }

        reader.close();

        PrintWriter writer = new PrintWriter(outputDir + fileName + atlasExtension, "UTF-8");

        //values read from .fnt file
        String fileNameForAtlas = this.getFileNameForPageLine(pageLine);
        String size = this.getSizeForCommonLine(commonLine);

        //default values
        String format = "RGBA8888";
        String filter = "Nearest, Nearest";
        String repeat = "none";

        this.writeOpeningLines(writer, fileNameForAtlas, size, format, filter, repeat);

        for (GlyphData glyph : this.glyphs) {
            this.writeGlyph(glyph, writer);
        }

        writer.close();
    }

    private void writeOpeningLines(PrintWriter writer, String fileName, String size, String format, String filter, String repeat) {
        writer.println(fileName);
        writer.println("size: " + size);
        writer.println("format: " + format);
        writer.println("filter: " + filter);
        writer.println("repeat: " + repeat);
    }

    /**
     * The name will be a string that is the integer of the character in ASCII
     * The idea is that you can get the integer value of a character in a string
     * and then render its image to the screen
     */
    private void writeGlyph(GlyphData glyph, PrintWriter writer) {
        String stringOffset = "  "; //two spaces for lines after name
        writer.println(glyph.id); //name
        writer.println(stringOffset + "rotate: false");
        writer.println(stringOffset + "xy: " + glyph.x + ", " + glyph.y);
        writer.println(stringOffset + "size: " + glyph.width + ", " + glyph.height);
        writer.println(stringOffset + "orig: " + glyph.width + ", " + glyph.height);
        writer.println(stringOffset + "offset: " + glyph.xoffset + ", " + glyph.yoffset);
        writer.println(stringOffset + "index: -1");
    }

    private String getFileNameForPageLine(String pageLine) {
        String[] fragments = pageLine.split(" ");
        String nameString = fragments[2];
        return nameString.replace("file=", "").replace("\"", "");
    }

    private String getSizeForCommonLine(String commonLine) {
        String[] fragments = commonLine.split(" ");
        String widthString = fragments[3];
        widthString = widthString.replace("scaleW=", "");
        String heightString = fragments[4];
        heightString = heightString.replace("scaleH=", "");
        return widthString + "," + heightString;
    }

    private void addLineToGlyphs(String lineString) throws IOException {
        if (lineString != null) {
            String[] lineFragments = lineString.split(" ");
            List<String> formattedStrings = new ArrayList<String>();

            //remove new line, space, and return characters
            //because there are wacky spaces in between the text of the .fnt file
            //and when you split on space, it adds new line type characters
            if (lineFragments[0].equals("char")) {
                for (int i = 0; i < lineFragments.length; i++) {
                    String string = lineFragments[i];
                    string = string.replace(" ", "");
                    string = string.replace("\n", "");
                    string = string.replace("\r", "");
                    //cant just reassign, because we need to remove empties
                    //and we want to directly assign based on index because we know the format
                    if (!(string.equals(" ") ||
                          string.equals("\n") ||
                          string.equals("\r") ||
                          string.isEmpty())) {
                        formattedStrings.add(string);
                    }
                }

                /*
                for (String string : formattedStrings) {
                    System.out.println(string);
                }
                */

                GlyphData data = new GlyphData(formattedStrings);
                this.glyphs.add(data);
            }
        }
    }
}

//example input
/*
info face="Pescadero" size=20 bold=1 italic=0 charset="" unicode=0 stretchH=100 smooth=1 aa=1 padding=2,2,2,2 spacing=2,2
        common lineHeight=31 base=19 scaleW=256 scaleH=256 pages=1 packed=0
        page id=0 file="pescadero-blackWhite-20.png"
        chars count=94
char id=32   x=0     y=0     width=0     height=0     xoffset=0     yoffset=19    xadvance=13     page=0  chnl=0 
        char id=124   x=0     y=0     width=7     height=26     xoffset=2     yoffset=2    xadvance=17     page=0  chnl=0 
        char id=92   x=7     y=0     width=14     height=25     xoffset=-2     yoffset=2    xadvance=14     page=0  chnl=0 
        char id=47   x=21     y=0     width=14     height=25     xoffset=-2     yoffset=2    xadvance=14     page=0  chnl=0 
        char id=106   x=35     y=0     width=10     height=24     xoffset=-2     yoffset=4    xadvance=12     page=0  chnl=0 
        char id=81   x=45     y=0     width=22     height=23     xoffset=-1     yoffset=4    xadvance=23     page=0  chnl=0 
        char id=74   x=67     y=0     width=12     height=23     xoffset=-2     yoffset=3    xadvance=13     page=0  chnl=0 
        char id=93   x=79     y=0     width=10     height=22     xoffset=-2     yoffset=3    xadvance=13     page=0  chnl=0 
        char id=91   x=89     y=0     width=10     height=22     xoffset=0     yoffset=3    xadvance=13     page=0  chnl=0 
        char id=41   x=99     y=0     width=11     height=22     xoffset=-2     yoffset=4    xadvance=13     page=0  chnl=0 
        char id=40   x=110     y=0     width=11     height=22     xoffset=-1     yoffset=4    xadvance=13     page=0  chnl=0 
        char id=112   x=121     y=0     width=16     height=22     xoffset=-2     yoffset=7    xadvance=18     page=0  chnl=0 
        kearnings count = -1
*/
//example output
/*
texturePackResize22.png
size: 1784,1498
format: RGBA8888
filter: Nearest,Nearest
repeat: none
arco01
  rotate: false
  xy: 164, 326
  size: 160, 318
  orig: 160, 318
  offset: 0, 0
  index: -1
arco02
  rotate: false
  xy: 326, 752
  size: 160, 318
  orig: 160, 318
  offset: 0, 0
  index: -1
arco03
  rotate: false
  xy: 488, 1178
  size: 160, 318
  orig: 160, 318
  offset: 0, 0
  index: -1
*/

GlyphData.java

public class GlyphData {

    public final String character;
    public final String id;
    public final String x;
    public final String y;
    public final String width;
    public final String height;
    public final String xoffset;
    public final String yoffset;
    public final String xadvance;
    public final String page;
    public final String chnl;

    public GlyphData(List<String> glyphDataFragments) {

        //preserving all non white space elements of the char line of the .fnt file
        //some of the data may be needed later
        //im leaving this block in so it is clear which are currently unused
        String character = glyphDataFragments.get(0); //keyword for font language
        String id = glyphDataFragments.get(1);
        String x = glyphDataFragments.get(2);
        String y = glyphDataFragments.get(3);
        String width = glyphDataFragments.get(4);
        String height = glyphDataFragments.get(5);
        String xoffset = glyphDataFragments.get(6);
        String yoffset = glyphDataFragments.get(7);
        String xadvance = glyphDataFragments.get(8);
        String page = glyphDataFragments.get(9);
        String chnl = glyphDataFragments.get(10);

        this.id = id.replace("id=", "");
        this.x = x.replace("x=", "");
        this.y = y.replace("y=", "");
        this.width = width.replace("width=", "");
        this.height = height.replace("height=", "");
        this.xoffset = xoffset.replace("xoffset=", "");
        this.yoffset = yoffset.replace("yoffset=", "");

        //unused
        this.character = character;
        this.xadvance = xadvance;
        this.page = page;
        this.chnl = chnl;
    }
}

When you place the generated .atlas file and .png file in the android/assets folder of the libGDX project and create a TextureAtlas object from the atlas file, you can access the TextureRegions according to the ASCII integer value of the character. For example, 70 is equal to capital F. Then you can create an Image object from the TextureRegion and use it like you would use any other sprite.

Here is the usage in libGDX:

Map<Integer, TextureRegion> textures =  new HashMap<Integer, TextureRegion>();
TextureAtlas atlas = new TextureAtlas("bitmapfont.atlas");

final Image charImage = new Image(this.libGDXGame.allTextures.get((int)character));

And a pretty picture of what is possible:

enter image description here

I've put the project on Github for anyone to use. There are no dependencies, and the README explains how to use the program. Here is the link.

\$\endgroup\$
  • \$\begingroup\$ How is this different from TextureRegion charImage = (new com.badlogic.gdx.graphics.g2d.BitmapFont(Gdx.files.internal("data/myfile.bmf").getRegion(character) ? \$\endgroup\$ – Emily L. Aug 23 '15 at 17:28
  • \$\begingroup\$ It may not be, I will have to try that and see if the results are the same. \$\endgroup\$ – bazola Aug 23 '15 at 18:59
19
+50
\$\begingroup\$

Turning my comment into an answer by request. You should be able to do what you want by simply using the functionality already built into LibGDX.

Caveat: I have not tried this, I just know that the class exists and it should be possible to extract the data you want from it, so: some assembly may be required on your part.

LibGDX has functionality for loading and dealing with bitmapped fonts. See the documentation here.

You can use the BitmapFont class to load the font data. It supports AngelCode BMFont formats. Hiero which you are using can output to BMFont.

Create a new instance of the BitmapFont:

BitmapFont bmf = new BitmapFont(Gdx.files.internal("data/myfile.bmf"));

Then get the data backing the font:

BitmapFont.BitmapFontData bmfdata = bmf.getData();

Get the Glyph for your wanted character, this contains the u/v coordinates for the glyph and which texture page it is on and some other goodies. Then get the correct texture page from the BitmapFont and use the u/v pairs to extract the texture region for your wanted glyph:

BitmapFont.Glyph glyph = bmfdata.getGlyph(character);

if(glyph == null){
    // handle error: No glyph for character
}
else{
    TextureRegion page = bmf.getRegion(glyph.page);
    TextureRegion glyphTexture = new TextureRegion(page.getTexture(), glyph.u, glyph.v, glyph.u2, glyph.v2);
    // Use glyphTexture to render, or store it somewhere. 
}

Again, I have not tested this. You may have to fiddle around or use other properties of the glyph to get the wanted result. But the data you need is all in there, you just have to pry it out. The LibGDX documentation (and source code) is your friend.

\$\endgroup\$
  • 2
    \$\begingroup\$ I just confirmed that this does work! I appreciate you showing me a new libGDX trick :) \$\endgroup\$ – bazola Aug 25 '15 at 20:52
12
\$\begingroup\$

The purpose of constructors

The purpose of constructors is to create objects. The constructor of FntToAtlasGenerator is something else entirely: it takes some input and generates some output in the filesystem, and the class has no other public methods and purpose. As such, the glyphs member variable is pointless, and all the code you have in this constructor should really be in a utility method instead.

Single responsibility principle

Converting the constructor to a utility method is not enough. A routine should have a single responsibility, and the code in the constructor has more:

  • read input data
  • write output data

These should be separated.

Use enhanced for-each loop

The counting for loop in addLineToGlyphs doesn't need the loop index. So it should be rewritten as an enhanced for-each loop:

for (String lineFragment : lineFragments) {
    // ...
}

Declare variables in the smallest scope necessary

In addLineToGlyphs, you can move the declaration of List<String> formattedStrings within the if (lineFragments[0].equals("char")) { condition, because it's not needed outside of it.

Naming

In addLineToGlyphs, you build a list called formattedStrings, and pass that to GlyphData's constructor, where the parameter is named glyphDataFragments. That's a much better name, it would be good to adopt that in addLineToGlyphs too.

Pointless statements

In this code:

    string = string.replace(" ", "");
    string = string.replace("\n", "");
    string = string.replace("\r", "");
    //cant just reassign, because we need to remove empties
    //and we want to directly assign based on index because we know the format
    if (!(string.equals(" ") ||
          string.equals("\n") ||
          string.equals("\r") ||
          string.isEmpty())) {

The equals conditions are all pointless, thanks to the replacements right before it. You can simplify the if statement to:

    if (!string.isEmpty()) {

Constructing paths

Instead of this:

    FileReader fontReader = new FileReader(inputDir + fileName + extension);

A cleaner way of constructing a path from parent directory and filename:

    FileReader fontReader = new FileReader(new File(inputDir, fileName + extension));

Formatted string output

Complicated string concatenations like this can be hard to read, and annoying to type, interrupting a quoted string to insert variables:

  writer.println(stringOffset + "xy: " + glyph.x + ", " + glyph.y);

Here's an alternative writing style of the same thing that you might consider:

    writer.printf(stringOffset + "xy: %s, %s%n", glyph.x, glyph.y);
\$\endgroup\$
10
\$\begingroup\$

Since you have java 7 available, you should definitely rework the whole File-based code you got there

The first thing you want to start doing is work with java.nio.Path it's much more responsive and clean when compared to some of the strange things you have to do with files.

The next thing you definitely should do is use try-with-resources. Then in addition you don't have to specify generic signatures on the right hand side anymore, use the diamond operator.

After changing to use Path, you should also adapt to the "new way" of doing things and use the convenience methods provided by java.io.Files that make construction of File-based Streams much easier.

So far this gets us to somtething in your FntToAtlasGenerator looking like this:

    List<GlyphData> glyphs = new ArrayList<>();

    public FntToAtlasGenerator(String fileName) throws IOException {

        String inputDir = "input/";
        String outputDir = "output/";
        String extension = ".fnt";
        String atlasExtension = ".atlas";

        String commonLine;
        String pageLine;
        try (BufferedReader reader = Files.newBufferedReader(Paths.get(inputDir, fileName + extension), Charset.forName("UTF-8"))) {
            reader.readLine(); //info line
            commonLine = reader.readLine();
            pageLine = reader.readLine();
            reader.readLine(); //chars line

            String line = reader.readLine();
            while (line != null) {
                this.addLineToGlyphs(line);
                line = reader.readLine();
            }
        }


        try (PrintWriter writer = new PrintWriter(
                Files.newOutputStream(Paths.get(outputDir, fileName + atlasExtension), StandardOpenOption.WRITE))
        ) {

            //values read from .fnt file
            String fileNameForAtlas = this.getFileNameForPageLine(pageLine);
            String size = this.getSizeForCommonLine(commonLine);

            //default values
            String format = "RGBA8888";
            String filter = "Nearest, Nearest";
            String repeat = "none";

            this.writeOpeningLines(writer, fileNameForAtlas, size, format, filter, repeat);

            for (GlyphData glyph : this.glyphs) {
                this.writeGlyph(glyph, writer);
            }
        }
    }

Then again Janos is completely right in his review, saying constructors should be responsible for creating objects. Your constructor is... a big side-effect, basically. It wasn't really hard to completely refactor your FntToAtlasGenerator into the static context, where it can stop pretending to be a class

Oh and one last thing... the last time I checked you could replace these repeated replace statements with a much more expressive .replaceAll:

fragment = fragment.replaceAll("[ \r\n]+", "");

(btw. this is after renaming string to fragment). Also you shouldn't comment out code you don't use anymore. Delete it, Version history got your back :)

\$\endgroup\$
0
\$\begingroup\$

Suggestions

  • Change launcher code to use args to set input and output files
  • Rename Launcher to something that makes more sense from the callers perspective. Fnt2Atlas maybe?
  • Move file reading and writing to a separate part so conversion code is usable w/o specific file system
  • Consider reading Effective Java to improve general code style
\$\endgroup\$
  • 5
    \$\begingroup\$ Care to comment on that "general code style"? What's wrong with OP's? Referring us to a book is tantamount to dropping a link and walking off stage. \$\endgroup\$ – RubberDuck Aug 23 '15 at 16:13
  • 2
    \$\begingroup\$ Although Effective Java sure has a ton of useful recommendations, your comment is too generic to be really useful. Make that last point more specific, or delete it. Also note the high vote count next to the comment asking for more details, the community is showing interest, don't ignore it and you might get some upvotes! \$\endgroup\$ – janos Sep 5 '15 at 18:01

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.