Browse Source

Added BDF font parser (#794)

projectitis 5 years ago
parent
commit
0c243379ec
2 changed files with 414 additions and 0 deletions
  1. 413 0
      hxd/res/BDFFont.hx
  2. 1 0
      hxd/res/Config.hx

+ 413 - 0
hxd/res/BDFFont.hx

@@ -0,0 +1,413 @@
+package hxd.res;
+
+using StringTools;
+
+/**
+ * Intermediate representation of a glyph. Only used while
+ * parsing a BDF font file.
+ */
+ class BDFFontChar {
+	public var code : Int;
+	public var x : Int;
+	public var y : Int;
+	public var width : Int;
+	public var height : Int;
+	public var xoffset : Int;
+	public var yoffset : Int;
+	public var stride : Int;
+	public var bits : Array<Int>;
+
+	public function new( code, width, height, xoffset, yoffset, stride ) {
+		this.code = code;
+		this.width = width;
+		this.height = height;
+		this.xoffset = xoffset;
+		this.yoffset = yoffset;
+		this.stride = stride;
+		this.bits = new Array();
+	}
+	
+	static public function sortOnHeight( a : BDFFontChar, b : BDFFontChar ) {
+		return b.height - a.height; // Largest first
+	}
+}
+
+/**
+ * Parse BDF font format to h2d.Font
+ */
+class BDFFont extends Resource {
+
+	static inline var BitmapPad : Float = 0.1;
+	static inline var BitmapMaxWidth : Int = 1024;
+	static inline var ClearColor : Int = 0x000000FF;
+	static inline var PixelColor : Int = 0x00FFFFFF;
+
+	var font : h2d.Font;
+	var bitsPerPixel : Int = 1;
+	var ascent : Int = -1;
+	var descent : Int = -1;
+	var fbbHeight : Int = -1;
+	var glyphData : Array<BDFFontChar>;
+
+	/**
+	 * Convert BDF resource to a h2d.Font instance
+	 * @return h2d.Font The font
+	 */
+	@:access(h2d.Font)
+	public function toFont() : h2d.Font {
+		if ( font != null ) return font;
+		
+		// File starts with STARTFONT
+		if ( (entry.getBytes().getInt32(0) != 0x52415453) || (entry.getBytes().getInt32(8) != 0x2E322054) )
+			throw 'File does not appear to be a BDF file. Expecting STARTFONT';
+
+		// Init empty font
+		font = new h2d.Font( null, 0 );
+
+		// Break file into lines
+		var lines = entry.getBytes().toString().split("\n");
+		var linenum = 0;
+
+		// Parse the header
+		linenum = parseFontHeader( lines, linenum );
+		// Parse the glyphs
+		linenum = parseGlyphs( lines, linenum );
+		// Generate glyphs and bitmap
+		generateGlyphs();
+
+		// Return the generated font
+		return font;
+	}
+	
+	/**
+	 * Extract what we can from the font header. Unlike other font formats supported by heaps, some
+	 * of the values need to be infered from what is given (e.g. line height is not specificed directly,
+	 * nor is baseline).
+	 * @param lines		The remaining lines in the file
+	 * @param linenum	The current line number
+	 * @return Int		The final line number after processing header
+	 */
+	@:access(h2d.Font)
+	function parseFontHeader( lines : Array<String>, linenum : Int ) : Int {
+		var line : String;
+		var prop : String;
+		var args : Array<String>;
+
+		// Iterate lines
+		while ( lines.length > 0 ) {
+			linenum++;
+
+			line = lines.shift();
+			args = line.trim().split(" ");
+			if ( args.length == 0 ) continue;
+			prop = args.shift().trim();
+
+			switch ( prop ) {
+				case 'FAMILY_NAME':
+					font.name = extractStr( args );
+				case 'SIZE':
+					font.size = font.initSize = extractInt( args[0] );
+				case 'BITS_PER_PIXEL':
+					this.bitsPerPixel = extractInt( args[0] );
+					if ( [1,2,4,8].indexOf( bitsPerPixel ) != -1 ) throw 'BITS_PER_PIXEL of $bitsPerPixel not supported, at line $linenum';
+				case 'FONTBOUNDINGBOX':
+					this.fbbHeight = extractInt( args[1] );
+				case 'FONT_ASCENT':
+					this.ascent = extractInt( args[0] );
+				case 'FONT_DESCENT':
+					this.descent = extractInt( args[0] );
+				// Once we find STARTCHAR we know that the header is done. Stop processing lines and continue.
+				case 'STARTCHAR':
+					break;
+			}
+		}
+		// Check we have everything we need
+		if ( font.initSize == 0 ) throw 'SIZE not found or is 0';
+
+		// Return linenum we are up to
+		return linenum;
+	}
+
+	/**
+	 * Extract glyph information from the file.
+	 * @param lines		The remaining lines in the file
+	 * @param linenum	The current line number
+	 * @return Int		The final line number after processing header
+	 */
+	function parseGlyphs( lines : Array<String>, linenum : Int ) : Int {
+		var line : String;
+		var prop : String;
+		var args : Array<String>;
+
+		this.glyphData = new Array(); // Destroyed after generating bitmap
+
+		var processingGlyphHeader : Bool = true;
+		var encoding : Int = -1;
+		var stride : Int = -1;
+		var bbxFound : Bool = false;
+		var bbxWidth : Int = 0;
+		var bbxHeight : Int = 0;
+		var bbxXOffset : Int = 0;
+		var bbxYOffset : Int = 0;
+		var expectedLines : Int = 0;
+		var expectedBytesPerLine : Int = 0;
+		var char : BDFFontChar = null;
+
+		// Iterate lines
+		while ( lines.length > 0 ) {
+			linenum++;
+
+			line = lines.shift();
+			args = line.trim().split(" ");
+			if ( args.length == 0 ) continue;
+			prop = args.shift().trim();
+
+			// Start by processing the glyph header
+			if ( processingGlyphHeader ) {
+				switch ( prop ) {
+					case 'ENCODING':
+						// XXX: Support encoding ranges. Not sure if they are common? Hacen't come across this yet
+						if ( encoding != -1 ) throw 'Encoding ranges not supported, at line $linenum';
+						encoding = extractInt( args[0] );
+						if ( encoding < 1 ) throw 'ENCODING $encoding not supported, at line $linenum';
+					case 'DWIDTH': // Device width
+						stride = extractInt( args[0] );
+						if ( stride < 0 ) throw 'DWIDTH is negative, at line $linenum';
+						// XXX: This could be a warning and we could ignore the vertical step. Font might render weird?
+						if ( extractInt( args[1] ) != 0 ) throw 'A non-0 DWIDTH is not supported (maybe vertical character set?), at line $linenum';
+					case 'BBX': // Bounding box
+						bbxFound = true;
+						bbxWidth = extractInt( args[0] );
+						bbxHeight = extractInt( args[1] );
+						bbxXOffset = extractInt( args[2] );
+						bbxYOffset = extractInt( args[3] );
+						if ( bbxWidth < 0 ) throw 'BBX width is negative, line $linenum';
+						if ( bbxHeight < 0 ) throw 'BBX height is negative, line $linenum';
+					case 'BITMAP':
+						if ( encoding < 0 ) throw 'missing ENCODING, line $linenum';
+						if ( stride < 0 ) throw 'missing DWIDTH, line $linenum';
+						if ( !bbxFound ) throw 'missing BBX, line $linenum';
+						processingGlyphHeader = false;
+						expectedLines = bbxHeight;
+						expectedBytesPerLine = ( (bbxWidth * bitsPerPixel) + 7 ) >> 3;
+						char = new BDFFontChar( encoding, bbxWidth, bbxHeight, bbxXOffset, bbxYOffset, stride );
+				}
+			} // header
+
+			// Secondly, extract the bitmap data (will be processed later)
+			else {
+				// Extract data as long as we are still expecting some
+				if ( (expectedLines > 0) && (expectedBytesPerLine > 0) ) {
+					for ( i in 0...expectedBytesPerLine ) {
+						char.bits.push( extractInt( '0x' + prop.substr( 0, 2 ) ) );
+						prop = prop.substr( 2 );
+					}
+					expectedLines--;
+				}
+				// Otherwise we have finished reading bitmap data
+				else {
+					// Sanity check
+					if ( prop != 'ENDCHAR' ) throw 'ENDCHAR expected, line $linenum';
+					// Save the glyph data
+					this.glyphData.push( char );
+					// reset for next glyph
+					processingGlyphHeader = true;
+					encoding = -1;
+					stride = -1;
+					bbxFound = false;
+				}
+			} // glyphs
+
+		} // lines
+		
+		// Return linenum we are up to
+		return linenum;
+	}
+
+	/**
+	 * We now have all glyphs and (unprocessed) bitmap data. We need to generate the bitmap in
+	 * an efficient manner. This involves estimating the size of the canvas we need, and then
+	 * tiling the glyphs into the bitmap.
+	 */
+	@:access(h2d.Font)
+	function generateGlyphs() {
+		// Firstly, sort glyphData by height
+		glyphData.sort( BDFFontChar.sortOnHeight );
+
+		// Calculate total volume, and from that an approx width and height if packing with 80%
+		// efficiency (i.e. add a 10% buffer). This is from trial and error :)
+		var volume : Int = 0;
+		for ( d in glyphData ) volume += ( d.width * d.height );
+		var bitmapWidth : Int = Math.ceil( Math.sqrt( volume * (1 + BitmapPad) ) );
+		if ( bitmapWidth > BitmapMaxWidth ) throw 'The font bitmap is too big: ${bitmapWidth}x${bitmapWidth} (max ${BitmapMaxWidth}x${BitmapMaxWidth})';
+		
+		// Create the bitmap
+		var bitmapData : hxd.BitmapData = new hxd.BitmapData( bitmapWidth, bitmapWidth );
+		bitmapData.lock();
+		bitmapData.clear( ClearColor ); // Blue, but transparent
+
+		// Calculate values for extracting pixel data
+		var bppMask : Int = 0x80;
+		switch ( bitsPerPixel ) {
+			case 2: bppMask = 0xC0;
+			case 4: bppMask = 0xF0;
+			case 8: bppMask = 0xFF;
+		}
+		var pixPerByte : Int = Math.floor( 8 / bitsPerPixel );
+		var bppScale : Float = 255 / ((1 << bitsPerPixel) - 1);
+		var pixLeftInByte : Int = 0;
+		var pixBits : Int = 0;
+		var pixAlpha : Int = 0;
+
+		// Draw glyphs to bitmap in height order and save position on bitmap
+		var x : Int = 0;
+		var y : Int = 0;
+		var found : Bool = false;
+		for ( d in glyphData ) {
+			found = false;
+
+			// Wrap x if glyph will not fit in width
+			if ( ( x + d.width ) > bitmapWidth ) x = 0;
+
+			// Find nearest space big enough for glyph, left to right, top to bottom
+			while ( x <= (bitmapWidth - d.width) ) {
+				y = 0;
+				while ( y <= (bitmapWidth - d.height) ) {
+					// If top-left pixel is clear...
+					if ( bitmapData.getPixel( x, y ) == ClearColor ) {
+						found = true;
+						// Check first row and first column are clear to ensure space is clear
+						for ( xx in x...(x + d.width) ) {
+							if ( bitmapData.getPixel( xx, y ) != ClearColor ) {
+								found = false;
+								break;
+							}
+						}
+						if ( found ) {
+							for ( yy in y...(y + d.height) ) {
+								if ( bitmapData.getPixel( x, yy ) != ClearColor ) {
+									found = false;
+									break;
+								}
+							}
+						}
+						if ( found ) break;
+					}
+					y++;
+				}
+				if ( found ) break;
+				x++;
+			}
+
+			// XXX: At this point it would be really good to see the bitmap (so far)
+			if ( !found ) throw 'Glyphs are overflowing the bitmap. Help!';
+
+			// Now have space that starts at x,y.
+			// Draw the glyph to the bitmap and save position
+			d.x = x; d.y = y;
+			for ( yy in y...(y + d.height) ) {
+				pixLeftInByte = 0;
+				for ( xx in x...(x + d.width) ) {
+					// Grab a new byte
+					if ( pixLeftInByte == 0 ) {
+						pixLeftInByte = pixPerByte;
+						pixBits = d.bits.shift();
+					}
+					// Grab a pixel alpha
+					pixAlpha = (pixBits & bppMask) >> (8 - bitsPerPixel);
+					pixBits = pixBits << bitsPerPixel;
+					// Calculate actual pixel value and set pixel
+					pixAlpha = Math.floor( pixAlpha * bppScale ) << 24;
+					bitmapData.setPixel( xx, yy, pixAlpha | PixelColor );
+					// Advance
+					pixLeftInByte--;
+				}
+			}
+
+			// Advance the start position to after the bitmap
+			x += d.width;
+		}
+		bitmapData.unlock();
+
+		// Create tile from bitmap data
+		font.tile = h2d.Tile.fromBitmap( bitmapData );
+
+		// Generate glyphs
+		for ( d in glyphData ) {
+			// In BDF, y-offset is offset from baseline. In FNT it appears to be offset from top
+			var t = font.tile.sub( d.x, d.y, d.width, d.height, d.xoffset, ascent - (d.height + d.yoffset) );
+			var fc = new h2d.Font.FontChar( t, d.stride );
+			font.glyphs.set( d.code, fc );
+		}
+
+		// Ensure space character exists
+		if( font.glyphs.get( " ".code ) == null )
+			font.glyphs.set( " ".code, new h2d.Font.FontChar( font.tile.sub( 0, 0, 0, 0 ), font.size >> 1 ) );
+
+		// Set line height, or estimate it
+		if ( (ascent >= 0) && (descent >= 0) )
+			font.lineHeight = ascent + descent;
+		else if ( fbbHeight >= 0 )
+			font.lineHeight = fbbHeight;
+		else{
+			var a = font.glyphs.get( "E".code );
+			if ( a == null )
+				a = font.glyphs.get( "A".code );
+			if ( a == null )
+				font.lineHeight = font.size * 2;
+			else
+				font.lineHeight = a.t.height * 2; 
+		}
+		
+		// Estimate a baseline
+		var space = font.glyphs.get( " ".code );
+		var padding : Float = ( space.t.height * .5 );
+		var a = font.glyphs.get( "A".code );
+		if( a == null ) a = font.glyphs.get( "a".code );
+		if( a == null ) a = font.glyphs.get( "0".code ); // numerical only
+		if( a == null )
+			font.baseLine = font.lineHeight - 2 - padding;
+		else
+			font.baseLine = a.t.dy + a.t.height - padding;
+
+		// Set a fallback glyph
+		var fallback = font.glyphs.get( 0xFFFD ); // <?>
+		if( fallback == null )
+			fallback = font.glyphs.get( 0x25A1 ); // square
+		if( fallback == null )
+			fallback = font.glyphs.get( "?".code );
+		if( fallback == null )
+			fallback = font.glyphs.get( " ".code );
+		font.defaultChar = fallback;
+		
+		// Cleanup
+		bitmapData.dispose();
+		this.glyphData = null; // No longer required
+	}
+
+	/**
+	 * Each line of the BDF file is split by spaces into an Array. Sometimes the
+	 * line is actually a string and shouldn't be split. This method detects that
+	 * by checking for "quote marks" and joining the string back up. 
+	 * @param p The split line
+	 * @return String The resulting string
+	 */
+	inline function extractStr( p : Array<String> ) : String {
+		if ( p[0].startsWith("\"") ) {
+			var pj : String = p.join(" ").trim();
+			return pj.substring( 1, pj.length - 1 );
+		}
+		return p[0];
+	}
+
+	/**
+	 * Extract an integer from a string. if the string starts with 0x it is treated
+	 * as hexidecimal, otherwise decimal.
+	 * @param s     The string
+	 * @return Int  The resulting integer
+	 */
+	inline function extractInt( s : String ) : Int {
+		return Std.parseInt( s );
+	}
+
+}

+ 1 - 0
hxd/res/Config.hx

@@ -21,6 +21,7 @@ class Config {
 		"fbx,hmd" => "hxd.res.Model",
 		"ttf" => "hxd.res.Font",
 		"fnt" => "hxd.res.BitmapFont",
+		"bdf" => "hxd.res.BDFFont",
 		"wav,mp3,ogg" => "hxd.res.Sound",
 		"tmx" => "hxd.res.TiledMap",
 		"atlas" => "hxd.res.Atlas",