├── CounterSpace.py ├── CounterSpaceUI.py ├── GlyphDrawView.py ├── LICENSE ├── README.md ├── Spacing_with_countershapes.ipynb ├── autospace.py ├── glyphs.png ├── regression.py ├── requirements.txt └── tensorfontglyphs.py /CounterSpace.py: -------------------------------------------------------------------------------- 1 | # coding: utf-8 2 | from __future__ import print_function 3 | 4 | import numpy as np 5 | import sys 6 | 7 | if sys.version_info[0] == 3: 8 | from urllib.request import urlretrieve 9 | else: 10 | from urllib import urlretrieve 11 | 12 | try: 13 | import GlyphsApp 14 | from tensorfontglyphs import Font,GlyphRendering 15 | except Exception as e: 16 | from tensorfont import Font,GlyphRendering 17 | 18 | import scipy 19 | import string 20 | from scipy.signal import convolve 21 | 22 | class CounterSpace: 23 | def __init__(self, file, 24 | bare_minimum = 50, 25 | absolute_maximum = 500, 26 | serif_smoothing = 2, 27 | key_pairs = ["HH","OO","HO","OH","EE","AV"], 28 | x_height_in_pixels = 90, 29 | ): 30 | """ 31 | To begin using CounterSpace, create a new `Counterspace` object by passing in the 32 | OpenType font filename, and the following keyword parameters: 33 | 34 | * `bare_minimum`: Minimum ink-to-ink distance. Default is 30 units. Increase this if "VV" is too close. 35 | * `serif_smoothing`: Default is 0. Amount of blurring applied. Increase to 20 or so if you have prominent serifs. 36 | """ 37 | self.filename = file 38 | self.font = Font(self.filename, x_height_in_pixels) 39 | self.bare_minimum = bare_minimum * self.font.scale_factor 40 | self.serif_smoothing = serif_smoothing 41 | self.absolute_maximum = int(absolute_maximum * self.font.scale_factor) 42 | self.key_pairs = key_pairs 43 | self.options = None 44 | 45 | self.box_height = self.font.full_height_px 46 | self.box_width = int(x_height_in_pixels * 10/3) 47 | if self.box_width % 2 == 1: 48 | self.box_width = self.box_width + 1 49 | 50 | self.theta = self.font.italic_angle * np.pi/180 51 | self.alpha = (90 - self.font.italic_angle) * np.pi/180 52 | 53 | # Various caches 54 | self._counters = {} 55 | self._lshifted_counters = {} 56 | self._rshifted_counters = {} 57 | self._pair_areas = {} 58 | 59 | hh = self.box_height / 2. 60 | bw = self.box_width / 2. 61 | fy, fx = np.mgrid[-hh:hh, -bw:bw] + 1. 62 | self.fx = fx 63 | self.fy = fy 64 | 65 | def gaussian(center_x, center_y, width_x, width_y, rotation): 66 | """Returns a gaussian function with the given parameters""" 67 | # print("Gaussian with parameters",center_x, center_y, width_x, width_y, rotation) 68 | width_x = float(width_x) 69 | width_y = float(width_y) 70 | center_x = self.box_width/2 - center_x 71 | center_y = self.box_height/2 - (self.box_height - center_y) 72 | center_x = center_x * np.cos(rotation) - center_y * np.sin(rotation) 73 | center_y = center_x * np.sin(rotation) + center_y * np.cos(rotation) 74 | 75 | xp = fx * np.cos(rotation) - fy * np.sin(rotation) 76 | yp = fx * np.sin(rotation) + fy * np.cos(rotation) 77 | g = np.exp( 78 | -(((center_x-xp)/width_x)**2+ 79 | ((center_y-yp)/width_y)**2)/2.) 80 | return np.flip(g,axis=1) 81 | self.gaussian = gaussian 82 | 83 | self.set_serif_smoothing(serif_smoothing) 84 | 85 | def set_serif_smoothing(self, serif_smoothing): 86 | self.serif_smoothing = serif_smoothing 87 | if serif_smoothing > 0: 88 | a = np.log(1.5*np.pi)/(serif_smoothing*(1+np.abs(self.theta))) 89 | lim = max(np.log(2*np.pi)/a, 1.) 90 | self.kernel = -np.sin(np.exp(-a*self.fx)) * np.where(self.fx>-lim, 1, 0) * a**2 * np.exp(-(self.fy/serif_smoothing)**2/2.) 91 | self.kernel *= self.kernel > 0 92 | else: 93 | self.kernel = None 94 | self._lshifted_counters = {} 95 | self._rshifted_counters = {} 96 | 97 | def counters(self, glyph): 98 | if glyph in self._counters: return self._counters[glyph] 99 | fg = self.font.glyph(glyph) 100 | self._counters[glyph] = fg.as_matrix(normalize=True).with_padding_to_constant_box_width(self.box_width).mask_ink_to_edge() 101 | return self._counters[glyph] 102 | 103 | def lshifted_counter(self, glyph, amount,reftop, refbottom): 104 | if (glyph,amount,reftop,refbottom) in self._lshifted_counters: return self._lshifted_counters[(glyph,amount,reftop,refbottom)] 105 | fg = self.font.glyph(glyph).as_matrix() 106 | conc = 0 107 | if fg.discontinuity(contour="right") > 0: 108 | conc = 1-fg.right_face() 109 | if self.kernel is not None: 110 | fg = GlyphRendering.init_from_numpy(fg._glyph,convolve(fg,self.kernel,mode="same") > 250) 111 | padded = fg.with_padding_to_constant_box_width(self.box_width) 112 | padded[0:reftop,:] = 0 113 | padded[refbottom:,:] = 0 114 | padded = padded.reduce_concavity(conc) 115 | c = scipy.ndimage.shift(padded, (0,amount), mode="nearest") 116 | l,r = GlyphRendering.init_from_numpy(glyph, c).mask_ink_to_edge() 117 | r = (r>0).astype(np.uint8) 118 | self._lshifted_counters[(glyph,amount,reftop,refbottom)] = r 119 | return r 120 | 121 | def rshifted_counter(self,glyph,amount,reftop,refbottom): 122 | if (glyph,amount,reftop,refbottom) in self._rshifted_counters: return self._rshifted_counters[(glyph,amount,reftop,refbottom)] 123 | fg = self.font.glyph(glyph).as_matrix() 124 | conc = 0 125 | if fg.discontinuity(contour="left") > 0: 126 | conc = 1-fg.left_face() 127 | if self.kernel is not None: 128 | fg = GlyphRendering.init_from_numpy(fg._glyph,convolve(fg,self.kernel,mode="same") > 250) 129 | padded = fg.with_padding_to_constant_box_width(self.box_width) 130 | padded[0:reftop,:] = 0 131 | padded[refbottom:,:] = 0 132 | padded = padded.reduce_concavity(conc) 133 | c = scipy.ndimage.shift(padded, (0,amount), mode="nearest") 134 | l,r = GlyphRendering.init_from_numpy(glyph, c).mask_ink_to_edge() 135 | l = (l>0).astype(np.uint8) 136 | self._rshifted_counters[(glyph,amount,reftop,refbottom)] = l 137 | return l 138 | 139 | def reference_pair(self,l,r): 140 | reference = "HH" 141 | if l in string.ascii_lowercase and r in string.ascii_lowercase: 142 | reference = "nn" 143 | if l in string.ascii_uppercase and r in string.ascii_lowercase: 144 | reference = "nn" 145 | if l in string.ascii_lowercase and r in string.ascii_uppercase: 146 | reference = "nn" 147 | return reference 148 | 149 | def pair_area(self, l, r, options, dist = None,reference=None): 150 | """Measure the area of the counter-space between two glyphs, set at 151 | a given distance. If the distance is got provided, then it is taken 152 | from the font's metrics. The glyphs are masked to the height of the 153 | reference pair.""" 154 | f = self.font 155 | if dist is None: 156 | dist = f.pair_distance(l,r) 157 | if reference is None: 158 | reference = self.reference_pair(l,r) 159 | shift_l, shift_r = f.shift_distances(l,r,dist) 160 | 161 | lref, rref = [f.glyph(ref) for ref in reference] 162 | reftop = int(min(lref.tsb,rref.tsb)) 163 | refbottom = int(min(lref.tsb+lref.ink_height, rref.tsb+rref.ink_height)) 164 | reftop = reftop + self.serif_smoothing 165 | refbottom = refbottom - self.serif_smoothing 166 | sigmas_top = (options["w_top"], options["h_top"]) 167 | sigmas_bottom = (options["w_bottom"], options["h_bottom"]) 168 | sigmas_center = (options["w_center"], options["h_center"]) 169 | 170 | top_strength = options["top_strength"] 171 | bottom_strength = options["bottom_strength"] 172 | center_strength = options["center_strength"] 173 | 174 | redgeofl = (self.box_width - f.glyph(l).ink_width) / 2.0 + f.glyph(l).ink_width + shift_l 175 | ledgeofr = self.box_width-((self.box_width - f.glyph(r).ink_width) / 2.0 + f.glyph(r).ink_width) + shift_r 176 | center = (redgeofl + ledgeofr)/2 - f.minimum_ink_distance(l, r) / 2 177 | midline = (reftop+refbottom)/2 - self.box_height/2 178 | 179 | # This mask ensures we only care about the area "between" the 180 | # glyphs, and don't get into e.g. interior counters of "PP" 181 | l_shifted = self.lshifted_counter(l,shift_l,reftop,refbottom) 182 | r_shifted = self.rshifted_counter(r,shift_r,reftop,refbottom) 183 | ink_mask = (l_shifted > 0) & (r_shifted > 0) 184 | ink_mask[0:reftop,:] = 0 185 | ink_mask[refbottom:,:] = 0 186 | 187 | # If the light was from the middle, this is where it would be 188 | union = np.array(((l_shifted + r_shifted) * ink_mask) > 0) 189 | y_center, x_center = scipy.ndimage.measurements.center_of_mass(union) 190 | if np.isnan(y_center) or np.isnan(y_center): return union 191 | top_x = int((x_center) + (y_center) / np.tan(self.alpha)) 192 | bottom_x = int((x_center) - (self.box_height-y_center) / np.tan(self.alpha)) 193 | top_y = 0 194 | bottom_y = self.box_height 195 | 196 | # Blur the countershape slightly 197 | union = union > 0 198 | # Now shine two lights from top and bottom 199 | toplight = self.gaussian(top_x,top_y,sigmas_top[0],sigmas_top[1],self.theta) 200 | bottomlight = self.gaussian(bottom_x,bottom_y,sigmas_bottom[0],sigmas_bottom[1],self.theta) 201 | centerlight = self.gaussian(x_center,y_center,sigmas_center[0],sigmas_center[1],self.theta) 202 | 203 | # XXX - this "shadowing" idea doesn't quite work 204 | 205 | # fnonz = False 206 | # for i in range(reftop+1,refbottom): 207 | # if fnonz: 208 | # toplight[i,:] = toplight[i,:] * (toplight[i-1,:] > 0) * (union[i,:] > 0) 209 | # else: 210 | # if np.any(toplight[i,:] > 0): 211 | # fnonz = True 212 | # fnonz = False 213 | # for i in range(refbottom-1,reftop,-1): 214 | # if fnonz: 215 | # bottomlight[i,:] = bottomlight[i,:] * (bottomlight[i+1,:] > 0) * (union[i,:] > 0) 216 | # else: 217 | # if np.any(toplight[i,:] > 0): 218 | # fnonz = True 219 | 220 | # # print("Total light:", np.sum( top_strength * toplight + bottomlight )) 221 | union = centerlight * center_strength * union + union * bottomlight * bottom_strength + union * ( top_strength * toplight) 222 | return union 223 | 224 | def determine_parameters(self, callback = None): 225 | bounds_for = { 226 | "w_center": (5,1000), 227 | "h_center": (10,1000), 228 | "w_top": (5,50), 229 | "h_top": (5,50), 230 | "w_bottom": (5,50), 231 | "h_bottom": (5,50), 232 | "top_strength": (0.05,1), 233 | "bottom_strength": (0.05,1), 234 | } 235 | def solve_for(variables, strings, options): 236 | reference = strings.pop(0) 237 | bounds = [bounds_for[v] for v in variables] 238 | guess = [options[v] for v in variables] 239 | def comparator(o): 240 | for ix,var in enumerate(variables): 241 | options[var] = o[ix] 242 | HH = np.sum(self.pair_area(reference[0],reference[1],options)) 243 | err = [] 244 | for s in strings: 245 | val = np.sum(self.pair_area(s[0],s[1],options)) 246 | err.append( ( val - HH) / HH ) 247 | err = np.sum(np.array(err) ** 2) 248 | if callback: 249 | callback(err) 250 | return err 251 | result = scipy.optimize.minimize(comparator,guess, 252 | method="TNC", 253 | bounds=bounds, 254 | options={ 255 | # 'maxfev': 200 256 | 'xtol': 0.01, 257 | 'eta': 0.8 258 | }, 259 | ) 260 | for ix,var in enumerate(variables): 261 | options[var] = result.x[ix] 262 | return options 263 | 264 | options = solve_for( 265 | variables = ["h_center","w_center","h_top","w_top","top_strength","h_bottom","w_bottom","bottom_strength"], 266 | strings = self.key_pairs, 267 | options = { 268 | "w_top": 15, 269 | "w_bottom": 15, 270 | "h_top": 15, 271 | "h_bottom": 15, 272 | "h_center": 1000, 273 | "w_center": 1000, 274 | "top_strength": 0.1, 275 | "bottom_strength": 0.1, 276 | "center_strength": 1 277 | } 278 | ) 279 | self.options = options 280 | return options 281 | 282 | def space(self, l, r): 283 | if not self.options: 284 | raise ValueError("You need to run .determine_parameters() or set self.options manually") 285 | reference = self.reference_pair(l,r) 286 | u_good = np.sum(self.pair_area(reference[0],reference[1], self.options, reference=reference)) 287 | mid = self.font.minimum_ink_distance(l, r) 288 | rv = None 289 | peak = -1 290 | peak_idx = -1 291 | goneover = False 292 | 293 | for n in range(-int(mid)+int(self.bare_minimum),self.absolute_maximum,1): 294 | u = np.sum(self.pair_area(l,r,self.options,dist=n, reference=reference)) 295 | if u > u_good: 296 | rv = n 297 | goneover = True 298 | break 299 | if u > peak: 300 | peak = u 301 | peak_idx = n 302 | if rv is None: 303 | return int(peak_idx / self.font.scale_factor) 304 | return int(rv / self.font.scale_factor) 305 | 306 | def derive_sidebearings(self, g, keyglyph = None): 307 | if keyglyph is None: 308 | keyglyph = self.reference_pair(g,g)[0] 309 | keyspace = self.space(keyglyph,keyglyph) // 2 310 | lsb = self.space(keyglyph,g) - keyspace 311 | rsb = self.space(g,keyglyph) - keyspace 312 | return(lsb,rsb) 313 | 314 | def test_string(self, string): 315 | from itertools import tee 316 | def pairwise(iterable): 317 | a, b = tee(iterable) 318 | next(b, None) 319 | return zip(a, b) 320 | found = [] 321 | good = [] 322 | for l,r in pairwise(string): 323 | found.append(self.space(l,r) * self.font.scale_factor) 324 | good.append(self.font.pair_distance(l,r)) 325 | return found,good 326 | 327 | @classmethod 328 | def get_sample_font(self, name): 329 | sample_fonts = { 330 | "CrimsonRoman.otf": "https://github.com/skosch/Crimson/blob/master/Desktop%20Fonts/OTF/Crimson-Roman.otf?raw=true", 331 | "Tinos-Italic.ttf": "https://github.com/jenskutilek/free-fonts/raw/master/Tinos/TTF/Tinos-Italic.ttf", 332 | "PTSerif-Italic.ttf": "https://github.com/divspace/pt-serif/raw/master/fonts/pt-serif/pt-serif-italic.ttf", 333 | "Crimson-SemiboldItalic.otf": "https://github.com/skosch/Crimson/raw/master/Desktop%20Fonts/OTF/Crimson-SemiboldItalic.otf", 334 | "OpenSans-Regular.ttf": "https://github.com/google/fonts/blob/master/apache/opensans/OpenSans-Regular.ttf" 335 | } 336 | if not (name in sample_fonts): 337 | print("%s not known; sample fonts available are: %s" % (name, ", ".join(sample_fonts.keys()))) 338 | return 339 | import os.path 340 | if not os.path.isfile(name): 341 | urlretrieve(sample_fonts[name], name) 342 | print("Downloaded %s" % name) 343 | 344 | if __name__ == '__main__': 345 | c = CounterSpace("OpenSans-Regular.ttf",serif_smoothing=0) 346 | print("Determining parameters", end="") 347 | print(c.determine_parameters(callback = lambda x: print(".",end="",flush=True))) 348 | pdd = {} 349 | 350 | def compare(s): 351 | global pdd 352 | found = c.space(s[0],s[1]) * c.font.scale_factor 353 | good = c.font.pair_distance(s[0],s[1]) 354 | pdd[(s[0],s[1])] = found 355 | if abs(found-good) > 5: 356 | ok="!!!" 357 | else: 358 | ok="" 359 | print("%s: pred=%i true=%i %s" % (s,found/c.font.scale_factor,good/c.font.scale_factor, ok)) 360 | 361 | from itertools import tee 362 | def pairwise(iterable): 363 | a, b = tee(iterable) 364 | next(b, None) 365 | return zip(a, b) 366 | 367 | def fill_pdd(s): 368 | for l,r in pairwise(s): 369 | if not (l,r) in pdd: compare((l,r)) 370 | 371 | for s in ["XX","DV","VF","FA","AV","tx", "VV","no","ga","LH"]: 372 | compare(s) 373 | -------------------------------------------------------------------------------- /CounterSpaceUI.py: -------------------------------------------------------------------------------- 1 | #MenuTitle: CounterSpace 2 | # -*- coding: utf-8 -*- 3 | __doc__=""" 4 | Autospace and autokern with counters 5 | """ 6 | from GlyphDrawView import GlyphDrawView 7 | from GlyphsApp import Message 8 | from AppKit import NSRunLoop 9 | from vanilla import * 10 | from itertools import tee,izip 11 | import CounterSpace 12 | import string 13 | import traceback 14 | 15 | def pairwise(iterable): 16 | a, b = tee(iterable) 17 | next(b, None) 18 | return izip(a, b) 19 | 20 | class CounterSpaceUI(object): 21 | def __init__(self): 22 | self.w = Window((900, 600)) 23 | self.view = GlyphDrawView.alloc().init() 24 | self.master = Glyphs.font.selectedFontMaster 25 | self.spacing = {} 26 | self.prespaced = { "caps": [], "caplower": [], "lower": [] } 27 | self.bare_minimum = 20 28 | self.serif_smoothing = 0 29 | self.view.setMaster(self.master) 30 | self.view.setFrame_(((0, 0), (880, 200))) 31 | self.view.setString("") 32 | self.w.scrollView = ScrollView((10, 380, -10, -10), self.view) 33 | 34 | self.w.textBox1 = TextBox((10,20,150,17),"Bare Minimum") 35 | self.w.bareMinSlider = Slider((180, 20, -60, 23), callback = self.bareMinCallback, minValue = 0, maxValue = 200) 36 | self.w.bareMinSlider.set(self.bare_minimum) 37 | self.w.bareMinTextBox = TextBox((-50,20,50,17),str(self.bare_minimum)) 38 | self.w.textBox1b = TextBox((10,40,-10,14),"(Set this first. A good test string for this variable is 'HHLArvt')",sizeStyle="small",selectable=False)#,callback=self.setHHLHvt) 39 | 40 | self.w.textBox2 = TextBox((10,70,150,17),"Serif smoothing") 41 | self.w.serifSmoothSlider = Slider((180, 70, -60, 23), callback = self.serifSmoothCallback, minValue = 0, maxValue = 20, continuous=False) 42 | self.w.serifSmoothSlider.set(self.serif_smoothing) 43 | self.w.serifSmoothSlider.enable(False) 44 | self.w.serifSmoothTextBox = TextBox((-50,70,50,17),self.serif_smoothing) 45 | 46 | 47 | self.w.textBox3 = TextBox((10,100,150,17),"Spaced pairs list") 48 | self.w.spacedPairsList = EditText((180, 100, -60, 20), callback = self.setSpacedPairs,text="EE,AV",continuous=False) 49 | self.oldssp = "" 50 | self.w.textBox3b = TextBox((10,130,-10,14),"Comma-separated list of pairs whose spacing is used as an example. The following pairs are silently added: HH,OO,HO,OH, Ho,To,Th,Hh, oo,nn,on,no,te,rg,ge.",sizeStyle="small",selectable=False) 51 | 52 | self.w.recomputeButton = Button((10,170,150,17),"Compute parameters", callback = self.runSolver) 53 | self.w.textBoxRCP = TextBox((10,190,-10,14),"(NB: This takes a long time but you need to do it!)",sizeStyle="small",selectable=False) 54 | self.w.bar = ProgressBar((10, 220, -10, 16),minValue =0 , maxValue = 100) 55 | self.w.errorBox = TextBox((10, 250, -10, 16),"") 56 | 57 | self.w.editText = EditText((10, 360, 200, 20), callback=self.editTextCallback,text="HHOOLVAH") 58 | self.setSpacedPairs(self.w.spacedPairsList) 59 | self.w.editText.enable(False) 60 | self.w.open() 61 | 62 | def progress(self,err): 63 | self.w.errorBox.set("Error: %.7f" % err) 64 | self.w.errorBox._nsObject.setNeedsDisplay_(True) 65 | pairs = len(self.prespaced["caps"]) + len(self.prespaced["caplower"]) + len(self.prespaced["lower"]) 66 | self.w.bar.increment(1.0/(3*pairs)) 67 | NSRunLoop.mainRunLoop().runUntilDate_(NSDate.dateWithTimeIntervalSinceNow_(0.0001)) 68 | 69 | def spacingClass(self,s): 70 | l,r = s[0],s[1] 71 | if l in string.ascii_uppercase and r in string.ascii_uppercase: 72 | return "caps" 73 | if l in string.ascii_uppercase and r in string.ascii_lowercase: 74 | return "caplower" 75 | return "lower" 76 | 77 | def setSpacedPairs(self,sender): 78 | if sender.get() == self.oldssp: 79 | return 80 | try: 81 | self.oldssp = sender.get() 82 | csl = sender.get().split(",") 83 | self.prespaced["caps"] = ["HH","OO","HO","OH"] 84 | self.prespaced["caplower"] = ["Ho","To","Th","Hh"] 85 | self.prespaced["lower"] = ["oo","nn","on","no","te","rg","ge"] 86 | for s in csl: 87 | self.prespaced[self.spacingClass(s)].append(s) 88 | for k in ["caps","caplower","lower"]: 89 | self.prespaced[k] = list(set(self.prespaced[k])) 90 | print("Set spaced pairs called") 91 | self.needsRecomputing() 92 | except Exception as e: 93 | print(e) 94 | traceback.print_exc() 95 | 96 | def needsRecomputing(self): 97 | self.spacers = {} 98 | for c in ["caps", "lower", "caplower"]: 99 | self.spacers[c] = CounterSpace.CounterSpace(self.master, 100 | bare_minimum=self.bare_minimum, 101 | serif_smoothing=self.serif_smoothing, 102 | key_pairs = self.prespaced[c] 103 | ) 104 | self.w.recomputeButton.enable(True) 105 | self.w.editText.enable(False) 106 | self.spacing = {} 107 | 108 | def runSolver(self,sender): 109 | print("Prespaced dictionary:") 110 | print(self.prespaced) 111 | from multiprocessing.pool import ThreadPool 112 | pool = ThreadPool(processes=1) 113 | self.w.bar.set(0) 114 | self.w.recomputeButton.enable(False) 115 | 116 | for c in ["lower"]: 117 | print("Determining parameters for %s" % c) 118 | result = self.spacers[c].determine_parameters(callback = lambda err:self.progress(err)) 119 | self.spacers[c].options = result 120 | print("Result for %s : %s" % (c,result)) 121 | 122 | self.spacers["caps"].options = {'bottom_strength': 1.1345652573787253, 'h_top': 5.487824242337442, 'h_center': 5.931368743262439, 'h_bottom': 15.282101114272237, 'w_top': 27.21495352061693, 'w_center': 58.93266226658602, 'top_strength': 0.5915259512256053, 'center_strength': 0.07957667771756849, 'w_bottom': 12.07849643211549} 123 | # self.spacers["lower"].options = {'bottom_strength': 5.236787909255455, 'h_top': -13.434270522062697, 'h_center': 47.60847066913675, 'h_bottom': 5.990015356494567, 'w_top': -7.108329693663322, 'w_center': 0.47190584344597786, 'top_strength': -0.9089546341683743, 'center_strength': 0.06563161642247345, 'w_bottom': 32.21412242837695} 124 | self.spacers["caplower"].options = {'bottom_strength': -2.422052201203848, 'h_top': -12.393311309708867, 'h_center': 51.62401673223201, 'h_bottom': 12.732836126432804, 'w_top': 139.33317239248765, 'w_center': -0.7569307267200843, 'top_strength': 1.7618843046229808, 'center_strength': 7.0190999837101, 'w_bottom': 56.97695661416948} 125 | self.w.bar.set(100) 126 | self.spacing={} 127 | self.setStringAndDistances(self.w.editText.get()) 128 | self.editTextCallback(self.w.editText) 129 | self.w.editText.enable(True) 130 | 131 | # pool.apply_async(f,callback = cb) 132 | 133 | def serifSmoothCallback(self, sender): 134 | serif_smoothing = int(sender.get()) 135 | self.w.serifSmoothTextBox.set(str(serif_smoothing)) 136 | self.serif_smoothing = serif_smoothing 137 | self.c.set_serif_smoothing(serif_smoothing) 138 | print("Serif callback called") 139 | self.needsRecomputing() 140 | 141 | def bareMinCallback(self, sender): 142 | bm = int(sender.get()) 143 | self.w.bareMinTextBox.set(str(bm)) 144 | for c in ["caps", "lower", "caplower"]: 145 | self.spacers[c].bare_minimum = bm 146 | self.spacing = {} 147 | self.editTextCallback(self.w.editText) 148 | 149 | def editTextCallback(self, sender): 150 | print("Edit text callback called") 151 | self.setStringAndDistances(sender.get()) 152 | 153 | def get_spacing(self,l,r): 154 | if not (l,r) in self.spacing: 155 | c = self.spacingClass(l+r) 156 | self.spacing[(l,r)] = self.spacers[c].space(l,r) 157 | print("%s%s = %.5f" % (l,r,self.spacing[(l,r)])) 158 | return self.spacing[(l,r)] 159 | 160 | def setStringAndDistances(self, string): 161 | string_a = list(string) 162 | for i in range(0,len(string_a)): 163 | if ord(string_a[i]) > 255: 164 | string_a[i] = "%04x" % ord(string_a[i]) 165 | print(string_a) 166 | for l,r in pairwise(string_a): 167 | self.get_spacing(l,r) 168 | self.view.setString(string_a) 169 | self.view.setDistances(self.spacing) 170 | 171 | def set_kerning(self,l,r): 172 | target = self.c.space(l,r) 173 | sofar = (master.font.glyphs[l].layers[master.id].RSB + master.font.glyphs[r].layers[master.id].LSB) 174 | kern = target - sofar 175 | self.master.font.setKerningForPair(master.id, l,r,kern) 176 | return kern 177 | 178 | def set_sidebearings(self, g): 179 | lsb, rsb = self.c.derive_sidebearings(g) 180 | layer = self.master.font.glyphs[g].layers[self.master.id] 181 | layer.LSB = lsb 182 | layer.RSB = rsb 183 | 184 | # Check we have the bare minimum glyphs defined 185 | notThere = "" 186 | master = Glyphs.font.selectedFontMaster 187 | for g in "HOLVA": 188 | glyph = master.font.glyphs[g].layers[master.id] 189 | if len(glyph.paths) < 1: 190 | notThere = notThere + g 191 | if len(notThere) > 0: 192 | Message("Required glyphs not defined: %s" % (",".join(notThere)), "CounterSpace") 193 | else: 194 | CounterSpaceUI() 195 | -------------------------------------------------------------------------------- /GlyphDrawView.py: -------------------------------------------------------------------------------- 1 | from AppKit import NSView, NSColor, NSRectFill, NSBezierPath, NSAffineTransform 2 | from vanilla import * 3 | import traceback 4 | import sys 5 | 6 | class GlyphDrawView(NSView): 7 | def setMaster(self, master): 8 | self.master = master 9 | self.setNeedsDisplay_(True) 10 | 11 | def setString(self, string): 12 | self.string = string 13 | self.setNeedsDisplay_(True) 14 | 15 | def setDistances(self, distances): 16 | self.distances = distances 17 | self.setNeedsDisplay_(True) 18 | 19 | def drawRect_(self, rect): 20 | try: 21 | NSColor.whiteColor().set() 22 | NSRectFill(self.bounds()) 23 | NSColor.blackColor().setFill() 24 | NSColor.blueColor().setStroke() 25 | p = NSBezierPath.bezierPath() 26 | xcursor = 0 27 | string = self.string 28 | master = self.master 29 | for s in range(0,len(string)): 30 | thisPath = NSBezierPath.bezierPath() 31 | gsglyph = master.font.glyphs[string[s]] 32 | layer = gsglyph.layers[master.id] 33 | thisPath.appendBezierPath_(layer.completeBezierPath) 34 | # print("X cursor was",xcursor) 35 | xcursor = xcursor - layer.bounds.origin.x 36 | # print("Moving backwards", layer.bounds.origin.x) 37 | t = NSAffineTransform.transform() 38 | t.translateXBy_yBy_(xcursor,-master.descender) 39 | thisPath.transformUsingAffineTransform_(t) 40 | # print("Drawing at",xcursor) 41 | # print(thisPath) 42 | xcursor = xcursor + layer.bounds.origin.x 43 | xcursor = xcursor + layer.bounds.size.width 44 | # print("Adding width", layer.bounds.size.width) 45 | if s < len(string)-1: 46 | xcursor = xcursor + self.distances[(string[s],string[s+1])] 47 | p.appendBezierPath_(thisPath) 48 | 49 | t = NSAffineTransform.transform() 50 | if xcursor > 0: 51 | vscale = self.bounds().size.height / (master.ascender - master.descender) 52 | hscale = self.bounds().size.width / xcursor 53 | t.scaleBy_(min(hscale,vscale)) 54 | p.transformUsingAffineTransform_(t) 55 | p.fill() 56 | except: 57 | print("Oops!",sys.exc_info()[0],"occured.") 58 | traceback.print_exc(file=sys.stdout) 59 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The software in this repository is licensed under the Apache license, 2 | found below. 3 | All images in this repository are licensed under the CC-BY (https://creativecommons.org/licenses/by/4.0/) license. 4 | 5 | Apache License 6 | Version 2.0, January 2004 7 | http://www.apache.org/licenses/ 8 | 9 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 10 | 11 | 1. Definitions. 12 | 13 | "License" shall mean the terms and conditions for use, reproduction, 14 | and distribution as defined by Sections 1 through 9 of this document. 15 | 16 | "Licensor" shall mean the copyright owner or entity authorized by 17 | the copyright owner that is granting the License. 18 | 19 | "Legal Entity" shall mean the union of the acting entity and all 20 | other entities that control, are controlled by, or are under common 21 | control with that entity. For the purposes of this definition, 22 | "control" means (i) the power, direct or indirect, to cause the 23 | direction or management of such entity, whether by contract or 24 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 25 | outstanding shares, or (iii) beneficial ownership of such entity. 26 | 27 | "You" (or "Your") shall mean an individual or Legal Entity 28 | exercising permissions granted by this License. 29 | 30 | "Source" form shall mean the preferred form for making modifications, 31 | including but not limited to software source code, documentation 32 | source, and configuration files. 33 | 34 | "Object" form shall mean any form resulting from mechanical 35 | transformation or translation of a Source form, including but 36 | not limited to compiled object code, generated documentation, 37 | and conversions to other media types. 38 | 39 | "Work" shall mean the work of authorship, whether in Source or 40 | Object form, made available under the License, as indicated by a 41 | copyright notice that is included in or attached to the work 42 | (an example is provided in the Appendix below). 43 | 44 | "Derivative Works" shall mean any work, whether in Source or Object 45 | form, that is based on (or derived from) the Work and for which the 46 | editorial revisions, annotations, elaborations, or other modifications 47 | represent, as a whole, an original work of authorship. For the purposes 48 | of this License, Derivative Works shall not include works that remain 49 | separable from, or merely link (or bind by name) to the interfaces of, 50 | the Work and Derivative Works thereof. 51 | 52 | "Contribution" shall mean any work of authorship, including 53 | the original version of the Work and any modifications or additions 54 | to that Work or Derivative Works thereof, that is intentionally 55 | submitted to Licensor for inclusion in the Work by the copyright owner 56 | or by an individual or Legal Entity authorized to submit on behalf of 57 | the copyright owner. For the purposes of this definition, "submitted" 58 | means any form of electronic, verbal, or written communication sent 59 | to the Licensor or its representatives, including but not limited to 60 | communication on electronic mailing lists, source code control systems, 61 | and issue tracking systems that are managed by, or on behalf of, the 62 | Licensor for the purpose of discussing and improving the Work, but 63 | excluding communication that is conspicuously marked or otherwise 64 | designated in writing by the copyright owner as "Not a Contribution." 65 | 66 | "Contributor" shall mean Licensor and any individual or Legal Entity 67 | on behalf of whom a Contribution has been received by Licensor and 68 | subsequently incorporated within the Work. 69 | 70 | 2. Grant of Copyright License. Subject to the terms and conditions of 71 | this License, each Contributor hereby grants to You a perpetual, 72 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 73 | copyright license to reproduce, prepare Derivative Works of, 74 | publicly display, publicly perform, sublicense, and distribute the 75 | Work and such Derivative Works in Source or Object form. 76 | 77 | 3. Grant of Patent License. Subject to the terms and conditions of 78 | this License, each Contributor hereby grants to You a perpetual, 79 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 80 | (except as stated in this section) patent license to make, have made, 81 | use, offer to sell, sell, import, and otherwise transfer the Work, 82 | where such license applies only to those patent claims licensable 83 | by such Contributor that are necessarily infringed by their 84 | Contribution(s) alone or by combination of their Contribution(s) 85 | with the Work to which such Contribution(s) was submitted. If You 86 | institute patent litigation against any entity (including a 87 | cross-claim or counterclaim in a lawsuit) alleging that the Work 88 | or a Contribution incorporated within the Work constitutes direct 89 | or contributory patent infringement, then any patent licenses 90 | granted to You under this License for that Work shall terminate 91 | as of the date such litigation is filed. 92 | 93 | 4. Redistribution. You may reproduce and distribute copies of the 94 | Work or Derivative Works thereof in any medium, with or without 95 | modifications, and in Source or Object form, provided that You 96 | meet the following conditions: 97 | 98 | (a) You must give any other recipients of the Work or 99 | Derivative Works a copy of this License; and 100 | 101 | (b) You must cause any modified files to carry prominent notices 102 | stating that You changed the files; and 103 | 104 | (c) You must retain, in the Source form of any Derivative Works 105 | that You distribute, all copyright, patent, trademark, and 106 | attribution notices from the Source form of the Work, 107 | excluding those notices that do not pertain to any part of 108 | the Derivative Works; and 109 | 110 | (d) If the Work includes a "NOTICE" text file as part of its 111 | distribution, then any Derivative Works that You distribute must 112 | include a readable copy of the attribution notices contained 113 | within such NOTICE file, excluding those notices that do not 114 | pertain to any part of the Derivative Works, in at least one 115 | of the following places: within a NOTICE text file distributed 116 | as part of the Derivative Works; within the Source form or 117 | documentation, if provided along with the Derivative Works; or, 118 | within a display generated by the Derivative Works, if and 119 | wherever such third-party notices normally appear. The contents 120 | of the NOTICE file are for informational purposes only and 121 | do not modify the License. You may add Your own attribution 122 | notices within Derivative Works that You distribute, alongside 123 | or as an addendum to the NOTICE text from the Work, provided 124 | that such additional attribution notices cannot be construed 125 | as modifying the License. 126 | 127 | You may add Your own copyright statement to Your modifications and 128 | may provide additional or different license terms and conditions 129 | for use, reproduction, or distribution of Your modifications, or 130 | for any such Derivative Works as a whole, provided Your use, 131 | reproduction, and distribution of the Work otherwise complies with 132 | the conditions stated in this License. 133 | 134 | 5. Submission of Contributions. Unless You explicitly state otherwise, 135 | any Contribution intentionally submitted for inclusion in the Work 136 | by You to the Licensor shall be under the terms and conditions of 137 | this License, without any additional terms or conditions. 138 | Notwithstanding the above, nothing herein shall supersede or modify 139 | the terms of any separate license agreement you may have executed 140 | with Licensor regarding such Contributions. 141 | 142 | 6. Trademarks. This License does not grant permission to use the trade 143 | names, trademarks, service marks, or product names of the Licensor, 144 | except as required for reasonable and customary use in describing the 145 | origin of the Work and reproducing the content of the NOTICE file. 146 | 147 | 7. Disclaimer of Warranty. Unless required by applicable law or 148 | agreed to in writing, Licensor provides the Work (and each 149 | Contributor provides its Contributions) on an "AS IS" BASIS, 150 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 151 | implied, including, without limitation, any warranties or conditions 152 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 153 | PARTICULAR PURPOSE. You are solely responsible for determining the 154 | appropriateness of using or redistributing the Work and assume any 155 | risks associated with Your exercise of permissions under this License. 156 | 157 | 8. Limitation of Liability. In no event and under no legal theory, 158 | whether in tort (including negligence), contract, or otherwise, 159 | unless required by applicable law (such as deliberate and grossly 160 | negligent acts) or agreed to in writing, shall any Contributor be 161 | liable to You for damages, including any direct, indirect, special, 162 | incidental, or consequential damages of any character arising as a 163 | result of this License or out of the use or inability to use the 164 | Work (including but not limited to damages for loss of goodwill, 165 | work stoppage, computer failure or malfunction, or any and all 166 | other commercial damages or losses), even if such Contributor 167 | has been advised of the possibility of such damages. 168 | 169 | 9. Accepting Warranty or Additional Liability. While redistributing 170 | the Work or Derivative Works thereof, You may choose to offer, 171 | and charge a fee for, acceptance of support, warranty, indemnity, 172 | or other liability obligations and/or rights consistent with this 173 | License. However, in accepting such obligations, You may act only 174 | on Your own behalf and on Your sole responsibility, not on behalf 175 | of any other Contributor, and only if You agree to indemnify, 176 | defend, and hold each Contributor harmless for any liability 177 | incurred by, or claims asserted against, such Contributor by reason 178 | of your accepting any such warranty or additional liability. 179 | 180 | END OF TERMS AND CONDITIONS 181 | 182 | APPENDIX: How to apply the Apache License to your work. 183 | 184 | To apply the Apache License to your work, attach the following 185 | boilerplate notice, with the fields enclosed by brackets "[]" 186 | replaced with your own identifying information. (Don't include 187 | the brackets!) The text should be enclosed in the appropriate 188 | comment syntax for the file format. We also recommend that a 189 | file or class name and description of purpose be included on the 190 | same "printed page" as the copyright notice for easier 191 | identification within third-party archives. 192 | 193 | Copyright [yyyy] [name of copyright owner] 194 | 195 | Licensed under the Apache License, Version 2.0 (the "License"); 196 | you may not use this file except in compliance with the License. 197 | You may obtain a copy of the License at 198 | 199 | http://www.apache.org/licenses/LICENSE-2.0 200 | 201 | Unless required by applicable law or agreed to in writing, software 202 | distributed under the License is distributed on an "AS IS" BASIS, 203 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 204 | See the License for the specific language governing permissions and 205 | limitations under the License. 206 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | CounterSpace: Automated spacing with counters 2 | ============================================= 3 | 4 | This is an experimental proof-of-concept automated spacing and kerning tool. For details of how it works, see the [conceptual overview](Spacing_with_countershapes.ipynb). Please note that this is experimental and for research purposes; I wouldn't yet trust it to automatically kern a whole font. 5 | 6 | This repository consists of a library which implements the CounterSpace algorithm (`CounterSpace.py`), a Jupyter notebook which demonstrates the algorithm and allows you to "play" with the technique, and a Glyphs script which can be used (with a little preparation) to investigate the technique within your font editor. 7 | 8 | Installation 9 | ------------ 10 | 11 | The library can be used in two ways. 12 | 13 | If you are using it on the command line or from the Jupyter notebook, then it requires the `tensorfont` library to access glyph information from a TTF or OTF font. It also requires Python 3. Assuming you have Python 3 installed, use the following command to install all needed dependencies: 14 | 15 | ``` 16 | sudo -H pip3 install -r requirements.txt 17 | ``` 18 | 19 | If you are using it within Glyphs, the library will detect this, and no longer requires `tensorfont`. (In turn allowing it to be Python 2 compatible.) However, it will still require a number of other libraries to do the mathematical computations. First, download the following files and place them in the same directory as the Glyphs script: 20 | 21 | * https://files.pythonhosted.org/packages/ad/e3/7c8234b15137d2886bbbc3e9a7c83aae851b8cb1e3cf1c3210bdcce56b98/scikit_image-0.14.3-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl 22 | * https://files.pythonhosted.org/packages/26/6d/b55e412b5ae437dad6efe102b1a5bdefce4290543bbef78462a7a2037a1e/Pillow-6.1.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl 23 | * https://files.pythonhosted.org/packages/f2/16/c66bd67f34b8cd2964c2e9914401a27f8cb50398e8cf06ac6e65d80a2c6d/PyWavelets-1.0.3-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl 24 | 25 | Next, run the following command: 26 | 27 | ``` 28 | sudo /usr/bin/easy_install scikit-image<0.15 numpy 29 | ``` 30 | 31 | This should install what you need. 32 | 33 | Playing with the Jupyter notebook 34 | --------------------------------- 35 | 36 | I'm going to assume you have also [installed Jupyter](https://jupyter.readthedocs.io/en/latest/install.html). Run `jupyter notebook` and open `Spacing_with_countershapes.ipynb`. First, hit "Cell > Run All" and wait. Hopefully you will see no errors and lots of pretty output. Read through each cell to ensure you understand what is going on. 37 | 38 | To play with the spacing output, scroll down to the cell beginning "So now we have all the pieces required to space our font." You can change the sample texts in the following cells and run them to see how the spacer copes with different glyph combinations. 39 | 40 | You can also change the font, either by pointing CounterSpace at a font of your own (don't forget to also change the `serif_smoothing` factor if appropriate), or by asking it to download one of the sample fonts: 41 | 42 | ``` 43 | CounterSpace.get_sample_font("PTSerif-Italic.ttf") 44 | CounterSpace.get_sample_font("OpenSans-Regular.ttf") 45 | CounterSpace.get_sample_font("Tinos-Italic.ttf") 46 | CounterSpace.get_sample_font("CrimsonRoman.otf") 47 | CounterSpace.get_sample_font("Crimson-SemiboldItalic.otf") 48 | ``` 49 | 50 | Spacing an existing OpenType font 51 | --------------------------------- 52 | 53 | CounterSpace can be used to space and then kern a font. (As mentioned above, this is an experimental research project. I haven't solved autokerning yet!) 54 | 55 | Check out the `autospace.py` script: 56 | 57 | ``` 58 | % python3 autospace.py OpenSans-Regular.ttf 59 | Determining parameters... 60 | 61 | Spacing... 62 | A LSB = 67, RSB = 80 63 | ... 64 | 65 | Kerning... 66 | AA(-14) AB AC(-38) AD AE AF AG(-50)... 67 | 68 | Saving OpenSans-Regular-autospaced.ttf 69 | ``` 70 | 71 | Using within Glyphs 72 | ------------------- 73 | 74 | Once you have the required modules installed (see above), you can use Glyphs to explore the autospacing algorithm. This does not work as well as the command line version, as it relies on horrible hackery to render the glyph outlines to a bitmap. Nevertheless, if you want to try it, place this directory (including the downloaded "wheel" files mentioned above) into your Glyphs `Scripts` directory and run `Script > CounterSpace > CounterSpace`. 75 | 76 | The following window should appear: 77 | 78 | ![glyphs.png](glyphs.png) 79 | 80 | Clicking on `Compute parameters` will determine the optimal parameters for the spacer. This takes a long time. Finally, in the lower text box, you can enter your own text and Glyphs will render it using the spacings computed by CounterSpace. 81 | 82 | Licence and Funding 83 | ------------------- 84 | 85 | Research and development of CounterSpace was graciously funded by the Google Fonts team. CounterSpace is available under the Apache 2.0 license. -------------------------------------------------------------------------------- /autospace.py: -------------------------------------------------------------------------------- 1 | from CounterSpace import CounterSpace 2 | import string 3 | import sys 4 | import os 5 | 6 | from fontTools.ttLib import TTFont 7 | 8 | c = CounterSpace(sys.argv[1],serif_smoothing=0) 9 | filename, file_extension = os.path.splitext(sys.argv[1]) 10 | spaced_filename = "%s-autospaced%s" % (filename,file_extension) 11 | 12 | ttfont = TTFont(c.font.filename) 13 | if not "glyf" in ttfont: 14 | print("Sorry, currently only supports truetype fonts. :-(") 15 | sys.exit(1) 16 | 17 | def set_sidebearings(g,new_lsb,new_rsb): 18 | old_width, old_lsb = ttfont["hmtx"].metrics[g] 19 | old_rsb = old_width - max([f[0] for f in ttfont["glyf"][g].coordinates]) 20 | ink_width = old_width - (old_lsb+old_rsb) 21 | ttfont["hmtx"].metrics[g] = (new_lsb+ink_width+new_rsb, new_lsb) 22 | ttfont["glyf"][g].coordinates -= (old_lsb-new_lsb,0) 23 | 24 | print("Determining parameters...") 25 | c.determine_parameters() 26 | 27 | print("Spacing...") 28 | lsbs = {} 29 | rsbs = {} 30 | for g in string.ascii_uppercase: 31 | lsbs[g], rsbs[g] = c.derive_sidebearings(g) 32 | print("%s LSB = %i, RSB = %i" % (g,lsbs[g],rsbs[g])) 33 | set_sidebearings(g,lsbs[g],rsbs[g]) 34 | 35 | print("\nKerning...") 36 | for l in string.ascii_uppercase: 37 | for r in string.ascii_uppercase: 38 | print(l+r, end="", flush=True) 39 | desiredspace = c.space(l,r) 40 | currentspace = rsbs[l] + lsbs[r] 41 | kernvalue = desiredspace - currentspace 42 | if abs(kernvalue) > 5: 43 | print("("+str(kernvalue)+")", end="", flush=True) 44 | ttfont["kern"].kernTables[0][l,r] = kernvalue 45 | print(" ", end="", flush=True) 46 | 47 | print("\nSaving "+spaced_filename) 48 | ttfont.save(spaced_filename) 49 | -------------------------------------------------------------------------------- /glyphs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/simoncozens/CounterSpace/01b33477225eb5fc7bedf93a090b5cd7dd0c812b/glyphs.png -------------------------------------------------------------------------------- /regression.py: -------------------------------------------------------------------------------- 1 | from CounterSpace import CounterSpace 2 | 3 | import unittest 4 | 5 | test_fonts = ["CrimsonRoman.otf", "Tinos-Italic.ttf", "PTSerif-Italic.ttf", "Crimson-SemiboldItalic.otf", "OpenSans-Regular.ttf"] 6 | spacers = {} 7 | 8 | for f in test_fonts: 9 | serif_smoothing = 3 10 | if "OpenSans" in f: 11 | serif_smoothing = 0 12 | spacers[f] = CounterSpace(f,serif_smoothing=0) 13 | 14 | from itertools import tee 15 | def pairwise(iterable): 16 | a, b = tee(iterable) 17 | next(b, None) 18 | return zip(a, b) 19 | 20 | class TestCounterSpace(unittest.TestCase): 21 | 22 | def test_1_solvingImprovesThings(self): 23 | for font in test_fonts: 24 | with self.subTest("Solving improves things for font "+font): 25 | c = spacers[font] 26 | c.key_pairs = ["HH","OO","HO","oo","nn","no"] 27 | c.options = { "h_center": 1000, "w_center": 1000, "h_top":1, "w_top": 1, "h_bottom":1, "w_bottom": 1, "top_strength":0.1, "bottom_strength":0.1, "center_strength":1 } 28 | def mse(string): 29 | found,good= c.test_string(string) 30 | return sum([(l-r)*(l-r) for l,r in zip(found,good)]) 31 | 32 | dummy = mse("HOAVAnoon") 33 | 34 | c.determine_parameters() 35 | solved = mse("HOAVAnoon") 36 | self.assertLess(solved,dummy, "Solved MSE (%i) < dummy MSE (%i) for font %s " % (solved,dummy,font)) 37 | 38 | def test_2_noCatastrophicFailures(self): 39 | for font in test_fonts: 40 | c = spacers[font] 41 | upem = c.font.face.units_per_EM 42 | for l,r in pairwise("HAMBURGEFONSIVhamburgefonsiv"): 43 | with self.subTest("Catastrophic failure test %s%s for font %s" % (l,r,font)): 44 | found = c.space(l,r) 45 | good = c.font.pair_distance(l,r) / c.font.scale_factor 46 | self.assertLess(abs(found-good), 60 * (upem/1024), "Catastrophic failure: %s%s (%i != %i)" % (l,r,found,good)) 47 | 48 | if __name__ == '__main__': 49 | unittest.main() -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | numpy 2 | scipy 3 | tensorfont==0.0.6 4 | -------------------------------------------------------------------------------- /tensorfontglyphs.py: -------------------------------------------------------------------------------- 1 | 2 | # wget https://files.pythonhosted.org/packages/ad/e3/7c8234b15137d2886bbbc3e9a7c83aae851b8cb1e3cf1c3210bdcce56b98/scikit_image-0.14.3-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl 3 | # wget https://files.pythonhosted.org/packages/26/6d/b55e412b5ae437dad6efe102b1a5bdefce4290543bbef78462a7a2037a1e/Pillow-6.1.0-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl 4 | # wget https://files.pythonhosted.org/packages/f2/16/c66bd67f34b8cd2964c2e9914401a27f8cb50398e8cf06ac6e65d80a2c6d/PyWavelets-1.0.3-cp27-cp27m-macosx_10_6_intel.macosx_10_9_intel.macosx_10_9_x86_64.macosx_10_10_intel.macosx_10_10_x86_64.whl 5 | 6 | # sudo /usr/bin/easy_install scikit-image<0.15 numpy 7 | import numpy as np 8 | from AppKit import NSBitmapImageRep, NSGraphicsContext, NSCalibratedWhiteColorSpace, NSPNGFileType,NSColor,NSBezierPath,NSMakeRect,NSAffineTransform 9 | import math 10 | 11 | from skimage.transform import resize 12 | from skimage.util import pad 13 | from skimage import filters 14 | from skimage.morphology import convex_hull_image 15 | 16 | from scipy import ndimage 17 | import scipy 18 | 19 | from itertools import tee 20 | 21 | safe_glyphs = set([ 22 | "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", 23 | "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", 24 | "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", 25 | "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", 26 | ]) 27 | 28 | safe_glyphs_l = set([ 29 | "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", 30 | "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", 31 | ]) 32 | 33 | safe_glyphs_r = set([ 34 | "a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", 35 | "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", 36 | "A", "B", "C", "D", "E", "F", "G", "H", "I", "J", "K", "L", "M", 37 | "N", "O", "P", "Q", "R", "S", "T", "U", "V", "W", "X", "Y", "Z", 38 | ]) 39 | 40 | 41 | class Font(object): 42 | """The `Font` module is your entry point for using Tensorfont. 43 | Use this to load up a font and begin exploring it. 44 | """ 45 | 46 | def __init__(self, master, x_height_in_px): 47 | self.master = master 48 | 49 | # Set size by rendering /x and dividing by its height 50 | x_height_at_em = master.xHeight 51 | self.scale_factor = x_height_in_px / x_height_at_em 52 | 53 | self.ascender = self.master.ascender 54 | self.ascender_px = int(self.master.ascender * self.scale_factor) 55 | """The font's ascender height, in font units and pixels.""" 56 | 57 | self.descender = self.master.descender 58 | self.descender_px = int(self.master.descender * self.scale_factor) 59 | """The font's descender height, in font units and pixels (usually negative).""" 60 | 61 | self.full_height = self.ascender - self.descender 62 | self.full_height_px = self.ascender_px - self.descender_px 63 | """The font's full (descender + ascender) height, in font units and pixels.""" 64 | 65 | self.baseline_ratio = 1 - (self.ascender) / self.full_height 66 | """The ascender-to-descender ratio.""" 67 | 68 | self.glyphcache = {} 69 | self.kernreader = None 70 | 71 | def get_xheight(self): 72 | return self.master.x_height 73 | 74 | @property 75 | def italic_angle(self): 76 | return self.master.italicAngle 77 | 78 | def glyph(self, g): 79 | """Access a glyph by name. Returns a `Glyph` object.""" 80 | if g in self.glyphcache: return self.glyphcache[g] 81 | self.glyphcache[g] = Glyph(self,g) 82 | return self.glyphcache[g] 83 | 84 | @property 85 | def m_width(self): 86 | """The width of the 'm' glyph, in font units.""" 87 | return self.glyph("m").width 88 | 89 | @property 90 | def x_height(self): 91 | """The height of the 'x' glyph, in font units.""" 92 | return self.glyph("x").height 93 | 94 | def pair_kerning(self, left, right): 95 | """The kerning between two glyphs (specified by name), in font units.""" 96 | k1 = self.master.font.kerningForPair(self.master.id, left,right) 97 | if k1 < 1e10: return k1 * self.scale_factor 98 | # Some other stuff 99 | return 0 100 | 101 | def pair_distance(self, left, right, with_kerning=True): 102 | """The ink distance between two named glyphs, in font units. 103 | This is formed by adding the right sidebearing of the left glyph to the left sidebearing 104 | of the right glyph, plus a kerning correction. To turn off kerning, use `with_kerning=False`.""" 105 | distance = self.glyph(left).rsb + self.glyph(right).lsb 106 | if with_kerning: 107 | distance = distance + self.pair_kerning(left, right) 108 | return distance 109 | 110 | def minimum_ink_distance(self,left,right): 111 | """The distance, in pixels, between the ink of the left glyph and the ink of the right glyph, when 112 | sidebearings are discarded. For many pairs, this will be zero, as the shapes bump up against 113 | each other (consider "nn" and "oo"). However, pairs like "VA" and "xT" will have a large 114 | minimum ink distance.""" 115 | right_of_l = self.glyph(left).as_matrix().right_contour() 116 | left_of_r = self.glyph(right).as_matrix().left_contour() 117 | return np.min(right_of_l + left_of_r) 118 | 119 | def maximum_ink_distance(self,left,right): 120 | """The maximum distance, in pixels, between the ink of the left glyph and the ink of the right glyph, when 121 | sidebearings are discarded. In other words, the size of the "hole" in the glyph (LV has a large hole).""" 122 | right_of_l = self.glyph(left).as_matrix().right_contour(max_depth = -1) 123 | left_of_r = self.glyph(right).as_matrix().left_contour(max_depth = -1) 124 | return np.max(right_of_l + left_of_r) 125 | 126 | def shift_distances(self,l,r,dist): 127 | """Returns two distances, for which the left glyph matrix and the right glyph matrix 128 | should be translated such that, when the translations are done, the pair is set at a 129 | distance `dist` pixels apart. 130 | 131 | (Inputs `l` and `r` are glyph names, not `GlyphRendering` objects.) 132 | """ 133 | sample_distance = dist + self.minimum_ink_distance(l,r) 134 | sample_distance_left = np.ceil(sample_distance / 2.0) 135 | sample_distance_right = np.floor(sample_distance / 2.0) 136 | total_ink_width = self.glyph(l).ink_width + self.glyph(r).ink_width 137 | ink_width_left = np.floor(total_ink_width / 4.0) 138 | ink_width_right = np.ceil(total_ink_width / 4.0) 139 | total_width_at_minimum_ink_distance = total_ink_width - self.minimum_ink_distance(l, r) 140 | left_translation = (-(np.ceil(total_width_at_minimum_ink_distance/2.0) + sample_distance_left) - (-ink_width_left)) 141 | right_translation = ((np.floor(total_width_at_minimum_ink_distance/2.0) + sample_distance_right) - ink_width_right) 142 | return left_translation,right_translation 143 | 144 | def set_string(self, s, pair_distance_dict = {}): 145 | """Returns a matrix containing a representation of the given string. If a dictionary 146 | is passed to `pair_distance_dict`, then each pair name `(l,r)` will be looked up 147 | in the directionary and the result will be used as a distance *in pixel units* at 148 | which to set the pair. If no entry is found or no dictionary is passed, then the font 149 | will be queried for the appropriate distance. 150 | 151 | Hint: If you want to use glyph names which are not single characters, then pass an 152 | *array* of glyph names instead of a string.""" 153 | def pairwise(iterable): 154 | a, b = tee(iterable) 155 | next(b, None) 156 | return zip(a, b) 157 | image = self.glyph(s[0]).as_matrix() 158 | for l,r in pairwise(s): 159 | newimage = self.glyph(r).as_matrix() 160 | if (l,r) in pair_distance_dict: 161 | dist = pair_distance_dict[(l,r)] 162 | else: 163 | dist = self.pair_distance(l,r) 164 | image = image.impose(newimage,int(dist)) 165 | return image 166 | 167 | 168 | class Glyph(object): 169 | """A representation of a glyph and its metrics.""" 170 | def __init__(self, font, g): 171 | self.font = font 172 | self.master = font.master 173 | 174 | self.name = g 175 | """The name of the glyph.""" 176 | 177 | self.gsglyph = self.master.font.glyphs[g] 178 | self.layer = self.gsglyph.layers[font.master.id] 179 | if len(self.layer.components) > 0: 180 | self.layer = self.layer.copyDecomposedLayer() 181 | 182 | self.ink_width = self.layer.bounds.size.width * self.font.scale_factor 183 | self.ink_height= self.layer.bounds.size.height * self.font.scale_factor 184 | self.width = self.layer.width * self.font.scale_factor 185 | """The width of the glyph in font units (including sidebearings).""" 186 | self.lsb = self.layer.LSB * self.font.scale_factor 187 | """The left sidebearing in font units.""" 188 | self.rsb = self.layer.RSB * self.font.scale_factor 189 | """The right sidebearing in font units.""" 190 | self.tsb = self.layer.TSB * self.font.scale_factor 191 | """The top sidebearing (distance from ascender to ink top) in font units.""" 192 | 193 | def as_matrix(self, normalize = False, binarize = False): 194 | """Renders the glyph as a matrix. By default, the matrix values are integer pixel greyscale values 195 | in the range 0 to 255, but they can be normalized or turned into binary values with the 196 | appropriate keyword arguments. The matrix is returned as a `GlyphRendering` object which 197 | can be further manipulated.""" 198 | box_height = int(self.font.full_height_px) 199 | box_width = int(self.ink_width) 200 | 201 | b = NSBitmapImageRep.alloc().initWithBitmapDataPlanes_pixelsWide_pixelsHigh_bitsPerSample_samplesPerPixel_hasAlpha_isPlanar_colorSpaceName_bytesPerRow_bitsPerPixel_(None,box_width,box_height,8,1,False,False,NSCalibratedWhiteColorSpace, 0,0) 202 | ctx = NSGraphicsContext.graphicsContextWithBitmapImageRep_(b) 203 | assert(ctx) 204 | NSGraphicsContext.setCurrentContext_(ctx) 205 | 206 | NSColor.whiteColor().setFill() 207 | p2 = NSBezierPath.bezierPath() 208 | p2.appendBezierPath_(self.layer.completeBezierPath) 209 | t = NSAffineTransform.transform() 210 | t.translateXBy_yBy_(-self.lsb,-self.font.descender * self.font.scale_factor) 211 | t.scaleBy_(self.font.scale_factor) 212 | p2.transformUsingAffineTransform_(t) 213 | p2.fill() 214 | 215 | png = b.representationUsingType_properties_(NSPNGFileType,None) 216 | png.writeToFile_atomically_("/tmp/foo.png", False) 217 | Z = np.array(b.bitmapData()) 218 | box_width_up = Z.shape[0]/box_height 219 | Z = Z.reshape((box_height,box_width_up))[0:box_height,0:box_width] 220 | 221 | if normalize or binarize: 222 | Z = Z / 255.0 223 | if binarize: 224 | Z = Z.astype(int) 225 | return GlyphRendering.init_from_numpy(self,Z) 226 | 227 | class GlyphRendering(np.ndarray): 228 | @classmethod 229 | def init_from_numpy(self, glyph, matrix): 230 | s = GlyphRendering(matrix.shape) 231 | s[:] = matrix 232 | s._glyph = glyph 233 | return s 234 | 235 | def with_padding(self, left_padding, right_padding): 236 | """Returns a new `GlyphRendering` object, left and right zero-padding to the glyph image.""" 237 | padding = ((0,0),(left_padding, right_padding)) 238 | padded = pad(self, padding, "constant") 239 | return GlyphRendering.init_from_numpy(self._glyph, padded) 240 | 241 | def with_padding_to_constant_box_width(self, box_width): 242 | padding_width = (box_width - int(self._glyph.ink_width)) / 2.0 243 | padding = ((0, 0), (int(np.ceil(padding_width)), int(np.floor(padding_width)))) 244 | padded = pad(self, padding, "constant") 245 | return GlyphRendering.init_from_numpy(self._glyph, padded) 246 | 247 | def with_sidebearings(self): 248 | """Returns a new `GlyphRendering` object, extending the image to add the 249 | glyph's sidebearings. If the sidebearings are negative, the matrix 250 | will be trimmed appropriately.""" 251 | lsb = self._glyph.lsb 252 | rsb = self._glyph.rsb 253 | matrix = self 254 | if lsb < 0: 255 | matrix = GlyphRendering.init_from_numpy(self._glyph,self[:,-lsb:]) 256 | lsb = 0 257 | if rsb < 0: 258 | matrix = GlyphRendering.init_from_numpy(self._glyph,self[:,:rsb]) 259 | rsb = 0 260 | return matrix.with_padding(lsb,rsb) 261 | 262 | def mask_to_x_height(self): 263 | """Returns a new `GlyphRendering` object, cropping the glyph image 264 | from the baseline to the x-height. (Assuming that the input `GlyphRendering` is full height.)""" 265 | f = self._glyph.font 266 | baseline = int(f.full_height + f.descender) 267 | top = int(baseline - f.x_height) 268 | cropped = self[top:baseline,:] 269 | return GlyphRendering.init_from_numpy(self._glyph, cropped) 270 | 271 | def crop_descender(self): 272 | """Returns a new `GlyphRendering` object, cropping the glyph image 273 | from the baseline to the ascender. (Assuming that the input `GlyphRendering` is full height.)""" 274 | f = self._glyph.font 275 | baseline = int(f.full_height + f.descender) 276 | cropped = self[:baseline,:] 277 | return GlyphRendering.init_from_numpy(self._glyph, cropped) 278 | 279 | def scale_to_height(self, height): 280 | """Returns a new `GlyphRendering` object, scaling the glyph image to the 281 | given height. (The new width is calculated proportionally.)""" 282 | new_width = int(self.shape[1] * height / self.shape[0]) 283 | return GlyphRendering.init_from_numpy(self._glyph, resize(self, (height, new_width), mode="constant")) 284 | 285 | def left_contour(self, cutoff = 30, max_depth = 10000): 286 | """Returns the left contour of the matrix; ie, the 'sidebearing array' from the 287 | edge of the matrix to the leftmost ink pixel. If no ink is found at a given 288 | scanline, the value of max_depth is used instead.""" 289 | contour = np.argmax(self > cutoff, axis=1) + max_depth * (np.max(self, axis=1) <= cutoff) 290 | return np.array(contour) 291 | 292 | def right_contour(self, cutoff = 30, max_depth = 10000): 293 | """Returns the right contour of the matrix; ie, the 'sidebearing array' from the 294 | edge of the matrix to the rightmost ink pixel. If no ink is found at a given 295 | scanline, the value of max_depth is used instead.""" 296 | pixels = np.fliplr(self) 297 | contour = np.argmax(pixels > cutoff, axis=1) + max_depth * (np.max(pixels, axis=1) <= cutoff) 298 | return np.array(contour) 299 | 300 | def apply_flexible_distance_kernel(self, strength): 301 | """Transforms the matrix by applying a flexible distance kernel, with given strength.""" 302 | transformed = 1. - np.clip(strength-ndimage.distance_transform_edt(np.logical_not(self)),0,strength) 303 | return GlyphRendering.init_from_numpy(self._glyph,transformed) 304 | 305 | def gradients(self): 306 | """Returns a pair of images representing the horizontal and vertical gradients.""" 307 | return filters.sobel_h(self), filters.sobel_v(self) 308 | 309 | def impose(self, other, distance=0): 310 | """Returns a new `GlyphRendering` object made up of two `GlyphRendering` objects 311 | placed side by side at the given distance.""" 312 | if self.shape[0] != other.shape[0]: 313 | raise ValueError("heights don't match in impose") 314 | extension = distance + other.shape[1] 315 | extended = self.with_padding(0,extension) 316 | extended[:,self.shape[1]+distance:self.shape[1]+extension] += other 317 | return extended 318 | 319 | def set_at_distance(self,other,distance=0): 320 | """Similar to `impose` but returns a pair of `GlyphRendering` objects separately, padded at the correct distance.""" 321 | s2, o2 = self.with_padding(0, other.shape[1] + distance), other.with_padding(self.shape[1]+distance, 0) 322 | return s2, o2 323 | 324 | def mask_ink_to_edge(self): 325 | """Returns two `GlyphRendering` objects representing the left and right "edges" of the glyph: 326 | the first has positive values in the space between the left-hand contour and the left edge of the matrix 327 | and zero values elsewhere, and the second has positive values between the right-hand contour and 328 | the right edge of the matrix and zero values elsewhere. In other words this gives you the 329 | "white" at the edge of the glyph, without any interior counters.""" 330 | def last_nonzero(arr, axis, invalid_val=-1): 331 | mask = arr > 5/255.0 332 | val = arr.shape[axis] - np.flip(mask, axis=axis).argmax(axis=axis) - 1 333 | return np.where(mask.any(axis=axis), val, invalid_val) 334 | 335 | def first_zero(arr, axis, invalid_val=-1): 336 | mask = arr < 5/255.0 337 | val = mask.argmax(axis=axis) - 1 338 | return np.where(mask.any(axis=axis), val, invalid_val) 339 | 340 | def left_counter(image): 341 | lcounter = 1 - image 342 | lnonz = first_zero(lcounter,1) 343 | for x in range(lnonz.shape[0]): lcounter[x,1+lnonz[x]:] = 0 344 | lcounter -= np.min(lcounter) 345 | for x in range(lnonz.shape[0]): lcounter[x,lnonz[x]:] = 0 346 | return lcounter 347 | 348 | def right_counter(image): 349 | rcounter = np.flip(image,axis=1) 350 | rcounter = left_counter(rcounter) 351 | rcounter = np.flip(rcounter,axis=1) 352 | return rcounter 353 | 354 | return [GlyphRendering.init_from_numpy(self._glyph,x) for x in [left_counter(self), right_counter(self)]] 355 | 356 | def discontinuity(self, contour="left", tolerance = 0.05): 357 | """Provides a measure, from zero to one, of the "jumpiness" or discontinuity of a 358 | contour. By default it looks at the left contour; pass `contour="right"` to look 359 | at the right. You can feed this value to `reduce_concavity`. The tolerance parameter 360 | determines the size of "jumps" that are significance, measured in fractions of an em. 361 | By default, anything less that 0.05em is considered smooth and continuous.""" 362 | if contour == "left": 363 | c = self.left_contour(max_depth=-1) 364 | else: 365 | c = self.right_contour(max_depth=-1) 366 | steps = c[1:] - c[:-1] 367 | non_edges = ((c[:-1]!=-1) & (c[1:]!=-1)) 368 | jumps = np.abs(steps) > (self._glyph.font.m_width * tolerance) 369 | total = np.abs(np.sum(steps * non_edges * jumps)) 370 | total2 = np.sum(np.abs(steps * non_edges * jumps)) 371 | 372 | return np.clip(float(total) / self.shape[1],0,1) 373 | 374 | def right_face(self, epsilon=0.2): 375 | c = self.right_contour(max_depth=-1) 376 | c = np.delete(c,np.where(c==-1)) 377 | pctile = np.min(c) + 0.2 * (np.max(c)-np.min(c)) 378 | return np.sum(c < pctile) / c.shape[0] 379 | 380 | def left_face(self, epsilon=0.2): 381 | c = self.left_contour(max_depth=-1) 382 | c = np.delete(c,np.where(c==-1)) 383 | pctile = np.min(c) + 0.2 * (np.max(c)-np.min(c)) 384 | return np.sum(c < pctile) / c.shape[0] 385 | 386 | def reduce_concavity(self,percent): 387 | """Smooths out concavities in glyphs like 'T', 'L', 'E' by interpolating between the 388 | glyph and its convex hull.""" 389 | 390 | ch = GlyphRendering.init_from_numpy(self,convex_hull_image(self)) 391 | r = self.left_contour(max_depth=-1) 392 | rch = ch.left_contour(cutoff=0.1,max_depth=-1) 393 | interpolated = (rch * (percent) + r * (1-percent)).astype(np.int32) 394 | new = GlyphRendering.init_from_numpy(self,self.copy()) 395 | fill = np.max(new) 396 | for line in range(0,len(interpolated)): 397 | if interpolated[line] != -1: 398 | new[line,interpolated[line]:r[line]+1] = fill 399 | 400 | r = self.right_contour(max_depth=-1) 401 | rch = ch.right_contour(cutoff=0.1,max_depth=-1) 402 | width = self.shape[1] 403 | interpolated = (rch * (percent) + r * (1-percent)).astype(np.int32) 404 | for line in range(0,len(interpolated)): 405 | if interpolated[line] != -1: 406 | new[line,(width-r[line]+1):(width-interpolated[line])] = fill 407 | 408 | return GlyphRendering.init_from_numpy(self._glyph,new) --------------------------------------------------------------------------------