Skip to content

Instantly share code, notes, and snippets.

@vmedea
Last active March 29, 2021 19:53
Show Gist options
  • Save vmedea/4553b0833871ddd7d0fa8bf32ff00d48 to your computer and use it in GitHub Desktop.
Save vmedea/4553b0833871ddd7d0fa8bf32ff00d48 to your computer and use it in GitHub Desktop.
Font conversion utility (rexpaint)
'''
Font conversion utility (rexpaint).
'''
# Mara Huldra 2021 :: SPDX-License-Identifier: MIT
from collections import namedtuple
from PIL import Image, ImageFont, ImageDraw
# Hardcoded text and background color RGBA, this matches rexpaint's existing fonts
FG_COLOR = (255, 255, 255, 255)
BG_COLOR = (0, 0, 0, 255)
# TODO: Scaling non-square glyphs to square results in some weird shapes, we work around this in different ways
# based on the glyph now.
# There ought to be a better way to scale a font that doesn't result in different horizontal and vertical stroke widths
# e.g. is it possible to scale the coordinates in the vector representation?
# named tuples can be used as a kind of tagged sum type
# Render glyph from the TTF font
PILFontSource = namedtuple('PILFontSource', ['ch', 'scale'])
# Copy glyph from existing font
# 'ch' is in here to retain unicode context information (export a UTF-8 map), it should be optional
ImageFontSource = namedtuple('ImageFontSource', ['ch', 'idx'])
def read_rexpaint_codepage(filename):
with open(filename, 'r') as f:
for line in f:
line = line.split()
num = int(line[0])
ch = int(line[1])
yield (num, ch)
class ImageGrid:
'''Image font (grid) abstraction.'''
def __init__(self, image, chars_x, chars_y, cwidth, cheight):
self.image = image
self.chars_x = chars_x
self.chars_y = chars_y
self.cwidth = cwidth
self.cheight = cheight
@classmethod
def new(cls, chars_x, chars_y, cwidth, cheight):
img = Image.new("RGBA", (cwidth * chars_x, cheight * chars_y), color=BG_COLOR)
return cls(img, chars_x, chars_y, cwidth, cheight)
@classmethod
def load(cls, filename, chars_x, chars_y):
img = Image.open(filename)
assert(img.size[0] % chars_x == 0)
assert(img.size[1] % chars_y == 0)
cwidth = img.size[0] // chars_x
cheight = img.size[1] // chars_y
return cls(img, chars_x, chars_y, cwidth, cheight)
def rect(self, idx):
'''
Return image rectangle for character index idx as (left, upper, right, lower).
'''
chx = (idx % self.chars_x) * self.cwidth
chy = (idx // self.chars_x) * self.cheight
return (chx, chy, chx + self.cwidth, chy + self.cheight)
def __getitem__(self, key):
'''Query substructure.'''
return self.image.crop(self.rect(key))
def __setitem__(self, key, value):
'''Insert substructure.'''
self.image.paste(value, box=self.rect(key))
# source font name
#ttf_file = 'DejaVuSansMono-Bold.ttf'
ttf_file = 'DejaVuSansMono.ttf'
#ttf_file = 'FreeMono.ttf'
# source font points
font_pt = 35
# target font name
fontname = 'xfont'
# target font grid size
chars_x = 16
chars_y = 16 * 3
# target font glyph size
twidth = 20
theight = 20
cp437_map = list(read_rexpaint_codepage('_utf8.txt'))
fullname = f'{fontname}_{twidth}x{theight}'
# ---------------------------------
# Font source per unicode character
# ---------------------------------
font_source = {}
# Existing font: copy from image grid
for (idx, ch) in cp437_map:
font_source[ch] = ImageFontSource(chr(ch), idx)
# Arrows: no scaling
for ch in range(0x2190, 0x2200):
font_source[ch] = PILFontSource(chr(ch), 0)
# Box Drawing
for ch in range(0x2500, 0x2580):
# keep aspect ratio, extend horizontally
font_source[ch] = PILFontSource(chr(ch), 3)
for ch in {0x2504, 0x2505, 0x2508, 0x2509, 0x2571, 0x2572, 0x2573, 0x254c, 0x254d}:
# horizontal dotted lines need to be scaled instead of extended
# otherwise it will look strange
font_source[ch] = PILFontSource(chr(ch), 1)
# Block Elements: use non-uniform scaling
for ch in range(0x2580, 0x25a0):
if ch not in {0x2591, 0x2592, 0x2593}: # exclude shade characters they are better in existing font
font_source[ch] = PILFontSource(chr(ch), 1)
# Geometric Shapes: no scaling
for ch in range(0x25a0, 0x2600):
font_source[ch] = PILFontSource(chr(ch), 0)
# ------------------------------------------------
# Font source per destination font character index
# ------------------------------------------------
chars = {}
for (idx, ch) in cp437_map:
chars[idx] = font_source[ch]
# we simply add the rest at offset 0x100
idx = 0x100
# Arrows
for ch in range(0x2190, 0x2200):
chars[idx] = font_source[ch]
idx += 1
# Box Drawing / Block Elements / Geometric Shapes
for ch in range(0x2500, 0x2600):
chars[idx] = font_source[ch]
idx += 1
# Symbols for Legacy Computing
#for ch in range(0x1FB00, 0x1FC00):
# ------------------------------------------------
# font source: ttf
font = ImageFont.truetype(ttf_file, font_pt)
bbox = font.getbbox('j', anchor='mm')
print(f'Bounding box {bbox}')
cwidth = bbox[2] - bbox[0] - 1
cheight = bbox[3] - bbox[1] + 2
print(f'Estimated character cell size {cwidth}x{cheight}')
assert(cwidth == twidth) # make sure we only scale vertically
# font source: image grid
ifont = ImageGrid.load(f'cp437_{twidth}x{theight}.png', 16, 16)
# font destination: image grid
out = ImageGrid.new(chars_x, chars_y, twidth, theight)
for (idx, source) in chars.items():
if isinstance(source, PILFontSource):
(ch, scale) = source
if scale == 1: # draw at original size then scale
cimage = Image.new("RGBA", (cwidth, cheight), color=BG_COLOR)
draw = ImageDraw.Draw(cimage)
draw.text((cwidth / 2, cheight / 2),
ch, font=font, fill=FG_COLOR, anchor='mm')
cimage = cimage.resize((twidth, theight))
elif scale == 0: # draw in original scale
cimage = Image.new("RGBA", (twidth, theight), color=BG_COLOR)
draw = ImageDraw.Draw(cimage)
#cbbox = font.getbbox(ch, anchor='mm')
chw = bbox[2] - bbox[0]
chh = bbox[3] - bbox[1]
draw.text((twidth / 2 - chw / 2 - bbox[0], theight / 2 - chh / 2 - bbox[1] - 1),
ch, font=font, fill=FG_COLOR, anchor='mm')
cimage = cimage.resize((twidth, theight))
elif scale == 2: # keep aspect ratio
# unused currently
sqs = cheight
cimage = Image.new("RGBA", (sqs, sqs), color=BG_COLOR)
draw = ImageDraw.Draw(cimage)
#cbbox = font.getbbox(ch, anchor='mm')
draw.text((sqs//2, -bbox[1] + 1),
ch, font=font, fill=FG_COLOR, anchor='mm')
cimage = cimage.resize((twidth, theight)) #, Image.NEAREST
elif scale == 3: # keep aspect ratio, extend horizontally
sqs = theight
cimage = Image.new("RGBA", (sqs, sqs), color=BG_COLOR)
draw = ImageDraw.Draw(cimage)
cbbox = font.getbbox(ch, anchor='mm')
base = (sqs//2, sqs//2) #-bbox[1] + 1)
draw.text(base, ch, font=font, fill=FG_COLOR, anchor='mm')
left = base[0] + cbbox[0] + 2
right = base[0] + cbbox[2] - 2
for y in range(0, sqs):
for x in range(0, left):
cimage.putpixel((x, y), cimage.getpixel((left, y)))
for x in range(right + 1, sqs):
cimage.putpixel((x, y), cimage.getpixel((right, y)))
#cimage = cimage.resize((twidth, theight)) #, Image.NEAREST
elif isinstance(source, ImageFontSource):
cimage = ifont[source.idx]
else:
raise NotImplemented
out[idx] = cimage
print(f'Exporting {fullname}…')
# save codepage mapping
with open(f'{fullname}_utf8.txt', 'w') as f:
for (i, source) in chars.items():
f.write(f'{i:3} {ord(source.ch)}\n')
# save font image
out.image.save(f'{fullname}.png')
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment