|
| 1 | +from beziers.path import BezierPath |
| 2 | + |
| 3 | +from fontbakery.callable import condition, check |
| 4 | +from fontbakery.checkrunner import FAIL, PASS, WARN, Section |
| 5 | +from fontbakery.fonts_profile import profile_factory # NOQA pylint: disable=unused-import |
| 6 | +from fontbakery.message import Message |
| 7 | +from fontbakery.utils import pretty_print_list |
| 8 | +import math |
| 9 | + |
| 10 | + |
| 11 | +ALIGNMENT_MISS_EPSILON = 2 # Two point lee-way on alignment misses |
| 12 | +SHORT_PATH_EPSILON = 0.006 # <0.6% of total outline length makes a short segment |
| 13 | +SHORT_PATH_ABSOLUTE_EPSILON = 3 # 3 units is a small outline |
| 14 | +COLINEAR_EPSILON = 0.1 # Radians |
| 15 | +JAG_AREA_EPSILON = 0.05 # <5% of total outline area makes a jaggy segment |
| 16 | +JAG_ANGLE = 0.25 # Radians |
| 17 | +FALSE_POSITIVE_CUTOFF = 100 # More than this and we don't make a report |
| 18 | + |
| 19 | + |
| 20 | +@condition |
| 21 | +def outlines_dict(ttFont): |
| 22 | + return {g: BezierPath.fromFonttoolsGlyph(ttFont, g) for g in ttFont.getGlyphOrder()} |
| 23 | + |
| 24 | + |
| 25 | +def close_but_not_on(yExpected, yTrue, tolerance): |
| 26 | + if yExpected == yTrue: |
| 27 | + return False |
| 28 | + if abs(yExpected - yTrue) <= tolerance: |
| 29 | + return True |
| 30 | + return False |
| 31 | + |
| 32 | + |
| 33 | +@check( |
| 34 | + id = "com.google.fonts/check/outline_alignment_miss", |
| 35 | + rationale = f""" |
| 36 | + This test heuristically looks for on-curve points which are close to, but do not sit on, significant boundary coordinates. For example, a point which has a Y-coordinate of 1 or -1 might be a misplaced baseline point. As well as the baseline, the test also checks for points near the x-height (but only for lower case Latin letters), cap-height, ascender and descender Y coordinates. |
| 37 | +
|
| 38 | + Not all such misaligned curve points are a mistake, and sometimes the design may call for points in locations near the boundaries. As this test is liable to generate significant numbers of false positives, the test will pass if there are more than {FALSE_POSITIVE_CUTOFF} reported misalignments. |
| 39 | + """, |
| 40 | + conditions = ["outlines_dict"] |
| 41 | +) |
| 42 | +def com_google_fonts_check_outline_alignment_miss(ttFont, outlines_dict): |
| 43 | + """Are there any misaligned on-curve points?""" |
| 44 | + alignments = { |
| 45 | + "baseline": 0, |
| 46 | + "x-height": ttFont["OS/2"].sxHeight, |
| 47 | + "cap-height": ttFont["OS/2"].sCapHeight, |
| 48 | + "ascender": ttFont["OS/2"].sTypoAscender, |
| 49 | + "descender": ttFont["OS/2"].sTypoDescender, |
| 50 | + } |
| 51 | + warnings = [] |
| 52 | + for glyphname, outlines in outlines_dict.items(): |
| 53 | + for p in outlines: |
| 54 | + for node in p.asNodelist(): |
| 55 | + if node.type == "offcurve": |
| 56 | + continue |
| 57 | + for line, yExpected in alignments.items(): |
| 58 | + # skip x-height check for caps |
| 59 | + if line == "x-height" and ( |
| 60 | + len(glyphname) > 1 or glyphname[0].isupper() |
| 61 | + ): |
| 62 | + continue |
| 63 | + if close_but_not_on(yExpected, node.y, ALIGNMENT_MISS_EPSILON): |
| 64 | + warnings.append(f"{glyphname}: X={node.x},Y={node.y}" |
| 65 | + f" (should be at {line} {yExpected}?)") |
| 66 | + if len(warnings) > FALSE_POSITIVE_CUTOFF: |
| 67 | + # Let's not waste time. |
| 68 | + yield PASS, ("So many Y-coordinates of points were close to" |
| 69 | + " boundaries that this was probably by design.") |
| 70 | + return |
| 71 | + |
| 72 | + if warnings: |
| 73 | + formatted_list = "\t* " + pretty_print_list(warnings, sep="\n\t* ") |
| 74 | + yield WARN,\ |
| 75 | + Message("found-misalignments", |
| 76 | + f"The following glyphs have on-curve points which" |
| 77 | + f" have potentially incorrect y coordinates:\n" |
| 78 | + f"{formatted_list}") |
| 79 | + else: |
| 80 | + yield PASS, "Y-coordinates of points fell on appropriate boundaries." |
| 81 | + |
| 82 | + |
| 83 | +@check( |
| 84 | + id = "com.google.fonts/check/outline_short_segments", |
| 85 | + rationale = f""" |
| 86 | + This test looks for outline segments which seem particularly short (less than {SHORT_PATH_EPSILON}%% of the overall path length). |
| 87 | +
|
| 88 | + This test is not run for variable fonts, as they may legitimately have short segments. As this test is liable to generate significant numbers of false positives, the test will pass if there are more than {FALSE_POSITIVE_CUTOFF} reported short segments. |
| 89 | + """, |
| 90 | + conditions = ["outlines_dict", |
| 91 | + "is_not_variable_font"] |
| 92 | +) |
| 93 | +def com_google_fonts_check_outline_short_segments(ttFont, outlines_dict): |
| 94 | + """Are any segments inordinately short?""" |
| 95 | + warnings = [] |
| 96 | + for glyphname, outlines in outlines_dict.items(): |
| 97 | + for p in outlines: |
| 98 | + outline_length = p.length |
| 99 | + segments = p.asSegments() |
| 100 | + if not segments: |
| 101 | + continue |
| 102 | + prev_was_line = len(segments[-1]) == 2 |
| 103 | + for seg in p.asSegments(): |
| 104 | + if math.isclose(seg.length, 0): # That's definitely wrong |
| 105 | + warnings.append(f"{glyphname} contains a short segment {seg}") |
| 106 | + elif ( |
| 107 | + seg.length < SHORT_PATH_ABSOLUTE_EPSILON |
| 108 | + or seg.length < SHORT_PATH_EPSILON * outline_length |
| 109 | + ) and (prev_was_line or len(seg) > 2): |
| 110 | + warnings.append(f"{glyphname} contains a short segment {seg}") |
| 111 | + prev_was_line = len(seg) == 2 |
| 112 | + if len(warnings) > FALSE_POSITIVE_CUTOFF: |
| 113 | + yield PASS, ("So many short segments were found" |
| 114 | + " that this was probably by design.") |
| 115 | + return |
| 116 | + |
| 117 | + if warnings: |
| 118 | + formatted_list = "\t* " + pretty_print_list(warnings, sep="\n\t* ") |
| 119 | + yield WARN,\ |
| 120 | + Message("found-short-segments", |
| 121 | + f"The following glyphs have segments which seem very short:\n" |
| 122 | + f"{formatted_list}") |
| 123 | + else: |
| 124 | + yield PASS, "No short segments were found." |
| 125 | + |
| 126 | + |
| 127 | +@check( |
| 128 | + id = "com.google.fonts/check/outline_colinear_vectors", |
| 129 | + rationale = """ |
| 130 | + This test looks for consecutive line segments which have the same angle. This normally happens if an outline point has been added by accident. |
| 131 | +
|
| 132 | + This test is not run for variable fonts, as they may legitimately have colinear vectors. |
| 133 | + """, |
| 134 | + conditions = ["outlines_dict", |
| 135 | + "is_not_variable_font"] |
| 136 | +) |
| 137 | +def com_google_fonts_check_outline_colinear_vectors(ttFont, outlines_dict): |
| 138 | + """Do any segments have colinear vectors?""" |
| 139 | + warnings = [] |
| 140 | + for glyphname, outlines in outlines_dict.items(): |
| 141 | + for p in outlines: |
| 142 | + segments = p.asSegments() |
| 143 | + if not segments: |
| 144 | + continue |
| 145 | + for i in range(0, len(segments)): |
| 146 | + prev = segments[i - 1] |
| 147 | + this = segments[i] |
| 148 | + if len(prev) == 2 and len(this) == 2: |
| 149 | + if ( |
| 150 | + abs(prev.tangentAtTime(0).angle - this.tangentAtTime(0).angle) |
| 151 | + < COLINEAR_EPSILON |
| 152 | + ): |
| 153 | + warnings.append(f"{glyphname}: {prev} -> {this}") |
| 154 | + if len(warnings) > FALSE_POSITIVE_CUTOFF: |
| 155 | + yield PASS, ("So many colinear vectors were found" |
| 156 | + " that this was probably by design.") |
| 157 | + return |
| 158 | + |
| 159 | + if warnings: |
| 160 | + formatted_list = "\t* " + pretty_print_list(sorted(set(warnings)), sep="\n\t* ") |
| 161 | + yield WARN,\ |
| 162 | + Message("found-colinear-vectors", |
| 163 | + f"The following glyphs have colinear vectors:\n" |
| 164 | + f"{formatted_list}") |
| 165 | + else: |
| 166 | + yield PASS, "No colinear vectors found." |
| 167 | + |
| 168 | + |
| 169 | +@check( |
| 170 | + id = "com.google.fonts/check/outline_jaggy_segments", |
| 171 | + rationale = """ |
| 172 | + This test heuristically detects outline segments which form a particularly small angle, indicative of an outline error. This may cause false positives in cases such as extreme ink traps, so should be regarded as advisory and backed up by manual inspection. |
| 173 | + """, |
| 174 | + conditions = ["outlines_dict", |
| 175 | + "is_not_variable_font"], |
| 176 | + misc_metadata = { |
| 177 | + 'request': 'https://github.com/googlefonts/fontbakery/issues/3064' |
| 178 | + } |
| 179 | +) |
| 180 | +def com_google_fonts_check_outline_jaggy_segments(ttFont, outlines_dict): |
| 181 | + """Do outlines contain any jaggy segments?""" |
| 182 | + warnings = [] |
| 183 | + for glyphname, outlines in outlines_dict.items(): |
| 184 | + for p in outlines: |
| 185 | + segments = p.asSegments() |
| 186 | + if not segments: |
| 187 | + continue |
| 188 | + for i in range(0, len(segments)): |
| 189 | + prev = segments[i - 1] |
| 190 | + this = segments[i] |
| 191 | + in_vector = prev.tangentAtTime(1) * -1 |
| 192 | + out_vector = this.tangentAtTime(0) |
| 193 | + if not (in_vector.magnitude * out_vector.magnitude): |
| 194 | + continue |
| 195 | + angle = (in_vector @ out_vector) / ( |
| 196 | + in_vector.magnitude * out_vector.magnitude |
| 197 | + ) |
| 198 | + if not (-1 <= angle <= 1): |
| 199 | + continue |
| 200 | + jag_angle = math.acos(angle) |
| 201 | + if abs(jag_angle) > JAG_ANGLE or jag_angle == 0: |
| 202 | + continue |
| 203 | + warnings.append(f"{glyphname}:" |
| 204 | + f" {prev}/{this} = {math.degrees(jag_angle)}") |
| 205 | + |
| 206 | + if warnings: |
| 207 | + formatted_list = "\t* " + pretty_print_list(sorted(warnings), sep="\n\t* ") |
| 208 | + yield WARN,\ |
| 209 | + Message("found-jaggy-segments", |
| 210 | + f"The following glyphs have jaggy segments:\n" |
| 211 | + f"{formatted_list}") |
| 212 | + else: |
| 213 | + yield PASS, "No jaggy segments found." |
| 214 | + |
| 215 | + |
| 216 | +@check( |
| 217 | + id = "com.google.fonts/check/outline_semi_vertical", |
| 218 | + rationale = """ |
| 219 | + This test detects line segments which are nearly, but not quite, exactly horizontal or vertical. Sometimes such lines are created by design, but often they are indicative of a design error. |
| 220 | +
|
| 221 | + This test is disabled for italic styles, which often contain nearly-upright lines. |
| 222 | + """, |
| 223 | + conditions = ["outlines_dict", |
| 224 | + "is_not_variable_font", |
| 225 | + "is_not_italic"] |
| 226 | +) |
| 227 | +def com_google_fonts_check_outline_semi_vertical(ttFont, outlines_dict): |
| 228 | + """Do outlines contain any semi-vertical or semi-horizontal lines?""" |
| 229 | + warnings = [] |
| 230 | + for glyphname, outlines in outlines_dict.items(): |
| 231 | + for p in outlines: |
| 232 | + segments = p.asSegments() |
| 233 | + if not segments: |
| 234 | + continue |
| 235 | + for s in segments: |
| 236 | + if len(s) != 2: |
| 237 | + continue |
| 238 | + angle = math.degrees((s.end - s.start).angle) |
| 239 | + for yExpected in [-180, -90, 0, 90, 180]: |
| 240 | + if close_but_not_on(angle, yExpected, 0.5): |
| 241 | + warnings.append(f"{glyphname}: {s}") |
| 242 | + |
| 243 | + if warnings: |
| 244 | + formatted_list = "\t* " + pretty_print_list(sorted(warnings), sep="\n\t* ") |
| 245 | + yield WARN,\ |
| 246 | + Message("found-semi-vertical", |
| 247 | + f"The following glyphs have semi-vertical/semi-horizontal lines:\n" |
| 248 | + f"{formatted_list}") |
| 249 | + else: |
| 250 | + yield PASS, "No semi-horizontal/semi-vertical lines found." |
| 251 | + |
| 252 | + |
| 253 | +OUTLINE_PROFILE_IMPORTS = ( |
| 254 | + ".", |
| 255 | + ("shared_conditions",), |
| 256 | +) |
| 257 | +profile_imports = (OUTLINE_PROFILE_IMPORTS,) |
| 258 | +profile = profile_factory(default_section=Section("Outline Correctness Checks")) |
| 259 | +OUTLINE_PROFILE_CHECKS = [ |
| 260 | + "com.google.fonts/check/outline_alignment_miss", |
| 261 | + "com.google.fonts/check/outline_short_segments", |
| 262 | + "com.google.fonts/check/outline_colinear_vectors", |
| 263 | + "com.google.fonts/check/outline_jaggy_segments", |
| 264 | + "com.google.fonts/check/outline_semi_vertical", |
| 265 | +] |
| 266 | + |
| 267 | +profile.auto_register(globals()) |
| 268 | +profile.test_expected_checks(OUTLINE_PROFILE_CHECKS, exclusive=True) |
0 commit comments