1*e1fe3e4aSElliott Hughes"""Pen calculating area, center of mass, variance and standard-deviation, 2*e1fe3e4aSElliott Hughescovariance and correlation, and slant, of glyph shapes.""" 3*e1fe3e4aSElliott Hughes 4*e1fe3e4aSElliott Hughesfrom math import sqrt, degrees, atan 5*e1fe3e4aSElliott Hughesfrom fontTools.pens.basePen import BasePen, OpenContourError 6*e1fe3e4aSElliott Hughesfrom fontTools.pens.momentsPen import MomentsPen 7*e1fe3e4aSElliott Hughes 8*e1fe3e4aSElliott Hughes__all__ = ["StatisticsPen", "StatisticsControlPen"] 9*e1fe3e4aSElliott Hughes 10*e1fe3e4aSElliott Hughes 11*e1fe3e4aSElliott Hughesclass StatisticsBase: 12*e1fe3e4aSElliott Hughes def __init__(self): 13*e1fe3e4aSElliott Hughes self._zero() 14*e1fe3e4aSElliott Hughes 15*e1fe3e4aSElliott Hughes def _zero(self): 16*e1fe3e4aSElliott Hughes self.area = 0 17*e1fe3e4aSElliott Hughes self.meanX = 0 18*e1fe3e4aSElliott Hughes self.meanY = 0 19*e1fe3e4aSElliott Hughes self.varianceX = 0 20*e1fe3e4aSElliott Hughes self.varianceY = 0 21*e1fe3e4aSElliott Hughes self.stddevX = 0 22*e1fe3e4aSElliott Hughes self.stddevY = 0 23*e1fe3e4aSElliott Hughes self.covariance = 0 24*e1fe3e4aSElliott Hughes self.correlation = 0 25*e1fe3e4aSElliott Hughes self.slant = 0 26*e1fe3e4aSElliott Hughes 27*e1fe3e4aSElliott Hughes def _update(self): 28*e1fe3e4aSElliott Hughes # XXX The variance formulas should never produce a negative value, 29*e1fe3e4aSElliott Hughes # but due to reasons I don't understand, both of our pens do. 30*e1fe3e4aSElliott Hughes # So we take the absolute value here. 31*e1fe3e4aSElliott Hughes self.varianceX = abs(self.varianceX) 32*e1fe3e4aSElliott Hughes self.varianceY = abs(self.varianceY) 33*e1fe3e4aSElliott Hughes 34*e1fe3e4aSElliott Hughes self.stddevX = stddevX = sqrt(self.varianceX) 35*e1fe3e4aSElliott Hughes self.stddevY = stddevY = sqrt(self.varianceY) 36*e1fe3e4aSElliott Hughes 37*e1fe3e4aSElliott Hughes # Correlation(X,Y) = Covariance(X,Y) / ( stddev(X) * stddev(Y) ) 38*e1fe3e4aSElliott Hughes # https://en.wikipedia.org/wiki/Pearson_product-moment_correlation_coefficient 39*e1fe3e4aSElliott Hughes if stddevX * stddevY == 0: 40*e1fe3e4aSElliott Hughes correlation = float("NaN") 41*e1fe3e4aSElliott Hughes else: 42*e1fe3e4aSElliott Hughes # XXX The above formula should never produce a value outside 43*e1fe3e4aSElliott Hughes # the range [-1, 1], but due to reasons I don't understand, 44*e1fe3e4aSElliott Hughes # (probably the same issue as above), it does. So we clamp. 45*e1fe3e4aSElliott Hughes correlation = self.covariance / (stddevX * stddevY) 46*e1fe3e4aSElliott Hughes correlation = max(-1, min(1, correlation)) 47*e1fe3e4aSElliott Hughes self.correlation = correlation if abs(correlation) > 1e-3 else 0 48*e1fe3e4aSElliott Hughes 49*e1fe3e4aSElliott Hughes slant = ( 50*e1fe3e4aSElliott Hughes self.covariance / self.varianceY if self.varianceY != 0 else float("NaN") 51*e1fe3e4aSElliott Hughes ) 52*e1fe3e4aSElliott Hughes self.slant = slant if abs(slant) > 1e-3 else 0 53*e1fe3e4aSElliott Hughes 54*e1fe3e4aSElliott Hughes 55*e1fe3e4aSElliott Hughesclass StatisticsPen(StatisticsBase, MomentsPen): 56*e1fe3e4aSElliott Hughes """Pen calculating area, center of mass, variance and 57*e1fe3e4aSElliott Hughes standard-deviation, covariance and correlation, and slant, 58*e1fe3e4aSElliott Hughes of glyph shapes. 59*e1fe3e4aSElliott Hughes 60*e1fe3e4aSElliott Hughes Note that if the glyph shape is self-intersecting, the values 61*e1fe3e4aSElliott Hughes are not correct (but well-defined). Moreover, area will be 62*e1fe3e4aSElliott Hughes negative if contour directions are clockwise.""" 63*e1fe3e4aSElliott Hughes 64*e1fe3e4aSElliott Hughes def __init__(self, glyphset=None): 65*e1fe3e4aSElliott Hughes MomentsPen.__init__(self, glyphset=glyphset) 66*e1fe3e4aSElliott Hughes StatisticsBase.__init__(self) 67*e1fe3e4aSElliott Hughes 68*e1fe3e4aSElliott Hughes def _closePath(self): 69*e1fe3e4aSElliott Hughes MomentsPen._closePath(self) 70*e1fe3e4aSElliott Hughes self._update() 71*e1fe3e4aSElliott Hughes 72*e1fe3e4aSElliott Hughes def _update(self): 73*e1fe3e4aSElliott Hughes area = self.area 74*e1fe3e4aSElliott Hughes if not area: 75*e1fe3e4aSElliott Hughes self._zero() 76*e1fe3e4aSElliott Hughes return 77*e1fe3e4aSElliott Hughes 78*e1fe3e4aSElliott Hughes # Center of mass 79*e1fe3e4aSElliott Hughes # https://en.wikipedia.org/wiki/Center_of_mass#A_continuous_volume 80*e1fe3e4aSElliott Hughes self.meanX = meanX = self.momentX / area 81*e1fe3e4aSElliott Hughes self.meanY = meanY = self.momentY / area 82*e1fe3e4aSElliott Hughes 83*e1fe3e4aSElliott Hughes # Var(X) = E[X^2] - E[X]^2 84*e1fe3e4aSElliott Hughes self.varianceX = self.momentXX / area - meanX * meanX 85*e1fe3e4aSElliott Hughes self.varianceY = self.momentYY / area - meanY * meanY 86*e1fe3e4aSElliott Hughes 87*e1fe3e4aSElliott Hughes # Covariance(X,Y) = (E[X.Y] - E[X]E[Y]) 88*e1fe3e4aSElliott Hughes self.covariance = self.momentXY / area - meanX * meanY 89*e1fe3e4aSElliott Hughes 90*e1fe3e4aSElliott Hughes StatisticsBase._update(self) 91*e1fe3e4aSElliott Hughes 92*e1fe3e4aSElliott Hughes 93*e1fe3e4aSElliott Hughesclass StatisticsControlPen(StatisticsBase, BasePen): 94*e1fe3e4aSElliott Hughes """Pen calculating area, center of mass, variance and 95*e1fe3e4aSElliott Hughes standard-deviation, covariance and correlation, and slant, 96*e1fe3e4aSElliott Hughes of glyph shapes, using the control polygon only. 97*e1fe3e4aSElliott Hughes 98*e1fe3e4aSElliott Hughes Note that if the glyph shape is self-intersecting, the values 99*e1fe3e4aSElliott Hughes are not correct (but well-defined). Moreover, area will be 100*e1fe3e4aSElliott Hughes negative if contour directions are clockwise.""" 101*e1fe3e4aSElliott Hughes 102*e1fe3e4aSElliott Hughes def __init__(self, glyphset=None): 103*e1fe3e4aSElliott Hughes BasePen.__init__(self, glyphset) 104*e1fe3e4aSElliott Hughes StatisticsBase.__init__(self) 105*e1fe3e4aSElliott Hughes self._nodes = [] 106*e1fe3e4aSElliott Hughes 107*e1fe3e4aSElliott Hughes def _moveTo(self, pt): 108*e1fe3e4aSElliott Hughes self._nodes.append(complex(*pt)) 109*e1fe3e4aSElliott Hughes 110*e1fe3e4aSElliott Hughes def _lineTo(self, pt): 111*e1fe3e4aSElliott Hughes self._nodes.append(complex(*pt)) 112*e1fe3e4aSElliott Hughes 113*e1fe3e4aSElliott Hughes def _qCurveToOne(self, pt1, pt2): 114*e1fe3e4aSElliott Hughes for pt in (pt1, pt2): 115*e1fe3e4aSElliott Hughes self._nodes.append(complex(*pt)) 116*e1fe3e4aSElliott Hughes 117*e1fe3e4aSElliott Hughes def _curveToOne(self, pt1, pt2, pt3): 118*e1fe3e4aSElliott Hughes for pt in (pt1, pt2, pt3): 119*e1fe3e4aSElliott Hughes self._nodes.append(complex(*pt)) 120*e1fe3e4aSElliott Hughes 121*e1fe3e4aSElliott Hughes def _closePath(self): 122*e1fe3e4aSElliott Hughes self._update() 123*e1fe3e4aSElliott Hughes 124*e1fe3e4aSElliott Hughes def _endPath(self): 125*e1fe3e4aSElliott Hughes p0 = self._getCurrentPoint() 126*e1fe3e4aSElliott Hughes if p0 != self.__startPoint: 127*e1fe3e4aSElliott Hughes raise OpenContourError("Glyph statistics not defined on open contours.") 128*e1fe3e4aSElliott Hughes 129*e1fe3e4aSElliott Hughes def _update(self): 130*e1fe3e4aSElliott Hughes nodes = self._nodes 131*e1fe3e4aSElliott Hughes n = len(nodes) 132*e1fe3e4aSElliott Hughes 133*e1fe3e4aSElliott Hughes # Triangle formula 134*e1fe3e4aSElliott Hughes self.area = ( 135*e1fe3e4aSElliott Hughes sum( 136*e1fe3e4aSElliott Hughes (p0.real * p1.imag - p1.real * p0.imag) 137*e1fe3e4aSElliott Hughes for p0, p1 in zip(nodes, nodes[1:] + nodes[:1]) 138*e1fe3e4aSElliott Hughes ) 139*e1fe3e4aSElliott Hughes / 2 140*e1fe3e4aSElliott Hughes ) 141*e1fe3e4aSElliott Hughes 142*e1fe3e4aSElliott Hughes # Center of mass 143*e1fe3e4aSElliott Hughes # https://en.wikipedia.org/wiki/Center_of_mass#A_system_of_particles 144*e1fe3e4aSElliott Hughes sumNodes = sum(nodes) 145*e1fe3e4aSElliott Hughes self.meanX = meanX = sumNodes.real / n 146*e1fe3e4aSElliott Hughes self.meanY = meanY = sumNodes.imag / n 147*e1fe3e4aSElliott Hughes 148*e1fe3e4aSElliott Hughes if n > 1: 149*e1fe3e4aSElliott Hughes # Var(X) = (sum[X^2] - sum[X]^2 / n) / (n - 1) 150*e1fe3e4aSElliott Hughes # https://www.statisticshowto.com/probability-and-statistics/descriptive-statistics/sample-variance/ 151*e1fe3e4aSElliott Hughes self.varianceX = varianceX = ( 152*e1fe3e4aSElliott Hughes sum(p.real * p.real for p in nodes) 153*e1fe3e4aSElliott Hughes - (sumNodes.real * sumNodes.real) / n 154*e1fe3e4aSElliott Hughes ) / (n - 1) 155*e1fe3e4aSElliott Hughes self.varianceY = varianceY = ( 156*e1fe3e4aSElliott Hughes sum(p.imag * p.imag for p in nodes) 157*e1fe3e4aSElliott Hughes - (sumNodes.imag * sumNodes.imag) / n 158*e1fe3e4aSElliott Hughes ) / (n - 1) 159*e1fe3e4aSElliott Hughes 160*e1fe3e4aSElliott Hughes # Covariance(X,Y) = (sum[X.Y] - sum[X].sum[Y] / n) / (n - 1) 161*e1fe3e4aSElliott Hughes self.covariance = covariance = ( 162*e1fe3e4aSElliott Hughes sum(p.real * p.imag for p in nodes) 163*e1fe3e4aSElliott Hughes - (sumNodes.real * sumNodes.imag) / n 164*e1fe3e4aSElliott Hughes ) / (n - 1) 165*e1fe3e4aSElliott Hughes else: 166*e1fe3e4aSElliott Hughes self.varianceX = varianceX = 0 167*e1fe3e4aSElliott Hughes self.varianceY = varianceY = 0 168*e1fe3e4aSElliott Hughes self.covariance = covariance = 0 169*e1fe3e4aSElliott Hughes 170*e1fe3e4aSElliott Hughes StatisticsBase._update(self) 171*e1fe3e4aSElliott Hughes 172*e1fe3e4aSElliott Hughes 173*e1fe3e4aSElliott Hughesdef _test(glyphset, upem, glyphs, quiet=False, *, control=False): 174*e1fe3e4aSElliott Hughes from fontTools.pens.transformPen import TransformPen 175*e1fe3e4aSElliott Hughes from fontTools.misc.transform import Scale 176*e1fe3e4aSElliott Hughes 177*e1fe3e4aSElliott Hughes wght_sum = 0 178*e1fe3e4aSElliott Hughes wght_sum_perceptual = 0 179*e1fe3e4aSElliott Hughes wdth_sum = 0 180*e1fe3e4aSElliott Hughes slnt_sum = 0 181*e1fe3e4aSElliott Hughes slnt_sum_perceptual = 0 182*e1fe3e4aSElliott Hughes for glyph_name in glyphs: 183*e1fe3e4aSElliott Hughes glyph = glyphset[glyph_name] 184*e1fe3e4aSElliott Hughes if control: 185*e1fe3e4aSElliott Hughes pen = StatisticsControlPen(glyphset=glyphset) 186*e1fe3e4aSElliott Hughes else: 187*e1fe3e4aSElliott Hughes pen = StatisticsPen(glyphset=glyphset) 188*e1fe3e4aSElliott Hughes transformer = TransformPen(pen, Scale(1.0 / upem)) 189*e1fe3e4aSElliott Hughes glyph.draw(transformer) 190*e1fe3e4aSElliott Hughes 191*e1fe3e4aSElliott Hughes area = abs(pen.area) 192*e1fe3e4aSElliott Hughes width = glyph.width 193*e1fe3e4aSElliott Hughes wght_sum += area 194*e1fe3e4aSElliott Hughes wght_sum_perceptual += pen.area * width 195*e1fe3e4aSElliott Hughes wdth_sum += width 196*e1fe3e4aSElliott Hughes slnt_sum += pen.slant 197*e1fe3e4aSElliott Hughes slnt_sum_perceptual += pen.slant * width 198*e1fe3e4aSElliott Hughes 199*e1fe3e4aSElliott Hughes if quiet: 200*e1fe3e4aSElliott Hughes continue 201*e1fe3e4aSElliott Hughes 202*e1fe3e4aSElliott Hughes print() 203*e1fe3e4aSElliott Hughes print("glyph:", glyph_name) 204*e1fe3e4aSElliott Hughes 205*e1fe3e4aSElliott Hughes for item in [ 206*e1fe3e4aSElliott Hughes "area", 207*e1fe3e4aSElliott Hughes "momentX", 208*e1fe3e4aSElliott Hughes "momentY", 209*e1fe3e4aSElliott Hughes "momentXX", 210*e1fe3e4aSElliott Hughes "momentYY", 211*e1fe3e4aSElliott Hughes "momentXY", 212*e1fe3e4aSElliott Hughes "meanX", 213*e1fe3e4aSElliott Hughes "meanY", 214*e1fe3e4aSElliott Hughes "varianceX", 215*e1fe3e4aSElliott Hughes "varianceY", 216*e1fe3e4aSElliott Hughes "stddevX", 217*e1fe3e4aSElliott Hughes "stddevY", 218*e1fe3e4aSElliott Hughes "covariance", 219*e1fe3e4aSElliott Hughes "correlation", 220*e1fe3e4aSElliott Hughes "slant", 221*e1fe3e4aSElliott Hughes ]: 222*e1fe3e4aSElliott Hughes print("%s: %g" % (item, getattr(pen, item))) 223*e1fe3e4aSElliott Hughes 224*e1fe3e4aSElliott Hughes if not quiet: 225*e1fe3e4aSElliott Hughes print() 226*e1fe3e4aSElliott Hughes print("font:") 227*e1fe3e4aSElliott Hughes 228*e1fe3e4aSElliott Hughes print("weight: %g" % (wght_sum * upem / wdth_sum)) 229*e1fe3e4aSElliott Hughes print("weight (perceptual): %g" % (wght_sum_perceptual / wdth_sum)) 230*e1fe3e4aSElliott Hughes print("width: %g" % (wdth_sum / upem / len(glyphs))) 231*e1fe3e4aSElliott Hughes slant = slnt_sum / len(glyphs) 232*e1fe3e4aSElliott Hughes print("slant: %g" % slant) 233*e1fe3e4aSElliott Hughes print("slant angle: %g" % -degrees(atan(slant))) 234*e1fe3e4aSElliott Hughes slant_perceptual = slnt_sum_perceptual / wdth_sum 235*e1fe3e4aSElliott Hughes print("slant (perceptual): %g" % slant_perceptual) 236*e1fe3e4aSElliott Hughes print("slant (perceptual) angle: %g" % -degrees(atan(slant_perceptual))) 237*e1fe3e4aSElliott Hughes 238*e1fe3e4aSElliott Hughes 239*e1fe3e4aSElliott Hughesdef main(args): 240*e1fe3e4aSElliott Hughes """Report font glyph shape geometricsl statistics""" 241*e1fe3e4aSElliott Hughes 242*e1fe3e4aSElliott Hughes if args is None: 243*e1fe3e4aSElliott Hughes import sys 244*e1fe3e4aSElliott Hughes 245*e1fe3e4aSElliott Hughes args = sys.argv[1:] 246*e1fe3e4aSElliott Hughes 247*e1fe3e4aSElliott Hughes import argparse 248*e1fe3e4aSElliott Hughes 249*e1fe3e4aSElliott Hughes parser = argparse.ArgumentParser( 250*e1fe3e4aSElliott Hughes "fonttools pens.statisticsPen", 251*e1fe3e4aSElliott Hughes description="Report font glyph shape geometricsl statistics", 252*e1fe3e4aSElliott Hughes ) 253*e1fe3e4aSElliott Hughes parser.add_argument("font", metavar="font.ttf", help="Font file.") 254*e1fe3e4aSElliott Hughes parser.add_argument("glyphs", metavar="glyph-name", help="Glyph names.", nargs="*") 255*e1fe3e4aSElliott Hughes parser.add_argument( 256*e1fe3e4aSElliott Hughes "-y", 257*e1fe3e4aSElliott Hughes metavar="<number>", 258*e1fe3e4aSElliott Hughes help="Face index into a collection to open. Zero based.", 259*e1fe3e4aSElliott Hughes ) 260*e1fe3e4aSElliott Hughes parser.add_argument( 261*e1fe3e4aSElliott Hughes "-c", 262*e1fe3e4aSElliott Hughes "--control", 263*e1fe3e4aSElliott Hughes action="store_true", 264*e1fe3e4aSElliott Hughes help="Use the control-box pen instead of the Green therem.", 265*e1fe3e4aSElliott Hughes ) 266*e1fe3e4aSElliott Hughes parser.add_argument( 267*e1fe3e4aSElliott Hughes "-q", "--quiet", action="store_true", help="Only report font-wide statistics." 268*e1fe3e4aSElliott Hughes ) 269*e1fe3e4aSElliott Hughes parser.add_argument( 270*e1fe3e4aSElliott Hughes "--variations", 271*e1fe3e4aSElliott Hughes metavar="AXIS=LOC", 272*e1fe3e4aSElliott Hughes default="", 273*e1fe3e4aSElliott Hughes help="List of space separated locations. A location consist in " 274*e1fe3e4aSElliott Hughes "the name of a variation axis, followed by '=' and a number. E.g.: " 275*e1fe3e4aSElliott Hughes "wght=700 wdth=80. The default is the location of the base master.", 276*e1fe3e4aSElliott Hughes ) 277*e1fe3e4aSElliott Hughes 278*e1fe3e4aSElliott Hughes options = parser.parse_args(args) 279*e1fe3e4aSElliott Hughes 280*e1fe3e4aSElliott Hughes glyphs = options.glyphs 281*e1fe3e4aSElliott Hughes fontNumber = int(options.y) if options.y is not None else 0 282*e1fe3e4aSElliott Hughes 283*e1fe3e4aSElliott Hughes location = {} 284*e1fe3e4aSElliott Hughes for tag_v in options.variations.split(): 285*e1fe3e4aSElliott Hughes fields = tag_v.split("=") 286*e1fe3e4aSElliott Hughes tag = fields[0].strip() 287*e1fe3e4aSElliott Hughes v = int(fields[1]) 288*e1fe3e4aSElliott Hughes location[tag] = v 289*e1fe3e4aSElliott Hughes 290*e1fe3e4aSElliott Hughes from fontTools.ttLib import TTFont 291*e1fe3e4aSElliott Hughes 292*e1fe3e4aSElliott Hughes font = TTFont(options.font, fontNumber=fontNumber) 293*e1fe3e4aSElliott Hughes if not glyphs: 294*e1fe3e4aSElliott Hughes glyphs = font.getGlyphOrder() 295*e1fe3e4aSElliott Hughes _test( 296*e1fe3e4aSElliott Hughes font.getGlyphSet(location=location), 297*e1fe3e4aSElliott Hughes font["head"].unitsPerEm, 298*e1fe3e4aSElliott Hughes glyphs, 299*e1fe3e4aSElliott Hughes quiet=options.quiet, 300*e1fe3e4aSElliott Hughes control=options.control, 301*e1fe3e4aSElliott Hughes ) 302*e1fe3e4aSElliott Hughes 303*e1fe3e4aSElliott Hughes 304*e1fe3e4aSElliott Hughesif __name__ == "__main__": 305*e1fe3e4aSElliott Hughes import sys 306*e1fe3e4aSElliott Hughes 307*e1fe3e4aSElliott Hughes main(sys.argv[1:]) 308