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 TextureRegion
s 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.
4 Answers 4
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.
-
2\$\begingroup\$ I just confirmed that this does work! I appreciate you showing me a new libGDX trick :) \$\endgroup\$bazola– bazola2015年08月25日 20:52:25 +00:00Commented Aug 25, 2015 at 20:52
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);
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 :)
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
-
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– RubberDuck2015年08月23日 16:13:05 +00:00Commented Aug 23, 2015 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– janos2015年09月05日 18:01:11 +00:00Commented Sep 5, 2015 at 18:01
TextureRegion charImage = (new com.badlogic.gdx.graphics.g2d.BitmapFont(Gdx.files.internal("data/myfile.bmf").getRegion(character)
? \$\endgroup\$