mirror of
https://bjh21.me.uk/bedstead/.git
synced 2026-07-09 13:33:06 -04:00
254 lines
9.6 KiB
Python
Executable File
254 lines
9.6 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
|
|
# Interactive glyph editor for Bedstead.
|
|
#
|
|
# This program was written by Simon Tatham in 2013 and updated by Ben
|
|
# Harris in 2024.
|
|
#
|
|
# Simon Tatham and Ben Harris make this program available under the
|
|
# CC0 Public Domain Dedication.
|
|
|
|
'''Interactive glyph editor for Bedstead.
|
|
|
|
Uses Python/Tk to display a window with a pixel grid on the left side,
|
|
where the user can click or drag to toggle pixels on and off, and on
|
|
the right, shows the output of the Bedstead smoothing algorithm
|
|
applied to that grid of pixels.
|
|
|
|
This is done by running the `bedstead` executable itself to compute
|
|
the smoothed outline, so a copy of that executable is required to use
|
|
this editor.
|
|
|
|
'''
|
|
|
|
import argparse
|
|
import os
|
|
import re
|
|
import sys
|
|
import string
|
|
import subprocess
|
|
import tkinter
|
|
|
|
gutter = 20
|
|
pixel = 32
|
|
XSIZE, YSIZE = 5, 9
|
|
LEFT, TOP = 100, 700 # for transforming coordinates returned from bedstead
|
|
|
|
class EditorGui:
|
|
def __init__(self, bedstead):
|
|
self.bedstead = bedstead
|
|
|
|
self.tkroot = tkinter.Tk()
|
|
|
|
self.canvas = tkinter.Canvas(self.tkroot,
|
|
width=2 * (XSIZE*pixel) + 3*gutter,
|
|
height=YSIZE*pixel + 2*gutter,
|
|
bg='white')
|
|
self.bitmap = [0] * YSIZE
|
|
self.oldbitmap = self.bitmap[:]
|
|
self.pixels = [[None]*XSIZE for y in range(YSIZE)]
|
|
self.polygons = []
|
|
|
|
for x in range(XSIZE+1):
|
|
self.canvas.create_line(gutter + x*pixel, gutter,
|
|
gutter + x*pixel, gutter + YSIZE*pixel)
|
|
for y in range(YSIZE+1):
|
|
self.canvas.create_line(gutter, gutter + y*pixel,
|
|
gutter + XSIZE*pixel, gutter + y*pixel)
|
|
|
|
self.canvas.bind("<Button-1>", self.click)
|
|
self.canvas.bind("<B1-Motion>", self.drag)
|
|
self.canvas.bind("<Button-2>", self.paste)
|
|
self.tkroot.bind("<Key>", self.key)
|
|
self.canvas.pack()
|
|
|
|
def getpixel(self, x, y):
|
|
assert x >= 0 and x < XSIZE and y >= 0 and y < YSIZE
|
|
bit = 1 << (XSIZE-1 - x)
|
|
return self.bitmap[y] & bit
|
|
|
|
def setpixel(self, x, y, state):
|
|
assert x >= 0 and x < XSIZE and y >= 0 and y < YSIZE
|
|
bit = 1 << (XSIZE-1 - x)
|
|
if state and not (self.bitmap[y] & bit):
|
|
self.bitmap[y] |= bit
|
|
self.pixels[y][x] = self.canvas.create_rectangle(
|
|
gutter + x*pixel, gutter + y*pixel,
|
|
gutter + (x+1)*pixel, gutter + (y+1)*pixel,
|
|
fill='black')
|
|
elif not state and (self.bitmap[y] & bit):
|
|
self.bitmap[y] &= ~bit
|
|
self.canvas.delete(self.pixels[y][x])
|
|
self.pixels[y][x] = None
|
|
|
|
def regenerate(self):
|
|
if self.oldbitmap == self.bitmap:
|
|
return
|
|
|
|
self.oldbitmap = self.bitmap[:]
|
|
|
|
for pg in self.polygons:
|
|
self.canvas.delete(pg)
|
|
self.polygons = []
|
|
|
|
data = subprocess.check_output(
|
|
[self.bedstead] + list(map(str, self.bitmap)),
|
|
universal_newlines=True)
|
|
class CharstringInterpreter:
|
|
def __init__(self):
|
|
self.paths = []
|
|
self.path = None
|
|
self.stack = []
|
|
self.cursor = [0, 0]
|
|
self.skip = False
|
|
def rmoveto(self):
|
|
self.path = []
|
|
self.paths.append(self.path)
|
|
self.rlineto()
|
|
def rlineto(self):
|
|
while len(self.stack) >= 2:
|
|
self.cursor[0] += self.stack[0]
|
|
self.cursor[1] += self.stack[1]
|
|
self.stack = self.stack[2:]
|
|
self.path.append(self.cursor[:])
|
|
def op(self, word):
|
|
try:
|
|
if not self.skip:
|
|
self.stack.append(float(word))
|
|
self.skip = False
|
|
except:
|
|
if word == "rmoveto": self.rmoveto()
|
|
elif word == "rlineto": self.rlineto()
|
|
elif word in ("hstem", "vstem"): self.stack = []
|
|
elif word in ("cntrmask", "hintmask"): self.skip = True
|
|
elif word == "endchar": pass
|
|
else:
|
|
print("unknown charstring component " + repr(word))
|
|
interp = CharstringInterpreter()
|
|
data = re.sub(r"<!--(?:[^-]|-[^-])*-->", "", data)
|
|
for word in data.split():
|
|
interp.op(word)
|
|
paths = [[[int((float(x)-LEFT)*pixel*0.01 + 2*gutter + XSIZE*pixel),
|
|
int((TOP - float(y))*pixel*0.01 + gutter)]
|
|
for x, y in path] for path in interp.paths]
|
|
|
|
# The output from 'bedstead' will be a set of disjoint paths,
|
|
# in the Postscript style (going one way around the outside of
|
|
# filled areas, and the other way around internal holes in
|
|
# those areas). Python/Tk doesn't know how to fill an
|
|
# arbitrary path in that representation, so instead we must
|
|
# convert into a set of individual Tk polygons (convex shapes
|
|
# with a single closed outline) and display them in the right
|
|
# order with the right colour.
|
|
#
|
|
# A neat way to arrange this is to compute the area enclosed
|
|
# by each polygon, essentially by integration: for each line
|
|
# segment (x0,y0)-(x1,y1), sum the y difference (y1-y0) times
|
|
# the average x value, which gives the area between that line
|
|
# segment and the corresponding segment of the x-axis. After
|
|
# we go all the way round an outline in this way, we'll have
|
|
# precisely the area enclosed by the outline, no matter how
|
|
# many times it doubles back on itself (because every piece of
|
|
# x-axis has been cancelled out by an outline going back the
|
|
# other way). Furthermore, the sign of the integral we've
|
|
# computed tells us whether the outline goes one way or the
|
|
# other around the area.
|
|
#
|
|
# So then we sort our paths into descending order of the
|
|
# absolute value of its computed area (guaranteeing that any
|
|
# path contained inside another appears after it, since it
|
|
# must enclose a strictly smaller area) and fill each one with
|
|
# a colour based on the area's sign.
|
|
#
|
|
# This strategy depends critically on 'bedstead' having given
|
|
# us sensible paths in the first place: it wouldn't handle an
|
|
# _arbitrary_ PostScript path, with loops allowed to overlap
|
|
# and intersect rather than being neatly nested.
|
|
pathswithmetadata = []
|
|
for path in paths:
|
|
area = 0
|
|
for i in range(len(path)):
|
|
x0, y0 = path[i-1]
|
|
x1, y1 = path[i]
|
|
area += (y1-y0) * (x0+x1)/2
|
|
pathswithmetadata.append([abs(area),
|
|
('black' if area<0 else 'white'),
|
|
path])
|
|
pathswithmetadata.sort(reverse=True)
|
|
|
|
for _, colour, path in pathswithmetadata:
|
|
if len(path) > 1:
|
|
args = sum(path, []) # x,y,x,y,...,x.y
|
|
pg = self.canvas.create_polygon(*args, fill=colour)
|
|
self.polygons.append(pg)
|
|
|
|
def click(self, event):
|
|
for dragstartx in gutter, 2*gutter + XSIZE*pixel:
|
|
x = (event.x - dragstartx) // pixel
|
|
y = (event.y - gutter) // pixel
|
|
if x >= 0 and x < XSIZE and y >= 0 and y < YSIZE:
|
|
self.dragstartx = dragstartx
|
|
self.dragstate = not self.getpixel(x,y)
|
|
self.setpixel(x, y, self.dragstate)
|
|
self.regenerate()
|
|
break
|
|
|
|
def paste(self, event):
|
|
s = self.tkroot.selection_get()
|
|
pat = re.compile("[0-7]+")
|
|
bitmap = []
|
|
for i in range(YSIZE):
|
|
m = pat.search(s)
|
|
if m is None:
|
|
print("Unable to interpret selection data {!r} as a "
|
|
"Bedstead glyph description".format(s))
|
|
return
|
|
bitmap.append(int(m.group(0), 8) & ((1 << XSIZE) - 1))
|
|
s = s[m.end(0):]
|
|
for y in range(YSIZE):
|
|
for x in range(XSIZE):
|
|
self.setpixel(x, y, 1 & (bitmap[y] >> (XSIZE-1 - x)))
|
|
self.regenerate()
|
|
|
|
def drag(self, event):
|
|
x = (event.x - self.dragstartx) // pixel
|
|
y = (event.y - gutter) // pixel
|
|
if 0 <= x < XSIZE and 0 <= y < YSIZE:
|
|
self.setpixel(x, y, self.dragstate)
|
|
self.regenerate()
|
|
|
|
def key(self, event):
|
|
if event.char in (' '):
|
|
bm = "".join(map(lambda n: "\\%02o" % n, self.bitmap))
|
|
print(' {"%s", U() },' % bm)
|
|
elif event.char in ('c','C'):
|
|
for y in range(YSIZE):
|
|
for x in range(XSIZE):
|
|
self.setpixel(x, y, 0)
|
|
self.regenerate()
|
|
elif event.char in ('q','Q','\x11'):
|
|
sys.exit(0)
|
|
|
|
def run(self):
|
|
tkinter.mainloop()
|
|
|
|
def main():
|
|
# By default, assume that the user ran 'make' in the bedstead
|
|
# source directory, so that the 'bedstead' executable is alongside
|
|
# the binary.
|
|
default_executable_path = os.path.join(
|
|
os.path.dirname(os.path.abspath(__file__)), "bedstead")
|
|
|
|
parser = argparse.ArgumentParser(
|
|
description=__doc__,
|
|
formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
parser.add_argument("--bedstead", default=default_executable_path,
|
|
help="Location of the 'bedstead' executable.")
|
|
args = parser.parse_args()
|
|
|
|
editor = EditorGui(args.bedstead)
|
|
editor.run()
|
|
|
|
if __name__ == '__main__':
|
|
main()
|