Skip to content

Commit a78cb80

Browse files
authored
new outline checks:
* com.google.fonts/check/outline_alignment_miss "Check for outline points near to, but not on, significant Y-axis boundaries." * com.google.fonts/check/outline_short_segments "Check for outline segments which are suspiciously short." * com.google.fonts/check/outline_colinear_vectors "Check for colinear segments in outlines." * com.google.fonts/check/outline_jaggy_segments "Check for segments with a particularly small angle." * com.google.fonts/check/outline_semi_vertical "Check for semi-vertical and semi-horizontal lines." The checks are placed on a new `outline` profile which is entirely included in the `universal` profile by default. (fixes #3064)
1 parent 8cf2698 commit a78cb80

File tree

10 files changed

+5749
-1
lines changed

10 files changed

+5749
-1
lines changed

CHANGELOG.md

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ Below are the most important changes from each release.
22
A more detailed list of changes is available in the corresponding milestones for each release in the Github issue tracker (https://github.com/googlefonts/fontbakery/milestones?state=closed).
33

44

5-
## 0.7.32 (2020-Oct-??)
5+
## 0.7.32 (2020-Nov-12)
66
### Note-worthy code changes
77
- We now keep a local copy of the Google Fonts Axis Registry textproto files so that the checks do not need to keep always fetch them online at runtime. These files should not change too often, but we should be careful to check for updates on our FontBakery releases. (issue #3022)
88
- Now Travis is configured to use pinned versions of dependencies as described on requirements.txt (issue #3058)
@@ -15,6 +15,11 @@ A more detailed list of changes is available in the corresponding milestones for
1515
- **[com.google.fonts/check/metadata/gf-axisregistry_bounds]:** VF axes have ranges compliant to the bounds specified on the GF Axis Registry (issue #3022)
1616
- **[com.google.fonts/check/STAT/gf-axisregistry]:** Check that particle names and values on STAT table match the fallback names in each axis registry at the Google Fonts Axis Registry (issue #3022)
1717
- **[com.google.fonts/check/glyf_nested_components]:** Check that components do not reference glyphs which are themselves compontents (issue #2961)
18+
- **[com.google.fonts/check/outline_alignment_miss]:** Check for outline points near to, but not on, significant Y-axis boundaries. (PR #3088)
19+
- **[com.google.fonts/check/outline_short_segments]:** Check for outline segments which are suspiciously short. (PR #3088)
20+
- **[com.google.fonts/check/outline_colinear_vectors]:** Check for colinear segments in outlines. (PR #3088)
21+
- **[com.google.fonts/check/outline_jaggy_segments]:** Check for segments with a particularly small angle. (issue #3064)
22+
- **[com.google.fonts/check/outline_semi_vertical]:** Check for semi-vertical and semi-horizontal lines. (PR #3088)
1823

1924
### Changes to existing checks
2025
- **[com.google.fonts/check/family/win_ascent_and_descent]**: Skip if font is cjk

Lib/fontbakery/profiles/outline.py

+268
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
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)

Lib/fontbakery/profiles/shared_conditions.py

+17
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,10 @@ def vmetrics(ttFonts):
204204
def is_variable_font(ttFont):
205205
return "fvar" in ttFont.keys()
206206

207+
@condition
208+
def is_not_variable_font(ttFont):
209+
return "fvar" not in ttFont.keys()
210+
207211
@condition
208212
def VFs(ttFonts):
209213
"""Returns a list of font files which are recognized as variable fonts"""
@@ -375,3 +379,16 @@ def is_indic_font(ttFont):
375379
#otherwise:
376380
return False
377381

382+
383+
@condition
384+
def is_italic(ttFont):
385+
return (
386+
ttFont["OS/2"].fsSelection & 0x1
387+
or ("post" in ttFont and ttFont["post"].italicAngle)
388+
or ttFont["head"].macStyle & 0x2
389+
)
390+
391+
392+
@condition
393+
def is_not_italic(ttFont):
394+
return not is_italic(ttFont)

Lib/fontbakery/profiles/universal.py

+3
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@
55
from fontbakery.message import Message
66
from fontbakery.fonts_profile import profile_factory
77
from fontbakery.profiles.opentype import OPENTYPE_PROFILE_CHECKS
8+
from fontbakery.profiles.outline import OUTLINE_PROFILE_CHECKS
89

910
profile_imports = ('fontbakery.profiles.opentype',
11+
'fontbakery.profiles.outline',
1012
'.shared_conditions')
1113
profile = profile_factory(default_section=Section("Universal"))
1214

@@ -23,6 +25,7 @@
2325

2426
UNIVERSAL_PROFILE_CHECKS = \
2527
OPENTYPE_PROFILE_CHECKS + \
28+
OUTLINE_PROFILE_CHECKS + \
2629
THIRDPARTY_CHECKS + \
2730
SUPERFAMILY_CHECKS + [
2831
'com.google.fonts/check/name/trailing_spaces',

data/test/wonky_paths/README.txt

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
This is a copy of Source Sans Pro by Paul Hunt, modified as a helper for
2+
path tests. The glyphs have the following properties:
3+
4+
/A base is slightly below the baseline
5+
/B stem is not quite vertical
6+
/C has colinear points on top right terminal
7+
/D has a small path in the bottom left of the counter
8+
/x is not quite aligned to the x height
9+
/E has an erroneous ink trap which should be flagged
10+
/F has an acceptable ink trap which should not be flagged

0 commit comments

Comments
 (0)