1#
2# Copyright (c) 2018 Dimitar Toshkov Zhekov <dimitar.zhekov@gmail.com>
3#
4# This program is free software; you can redistribute it and/or
5# modify it under the terms of the GNU General Public License as
6# published by the Free Software Foundation; either version 2 of
7# the License, or (at your option) any later version.
8#
9# This program is distributed in the hope that it will be useful,
10# but WITHOUT ANY WARRANTY; without even the implied warranty of
11# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12# GNU General Public License for more details.
13#
14
15import re
16import codecs
17from enum import IntEnum, unique
18
19import fnutil
20
21
22WIDTH_MAX = 127
23HEIGHT_MAX = 255
24SWIDTH_MAX = 32000
25
26class Width:
27	def __init__(self, x, y):
28		self.x = x
29		self.y = y
30
31
32	@staticmethod
33	def _parse(name, value, limit_x, limit_y):
34		words = fnutil.split_words(name, value, 2)
35		return Width(fnutil.parse_dec('width x', words[0], -limit_x, limit_x),
36			fnutil.parse_dec('width y', words[1], -limit_y, limit_y))
37
38
39	@staticmethod
40	def parse_s(value):
41		return Width._parse('SWIDTH', value, SWIDTH_MAX, SWIDTH_MAX)
42
43
44	@staticmethod
45	def parse_d(value):
46		return Width._parse('DWIDTH', value, WIDTH_MAX, HEIGHT_MAX)
47
48
49OFFSET_MIN = -128
50OFFSET_MAX = 127
51
52class BBX:
53	def __init__(self, width, height, xoff, yoff):
54		self.width = width
55		self.height = height
56		self.xoff = xoff
57		self.yoff = yoff
58
59
60	@staticmethod
61	def parse(name, value):
62		words = fnutil.split_words(name, value, 4)
63		return BBX(fnutil.parse_dec('width', words[0], 1, WIDTH_MAX),
64			fnutil.parse_dec('height', words[1], 1, HEIGHT_MAX),
65			fnutil.parse_dec('bbxoff', words[2], -WIDTH_MAX, WIDTH_MAX),
66			fnutil.parse_dec('bbyoff', words[3], -WIDTH_MAX, WIDTH_MAX))
67
68
69	def row_size(self):
70		return (self.width + 7) >> 3
71
72
73	def __str__(self):
74		return '%d %d %d %d' % (self.width, self.height, self.xoff, self.yoff)
75
76
77class Props:
78	def __init__(self):
79		self.names = []
80		self.values = []
81
82
83	def add(self, name, value):
84		self.names.append(name)
85		self.values.append(value)
86
87
88	def clone(self):
89		props = Props()
90		props.names = self.names[:]
91		props.values = self.values[:]
92		return props
93
94
95	def get(self, name):
96		try:
97			return self.values[self.names.index(name)]
98		except ValueError:
99			return None
100
101
102	class Iter:
103		def __init__(self, props):
104			self.index = 0
105			self.props = props
106
107
108		def __next__(self):
109			if self.index == len(self.props.names):
110				raise StopIteration
111
112			result = (self.props.names[self.index], self.props.values[self.index])
113			self.index += 1
114			return result
115
116
117	def __iter__(self):
118		return Props.Iter(self)
119
120
121	def parse(self, line, name, callback=None):
122		if not line or not line.startswith(bytes(name, 'ascii')):
123			raise Exception(name + ' expected')
124
125		value = line[len(name):].lstrip()
126		self.add(name, value)
127		return value if callback is None else callback(name, value)
128
129
130	def set(self, name, value):
131		try:
132			self.values[self.names.index(name)] = value
133		except ValueError:
134			self.add(name, value)
135
136
137class Base:
138	def __init__(self):
139		self.props = Props()
140		self.bbx = None
141		self.finis = []
142
143
144	def keep_comments(self, line):
145		if not line.startswith(b'COMMENT'):
146			return line
147
148		self.props.add('', line)
149		return None
150
151
152	def keep_finishes(self, line):
153		self.finis.append(line)
154		return None if line.startswith(b'COMMENT') else line
155
156
157class Char(Base):
158	def __init__(self):
159		Base.__init__(self)
160		self.code = -1
161		self.swidth = None
162		self.dwidth = None
163		self.data = None
164
165
166	@staticmethod
167	def bitmap(data, row_size):
168		bitmap = ''
169
170		for index in range(0, len(data), row_size):
171			bitmap += data[index : index + row_size].hex() + '\n'
172
173		return bytes(bitmap, 'ascii').upper()
174
175
176	def _read(self, input):
177		# HEADER
178		read_next = lambda: input.read_lines(lambda line: self.keep_comments(line))
179		read_prop = lambda name, callback=None: self.props.parse(read_next(), name, callback)
180
181		read_prop('STARTCHAR')
182		self.code = read_prop('ENCODING', fnutil.parse_dec)
183		self.swidth = read_prop('SWIDTH', lambda _, value: Width.parse_s(value))
184		self.dwidth = read_prop('DWIDTH', lambda _, value: Width.parse_d(value))
185		self.bbx = read_prop('BBX', BBX.parse)
186		line = read_next()
187
188		if line and line.startswith(b'ATTRIBUTES'):
189			self.props.parse(line, 'ATTRIBUTES')
190			line = read_next()
191
192		# BITMAP
193		if self.props.parse(line, 'BITMAP') != b'':
194			raise Exception('BITMAP expected')
195
196		row_len = self.bbx.row_size() * 2
197		bitmap = b''
198
199		for _ in range(0, self.bbx.height):
200			line = read_next()
201
202			if not line:
203				raise Exception('bitmap data expected')
204
205			if len(line) == row_len:
206				bitmap += line
207			else:
208				raise Exception('invalid bitmap length')
209
210		# FINAL
211		if input.read_lines(lambda line: self.keep_finishes(line)) != b'ENDCHAR':
212			raise Exception('ENDCHAR expected')
213
214		self.data = codecs.decode(bitmap, 'hex')  # no spaces allowed
215		return self
216
217
218	@staticmethod
219	def read(input):
220		return Char()._read(input)  # pylint: disable=protected-access
221
222
223	def write(self, output):
224		for [name, value] in self.props:
225			output.write_prop(name, value)
226
227		output.write_line(Char.bitmap(self.data, self.bbx.row_size()) + b'\n'.join(self.finis))
228
229
230@unique
231class XLFD(IntEnum):
232	FOUNDRY = 1
233	FAMILY_NAME = 2
234	WEIGHT_NAME = 3
235	SLANT = 4
236	SETWIDTH_NAME = 5
237	ADD_STYLE_NAME = 6
238	PIXEL_SIZE = 7
239	POINT_SIZE = 8
240	RESOLUTION_X = 9
241	RESOLUTION_Y = 10
242	SPACING = 11
243	AVERAGE_WIDTH = 12
244	CHARSET_REGISTRY = 13
245	CHARSET_ENCODING = 14
246
247CHARS_MAX = 65535
248
249class Font(Base):
250	def __init__(self):
251		Base.__init__(self)
252		self.xlfd = []
253		self.chars = []
254		self.default_code = -1
255
256
257	def get_ascent(self):
258		ascent = self.props.get('FONT_ASCENT')
259
260		if ascent is not None:
261			return fnutil.parse_dec('FONT_ASCENT', ascent, -HEIGHT_MAX, HEIGHT_MAX)
262
263		return self.bbx.height + self.bbx.yoff
264
265
266	def get_bold(self):
267		return int(b'bold' in self.xlfd[XLFD.WEIGHT_NAME].lower())
268
269
270	def get_italic(self):
271		return int(re.search(b'^[IO]', self.xlfd[XLFD.SLANT]) is not None)
272
273
274	def _read(self, input):
275		# HEADER
276		read_next = lambda: input.read_lines(lambda line: self.keep_comments(line))
277		read_prop = lambda name, callback=None: self.props.parse(read_next(), name, callback)
278		line = input.read_lines(Font.skip_empty)
279
280		if self.props.parse(line, 'STARTFONT') != b'2.1':
281			raise Exception('STARTFONT 2.1 expected')
282
283		self.xlfd = read_prop('FONT', lambda name, value: value.split(b'-', 15))
284
285		if len(self.xlfd) != 15 or self.xlfd[0] != b'':
286			raise Exception('non-XLFD font names are not supported')
287
288		read_prop('SIZE')
289		self.bbx = read_prop('FONTBOUNDINGBOX', BBX.parse)
290		line = read_next()
291
292		if line and line.startswith(b'STARTPROPERTIES'):
293			num_props = self.props.parse(line, 'STARTPROPERTIES', fnutil.parse_dec)
294
295			for _ in range(0, num_props):
296				line = read_next()
297
298				if not line:
299					raise Exception('property expected')
300
301				match = re.fullmatch(br'(\w+)\s+([-\d"].*)', line)
302
303				if not match:
304					raise Exception('invalid property format')
305
306				name = str(match.group(1), 'ascii')
307				value = match.group(2)
308
309				if name == 'DEFAULT_CHAR':
310					self.default_code = fnutil.parse_dec(name, value)
311
312				self.props.add(name, value)
313
314			if read_prop('ENDPROPERTIES') != b'':
315				raise Exception('ENDPROPERTIES expected')
316
317			line = read_next()
318
319		# GLYPHS
320		num_chars = self.props.parse(line, 'CHARS', lambda name, value: fnutil.parse_dec(name, value, 1, CHARS_MAX))
321
322		for _ in range(0, num_chars):
323			self.chars.append(Char.read(input))
324
325		if next((char.code for char in self.chars if char.code == self.default_code), -1) != self.default_code:
326			raise Exception('invalid DEFAULT_CHAR')
327
328		# FINAL
329		if input.read_lines(lambda line: self.keep_finishes(line)) != b'ENDFONT':
330			raise Exception('ENDFONT expected')
331
332		if input.read_lines(Font.skip_empty):
333			raise Exception('garbage after ENDFONT')
334
335		return self
336
337
338	@staticmethod
339	def read(input):
340		return Font()._read(input)  # pylint: disable=protected-access
341
342
343	@staticmethod
344	def skip_empty(line):
345		return line if line else None
346
347
348	def write(self, output):
349		for [name, value] in self.props:
350			output.write_prop(name, value)
351
352		for char in self.chars:
353			char.write(output)
354
355		output.write_line(b'\n'.join(self.finis))
356