Last active
March 29, 2021 19:53
-
-
Save vmedea/4553b0833871ddd7d0fa8bf32ff00d48 to your computer and use it in GitHub Desktop.
Font conversion utility (rexpaint)
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
''' | |
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