Initial version not fully tested

pull/3/merge
Peter Hinch 2016-10-26 17:53:37 +01:00
rodzic ae5b00c7d1
commit 3ba89d2e0d
2 zmienionych plików z 539 dodań i 4 usunięć

Wyświetl plik

@ -47,22 +47,23 @@ target display hardware. Their usage should be defined in the documentation for
the device driver.
Example usage to produce a file ``myfont.py`` with height of 23 pixels:
``font_to_py.py FreeSans.ttf 23 -o myfont.py``
``font_to_py.py FreeSans.ttf 23 myfont.py``
## Arguments
### Mandatory arguments:
### Mandatory positional arguments:
1. Font file path. Must be a ttf or otf file.
2. Height in pixels.
3. -o or --outfile Output file path. Must have a .py extension.
3. Output file path. Must have a .py extension.
### Optional arguments:
* -f or --fixed If specified, all characters will have the same width. By
default fonts are assumed to be variable pitch.
* -h Specifies horizontal mapping (default is vertical).
* -x Specifies horizontal mapping (default is vertical).
* -b Specifies bit reversal in each font byte.
* -t Specifies test mode: output file suitable for cPython test programs only.
Optional arguments other than the fixed pitch argument will be specified in the
device driver documentation. Bit reversal is required by some display hardware.
@ -119,6 +120,7 @@ has the following outline definition (in practice the bytes objects are large):
```python
version = '0.1'
test = False
height = 23
width = 22
vmap = True

533
font_to_py.py 100755
Wyświetl plik

@ -0,0 +1,533 @@
#! /usr/bin/python3
# -*- coding: utf-8 -*-
# Needs freetype-py>=1.0
# Some code adapted from Daniel Bader's work at the following URL
# http://dbader.org/blog/monochrome-font-rendering-with-freetype-and-python
# The MIT License (MIT)
#
# Copyright (c) 2016 Peter Hinch
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
# THE SOFTWARE.
import freetype
import argparse
import sys
import os
# UTILITIES FOR WRITING PYTHON SOURCECODE TO A FILE
# ByteWriter takes as input a variable name and data values and writes
# Python source to an output stream of the form
# my_variable = b'\x01\x02\x03\x04\x05\x06\x07\x08'\
# Lines are broken with \ for readability.
class ByteWriter(object):
bytes_per_line = 8
def __init__(self, stream, varname):
self.stream = stream
self.stream.write(''.join((varname, ' = ')))
self.bytecount = 0 # For line breaks
self.total_bytes = 0
def _eol(self):
self.stream.write("'\\\n")
def _eot(self):
self.stream.write("'\n")
def _bol(self):
self.stream.write("b'")
# Output a single byte
def obyte(self, data):
if not self.bytecount:
self._bol()
self.stream.write('\\x{:02x}'.format(data))
self.total_bytes += 1
self.bytecount += 1
self.bytecount %= self.bytes_per_line
if not self.bytecount:
self._eol()
# Output from a sequence
def odata(self, bytelist):
for b in bytelist:
self.obyte(b)
# Output words of arbitrary length litle-endian
def owords(self, words, length=2):
for data in words:
for _ in range(length):
self.obyte(data & 0xff)
data >>= 8
# ensure a correct final line
def eot(self): # User force EOL if one hasn't occurred
if self.bytecount:
self._eot()
self.stream.write('\n')
def bytes_written(self):
return self.total_bytes
# Define a global
def var_write(stream, name, value):
stream.write('{} = {}\n'.format(name, value))
# FONT HANDLING
class Bitmap(object):
"""
A 2D bitmap image represented as a list of byte values. Each byte indicates
the state of a single pixel in the bitmap. A value of 0 indicates that the
pixel is `off` and any other value indicates that it is `on`.
"""
def __init__(self, width, height, pixels=None):
self.width = width
self.height = height
self.pixels = pixels or bytearray(width * height)
def display(self):
"""Print the bitmap's pixels."""
for y in range(self.height):
for x in range(self.width):
ch = '#' if self.pixels[y * self.width + x] else '.'
print(ch, end='')
print()
print()
def bitblt(self, src, y):
"""Copy all pixels from `src` into this bitmap"""
srcpixel = 0
dstpixel = y * self.width
row_offset = self.width - src.width
for sy in range(src.height):
for sx in range(src.width):
self.pixels[dstpixel] = src.pixels[srcpixel]
srcpixel += 1
dstpixel += 1
dstpixel += row_offset
# Horizontal mapping generator function
def get_hbyte(self, reverse):
for y in range(self.height):
x = 0
while True:
bit = x % 8
if bit == 0:
if x >= self.width:
break
byte = 0
if x < self.width:
if reverse:
byte |= self.pixels[y * self.width + x] << bit
else:
# Normal map MSB of byte 0 is (0, 0)
byte |= self.pixels[y * self.width + x] << (7 - bit)
if bit == 7:
yield byte
x += 1
# Vertical mapping
def get_vbyte(self, reverse):
for x in range(self.width):
y = 0
while True:
bit = y % 8
if bit == 0:
if y >= self.height:
break
byte = 0
if y < self.height:
if reverse:
byte |= self.pixels[y * self.width + x] << (7 - bit)
else:
# Normal map MSB of byte 0 is (0, 7)
byte |= self.pixels[y * self.width + x] << bit
if bit == 7:
yield byte
y += 1
class Glyph(object):
def __init__(self, pixels, width, height, top, advance_width):
self.bitmap = Bitmap(width, height, pixels)
# The glyph bitmap's top-side bearing, i.e. the vertical distance from
# the baseline to the bitmap's top-most scanline.
self.top = top
# Ascent and descent determine how many pixels the glyph extends
# above or below the baseline.
self.descent = max(0, self.height - self.top)
self.ascent = max(0, max(self.top, self.height) - self.descent)
# The advance width determines where to place the next character
# horizontally, that is, how many pixels we move to the right to
# draw the next glyph.
self.advance_width = advance_width
@property
def width(self):
return self.bitmap.width
@property
def height(self):
return self.bitmap.height
@staticmethod
def from_glyphslot(slot):
"""Construct and return a Glyph object from a FreeType GlyphSlot."""
pixels = Glyph.unpack_mono_bitmap(slot.bitmap)
width, height = slot.bitmap.width, slot.bitmap.rows
top = slot.bitmap_top
# The advance width is given in FreeType's 26.6 fixed point format,
# which means that the pixel values are multiples of 64.
advance_width = slot.advance.x / 64
return Glyph(pixels, width, height, top, advance_width)
@staticmethod
def unpack_mono_bitmap(bitmap):
"""
Unpack a freetype FT_LOAD_TARGET_MONO glyph bitmap into a bytearray
where each pixel is represented by a single byte.
"""
# Allocate a bytearray of sufficient size to hold the glyph bitmap.
data = bytearray(bitmap.rows * bitmap.width)
# Iterate over every byte in the glyph bitmap. Note that we're not
# iterating over every pixel in the resulting unpacked bitmap --
# we're iterating over the packed bytes in the input bitmap.
for y in range(bitmap.rows):
for byte_index in range(bitmap.pitch):
# Read the byte that contains the packed pixel data.
byte_value = bitmap.buffer[y * bitmap.pitch + byte_index]
# We've processed this many bits (=pixels) so far. This
# determines where we'll read the next batch of pixels from.
num_bits_done = byte_index * 8
# Pre-compute where to write the pixels that we're going
# to unpack from the current byte in the glyph bitmap.
rowstart = y * bitmap.width + byte_index * 8
# Iterate over every bit (=pixel) that's still a part of the
# output bitmap. Sometimes we're only unpacking a fraction of
# a byte because glyphs may not always fit on a byte boundary.
# So we make sure to stop if we unpack past the current row
# of pixels.
for bit_index in range(min(8, bitmap.width - num_bits_done)):
# Unpack the next pixel from the current glyph byte.
bit = byte_value & (1 << (7 - bit_index))
# Write the pixel to the output bytearray. We ensure that
# `off` pixels have a value of 0 and `on` pixels have a
# value of 1.
data[rowstart + bit_index] = 1 if bit else 0
return data
# A Font object is a dictionary of ASCII chars indexed by a character e.g.
# myfont['a']
# Each entry comprises a list
# [0] A Bitmap instance containing the character
# [1] The width of the character data including advance (actual data stored)
# Public attributes:
# height (in pixels) of all characters
# width (in pixels) for monospaced output (advance width of widest char)
class Font(dict):
charset = [chr(x) for x in range(32, 127)]
def __init__(self, filename, size, monospaced=False):
self._face = freetype.Face(filename)
self._face.set_pixel_sizes(0, size)
self._max_descent = 0
# For each character in the charset string we get the glyph
# and update the overall dimensions of the resulting bitmap.
max_width = 0
max_ascent = 0
for char in self.charset:
glyph = self._glyph_for_character(char)
max_ascent = max(max_ascent, int(glyph.ascent))
self._max_descent = max(self._max_descent, int(glyph.descent))
# for a few chars e.g. _ glyph.width > glyph.advance_width
max_width = int(max(max_width, glyph.advance_width, glyph.width))
self.height = max_ascent + self._max_descent
self.width = max_width if monospaced else 0
for char in self.charset:
self._render_char(char)
def _glyph_for_character(self, char):
# Let FreeType load the glyph for the given character and tell it to
# render a monochromatic bitmap representation.
self._face.load_char(char, freetype.FT_LOAD_RENDER |
freetype.FT_LOAD_TARGET_MONO)
return Glyph.from_glyphslot(self._face.glyph)
def _render_char(self, char):
glyph = self._glyph_for_character(char)
if self.width: # Monospaced
width = self.width
else:
width = int(max(glyph.width, glyph.advance_width))
outbuffer = Bitmap(width, self.height)
# The vertical drawing position should place the glyph
# on the baseline as intended.
y = self.height - int(glyph.ascent) - self._max_descent
outbuffer.bitblt(glyph.bitmap, y)
self[char] = [outbuffer, width]
def _stream_char(self, char, hmap, reverse):
outbuffer, width = self[char]
if hmap:
gen = outbuffer.get_hbyte(reverse)
else:
gen = outbuffer.get_vbyte(reverse)
yield from gen
def build_arrays(self, hmap, reverse):
data = bytearray()
index = bytearray((0, 0))
for char in self.charset:
width = self[char][1]
data += (width).to_bytes(2, byteorder='little')
data += bytearray(self._stream_char(char, hmap, reverse))
index += (len(data)).to_bytes(2, byteorder='little')
return data, index
# PYTHON FILE WRITING
str01 = """# Code generated by font-to-py.py.
# Font: {}
version = '0.1'
"""
str02 = """
from uctypes import addressof
def _chr_addr(ordch):
offset = 2 * (ordch - 32)
return int.from_bytes(_index[offset:offset + 2], 'little')
def get_ch(ch):
ordch = ord(ch)
ordch = ordch if ordch >= 32 and ordch <= 126 else ord('?')
offset = _chr_addr(ordch)
width = int.from_bytes(_font[offset:offset + 2], 'little')
return addressof(_font) + offset + 2, height, width
"""
# Test mode get_ch returns a slice rather than an address
str03 = """
def _chr_addr(ordch):
offset = 2 * (ordch - 32)
return int.from_bytes(_index[offset:offset + 2], 'little')
def get_ch(ch):
ordch = ord(ch)
ordch = ordch if ordch >= 32 and ordch <= 126 else ord('?')
offset = _chr_addr(ordch)
width = int.from_bytes(_font[offset:offset + 2], 'little')
next_offs = _chr_addr(ordch +1)
return _font[offset + 2:next_offs], height, width
"""
def write_font(op_path, font_path, height, monospaced, hmap, reverse, test):
try:
fnt = Font(font_path, height, monospaced)
except freetype.ft_errors.FT_Exception:
print("Can't open", font_path)
return False
try:
with open(op_path, 'w') as stream:
write_data(stream, fnt, font_path, height,
monospaced, hmap, reverse, test)
except OSError:
print("Can't open", op_path, 'for writing')
return False
return True
def write_data(stream, fnt, font_path, height,
monospaced, hmap, reverse, test):
stream.write(str01.format(os.path.split(font_path)[1]))
var_write(stream, 'test', test)
var_write(stream, 'height', height)
var_write(stream, 'width', fnt.width)
var_write(stream, 'vmap', not hmap)
var_write(stream, 'reversed', reverse)
data, index = fnt.build_arrays(hmap, reverse)
bw_font = ByteWriter(stream, '_font')
bw_font.odata(data)
bw_font.eot()
bw_index = ByteWriter(stream, '_index')
bw_index.odata(index)
bw_index.eot()
strfinal = str03 if test else str02
stream.write(strfinal)
# ******************* TESTS *******************
def display_hmap(ba, height, width, reverse):
bytes_per_row = width // 8 + 1
for bitnum in range(height * width):
row, bn = divmod(bitnum, width)
if bn == 0:
print()
byte = ba[row * bytes_per_row + bn // 8]
if reverse:
bit = (byte & (1 << (bn % 8))) > 0
else:
bit = (byte & (1 << (7 - (bn % 8)))) > 0
ch = '#' if bit else '.'
print(ch, end='')
print()
print(height, width)
def display_vmap(ba, height, width, reverse):
bytes_per_col = height // 8 + 1
for row in range(height):
for col in range(width):
byte = ba[col * bytes_per_col + row // 8]
if reverse:
bit = (byte & (1 << (7 - (row % 8)))) > 0
else:
bit = (byte & (1 << (row % 8))) > 0
ch = '#' if bit else '.'
print(ch, end='')
print()
print(height, width)
def display(g, hmap, height, width, reverse):
if hmap:
display_hmap(g, height, width, reverse)
else:
display_vmap(g, height, width, reverse)
def test1(string, height, monospaced, hmap, reverse):
fnt = Font("FreeSans.ttf", height, monospaced)
height = fnt.height
for char in string:
width = fnt[char][1]
g = bytearray(fnt._stream_char(char, hmap, reverse))
display(g, hmap, height, width, reverse)
def chr_addr(index, ordch):
offset = 2 * (ordch - 32)
return int.from_bytes(index[offset:offset + 2], 'little')
def test(string, height, monospaced, hmap, reverse):
fnt = Font("FreeSans.ttf", height, monospaced)
height = fnt.height
data, index = fnt.build_arrays(hmap, reverse)
for char in string:
ordch = ord(char)
offset = chr_addr(index, ordch)
width = int.from_bytes(data[offset:offset + 2], 'little')
offset += 2
next_offs = chr_addr(index, ordch + 1)
display(data[offset:next_offs], hmap, height, width, reverse)
# usage testfile('FreeSans','xyz')
def testfile(fontfile, string):
import importlib
myfont = importlib.import_module(fontfile)
for ch in string:
data, height, width = myfont.get_ch(ch)
display(data, not myfont.vmap, height, width, myfont.reversed)
def bar():
# Number indicates height, in practice can be one less i.e. 36->35 rows
fnt = Font("FreeSans.ttf", 20)
for ch in 'WM_eg!.,':
fnt[ch][0].display()
print(fnt.width)
# test('|_g.AW', height = 20, monospaced = True, hmap = False, reverse = False)
# PARSE COMMAND LINE ARGUMENTS
desc = """font_to_py.py
Utility to convert ttf or otf font files to Python source.
Sample usage:
font_to_py.py FreeSans.ttf 23 freesans.py
This creates a font with nominal height 23 pixels. To specify monospaced
rendering issue
font_to_py.py FreeSans.ttf 23 --fixed freesans.py
"""
if __name__ == "__main__":
parser = argparse.ArgumentParser(__file__, description=desc,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('infile', type=str, help='input file path')
parser.add_argument('height', type=int, help='font height in pixels')
parser.add_argument('-x', '--xmap', action='store_true',
help='horizontal (x) mapping')
parser.add_argument('-r', '--reverse', action='store_true',
help='bit reversal')
parser.add_argument('-f', '--fixed', action='store_true',
help='Fixed width (monospaced) font')
parser.add_argument('-t', '--test', action='store_true',
help='Test file: import from cPython')
parser.add_argument('outfile', type=str,
help='Path and name of output file')
args = parser.parse_args()
if not args.infile[0].isalpha():
print('Font filenames must be valid Python variable names.')
sys.exit(1)
if not os.path.isfile(args.infile):
print("Font filename does not exist")
sys.exit(1)
if not os.path.splitext(args.infile)[1].upper() in ('.TTF', '.OTF'):
print("Font file should be a ttf or otf file.")
sys.exit(1)
if not os.path.splitext(args.outfile)[1].upper() == '.PY':
print("Output filename should have a .py extension.")
sys.exit(1)
print(args.infile, args.outfile, args.reverse, args.xmap)
if not write_font(args.outfile, args.infile, args.height, args.fixed,
args.xmap, args.reverse, args.test):
sys.exit(1)