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