Font that can be used for validating baseline alignments.
sajidanwar.com/misc/baseline-diagnostic-font/
1import re
2from font import Font, FontBaseline, FontBaselineStyle, FontGlyph, FontGlyphKind, build_baselines_font
3from jinja2 import Environment, FileSystemLoader
4from textwrap import dedent, indent
5from typing import Dict, List
6
7AUTHOR = "Sajid Anwar"
8
9def main():
10 glyphs = [
11 FontGlyph("x", FontGlyphKind.PAIR_LAYOUT, ["x-height", "alphabetic"]),
12 FontGlyph("χ", FontGlyphKind.PAIR_LABELED, ["x-height", "alphabetic"]),
13 FontGlyph("B", FontGlyphKind.PAIR_LAYOUT, ["cap-height", "alphabetic"]),
14 FontGlyph("β", FontGlyphKind.PAIR_LABELED, ["cap-height", "alphabetic"]),
15 FontGlyph("口", FontGlyphKind.PAIR_LAYOUT, ["ideographic-over", "ideographic-under"]),
16 FontGlyph("日", FontGlyphKind.PAIR_LABELED, ["ideographic-over", "ideographic-under"]),
17 FontGlyph("中", FontGlyphKind.PAIR_LAYOUT, ["ideographic-face-over", "ideographic-face-under"]),
18 FontGlyph("田", FontGlyphKind.PAIR_LABELED, ["ideographic-face-over", "ideographic-face-under"]),
19 FontGlyph("अ", FontGlyphKind.PAIR_LAYOUT, ["hanging", "alphabetic"]),
20 FontGlyph("आ", FontGlyphKind.PAIR_LABELED, ["hanging", "alphabetic"]),
21 FontGlyph("+", FontGlyphKind.PAIR_LAYOUT, ["math", "alphabetic"]),
22 FontGlyph("±", FontGlyphKind.PAIR_LABELED, ["math", "alphabetic"]),
23 FontGlyph("█", FontGlyphKind.EMBOX_FILLED),
24 FontGlyph("□", FontGlyphKind.EMBOX_OUTLINE),
25 ]
26
27 fonts = []
28 fonts.append(Font(
29 name="BaselineDiagnostic",
30 description=(dedent("""\
31 Font that can be used for validating baseline alignments. Given the embedded
32 text in the font, this should be used with very large font sizes. There are
33 two glyphs in the font.""")),
34 baselines=[
35 FontBaseline("ascent", 800, "OS/2", "sTypoAscender", None, None),
36 FontBaseline("ascent", 800, "hhea", "ascent", None, None),
37 FontBaseline("ascent", 800, "vhea", "ascent", None, None),
38 FontBaseline("ideographic-over", 750, "BASE", "idtp", "IDEOGRAPHIC-OVER", FontBaselineStyle.SOLID),
39 FontBaseline("hanging", 650, "BASE", "hang", "HANGING", FontBaselineStyle.SOLID),
40 FontBaseline("ideographic-face-over", 650, "BASE", "icft", "IDEO-FACE-OVER", None),
41 FontBaseline("cap-height", 550, "OS/2", "sCapHeight", "CAP-HEIGHT", FontBaselineStyle.SOLID),
42 FontBaseline("math", 450, "BASE", "math", "MATH", FontBaselineStyle.SOLID),
43 FontBaseline("central", 350, None, None, "CENTRAL", FontBaselineStyle.SOLID),
44 FontBaseline("em-middle", 300, None, None, None, FontBaselineStyle.DASHED),
45 FontBaseline("x-height", 250, "OS/2", "sxHeight", "X-HEIGHT", FontBaselineStyle.SOLID),
46 FontBaseline("x-middle", 150, None, None, "X-MIDDLE", FontBaselineStyle.SOLID),
47 FontBaseline("alphabetic", 50, "BASE", "romn", "ALPHABETIC", FontBaselineStyle.SOLID),
48 FontBaseline("ideographic-face-under", 50, "BASE", "icfb", "IDEO-FACE-UNDER", None),
49 FontBaseline("zero", 0, None, None, None, FontBaselineStyle.DASHED),
50 FontBaseline("ideographic-under", -50, "BASE", "ideo", "IDEOGRAPHIC-UNDER", FontBaselineStyle.SOLID),
51 FontBaseline("descent", -200, "OS/2", "sTypoDescender", None, None),
52 FontBaseline("descent", -200, "hhea", "descent", None, None),
53 FontBaseline("descent", -200, "vhea", "descent", None, None),
54 ],
55 glyphs=glyphs,
56 ))
57 fonts.append(Font(
58 name="BaselineDiagnosticAlphabeticZero",
59 description=(dedent("""\
60 Same as the "BaselineDiagnostic" font, but uses the common alphabetic baseline
61 of 0. This also results in the x-middle baseline being at 125.""")),
62 baselines=[
63 FontBaseline("ascent", 800, "OS/2", "sTypoAscender", None, None),
64 FontBaseline("ascent", 800, "hhea", "ascent", None, None),
65 FontBaseline("ascent", 800, "vhea", "ascent", None, None),
66 FontBaseline("ideographic-over", 750, "BASE", "idtp", "IDEOGRAPHIC-OVER", FontBaselineStyle.SOLID),
67 FontBaseline("hanging", 650, "BASE", "hang", "HANGING", FontBaselineStyle.SOLID),
68 FontBaseline("ideographic-face-over", 650, "BASE", "icft", "IDEO-FACE-OVER", None),
69 FontBaseline("cap-height", 550, "OS/2", "sCapHeight", "CAP-HEIGHT", FontBaselineStyle.SOLID),
70 FontBaseline("math", 450, "BASE", "math", "MATH", FontBaselineStyle.SOLID),
71 FontBaseline("central", 350, None, None, "CENTRAL", FontBaselineStyle.SOLID),
72 FontBaseline("em-middle", 300, None, None, None, FontBaselineStyle.DASHED),
73 FontBaseline("x-height", 250, "OS/2", "sxHeight", "X-HEIGHT", FontBaselineStyle.SOLID),
74 FontBaseline("x-middle", 125, None, None, "X-MIDDLE", FontBaselineStyle.SOLID),
75 FontBaseline("alphabetic", 0, "BASE", "romn", None, FontBaselineStyle.DASHED),
76 FontBaseline("ideographic-face-under", 50, "BASE", "icfb", "IDEO-FACE-UNDER", None),
77 FontBaseline("zero", 0, None, None, None, None),
78 FontBaseline("ideographic-under", -50, "BASE", "ideo", "IDEOGRAPHIC-UNDER", FontBaselineStyle.SOLID),
79 FontBaseline("descent", -200, "OS/2", "sTypoDescender", None, None),
80 FontBaseline("descent", -200, "hhea", "descent", None, None),
81 FontBaseline("descent", -200, "vhea", "descent", None, None),
82 ],
83 glyphs=glyphs,
84 ))
85
86 write_font_files(fonts)
87 write_font_stylesheet(fonts)
88 write_font_html(fonts)
89 write_font_readme(fonts)
90 write_font_license()
91
92
93def write_font_files(fonts: List[Font]):
94 for font in fonts:
95 build_baselines_font(font, f'dist/{font.name}.ttf')
96
97
98def write_font_stylesheet(fonts: List[Font]):
99 out_path = "dist/baseline-diagnostic-font.css"
100 with open(out_path, "w") as f:
101 for font in fonts:
102 template = dedent('''
103 @font-face {{
104 /**
105 {description}
106 */
107 font-family: "{name}";
108 src: url('./{name}.ttf') format('opentype');
109 }}
110
111 :root {{
112 /**
113 * Variables representing the positions of the given baselines/metrics from the top
114 * of the em-box as a percentage of the em-height. The top of the em-box (ascent) has
115 * a position of 0, and the bottom of the em-box (descent) has a position of 1.
116 */
117 {variables}
118 }}
119 ''')
120 description = indent(font.description, ' * ')
121 positions: Dict[str, int] = {}
122 ascent = None
123 descent = None
124
125 for baseline in font.baselines:
126 if baseline.id in positions and positions[baseline.id] != baseline.position:
127 raise ValueError(f"Baseline metric {baseline.id} has different position values")
128 if baseline.id == 'ascent':
129 ascent = baseline.position
130 if baseline.id == 'descent':
131 descent = -1 * baseline.position
132 positions[baseline.id] = baseline.position
133
134 if not ascent or not descent:
135 raise ValueError(f"Required ascent / descent but got {ascent} / {descent}")
136
137 variables = []
138 for baseline, position in positions.items():
139 variables.append(
140 "--{font_name}-{baseline}: calc(1 - ({position} + {descent}) / {height});".format(
141 font_name=dashing(font.name),
142 baseline=baseline,
143 position=position,
144 descent=descent,
145 height=(ascent + descent),
146 )
147 )
148
149 f.write(template.format(
150 name=font.name,
151 description=description,
152 variables=indent('\n'.join(variables), ' '),
153 ))
154 print(f"Wrote stylesheet at {out_path}")
155
156
157def prepare_template_data(fonts: List[Font]) -> dict:
158 font = fonts[0]
159 font_az = fonts[1]
160
161 dfn_tooltips = {
162 'central': 'Computed at halfway between ideographic-under and ideographic-over',
163 'em-middle': 'Computed at halfway between ascent and descent',
164 'x-middle': 'Computed at halfway between alphabetic and x-height',
165 'zero': 'Zero coordinate',
166 }
167
168 seen: Dict[str, dict] = {}
169 baseline_table = []
170 for b in font.baselines:
171 if b.id not in seen:
172 entry = {'id': b.id, 'position': b.position, 'base': '', 'os2': '', 'hhea': '', 'tooltip': dfn_tooltips.get(b.id)}
173 seen[b.id] = entry
174 baseline_table.append(entry)
175 if b.table == 'BASE': seen[b.id]['base'] = b.name
176 elif b.table == 'OS/2': seen[b.id]['os2'] = b.name
177 elif b.table == 'hhea': seen[b.id]['hhea'] = b.name
178
179 az_positions = {}
180 for b in font_az.baselines:
181 if b.id not in az_positions:
182 az_positions[b.id] = b.position
183 az_diffs = [{'id': e['id'], 'position': az_positions[e['id']]}
184 for e in baseline_table
185 if e['id'] in az_positions and az_positions[e['id']] != e['position']]
186 az_diffs.sort(key=lambda baseline: baseline['id'])
187
188 pair_map: Dict[tuple, dict] = {}
189 pair_order = []
190 for g in font.glyphs:
191 if g.baseline_ids:
192 key = tuple(g.baseline_ids)
193 if key not in pair_map:
194 pair_map[key] = {'ids': list(key), 'layout': None, 'labeled': None}
195 pair_order.append(key)
196 glyph_data = {'char': g.char, 'codepoint': f'U+{ord(g.char):04X}'}
197 if g.kind == FontGlyphKind.PAIR_LAYOUT: pair_map[key]['layout'] = glyph_data
198 elif g.kind == FontGlyphKind.PAIR_LABELED: pair_map[key]['labeled'] = glyph_data
199
200 def embox_data(kind):
201 g = next((g for g in font.glyphs if g.kind == kind), None)
202 return {'char': g.char, 'codepoint': f'U+{ord(g.char):04X}'} if g else None
203
204 return {
205 'font_name': font.name,
206 'font_az_name': font_az.name,
207 'baseline_table': baseline_table,
208 'az_diffs': az_diffs,
209 'pairs': [pair_map[k] for k in pair_order],
210 'embox_filled': embox_data(FontGlyphKind.EMBOX_FILLED),
211 'embox_outline': embox_data(FontGlyphKind.EMBOX_OUTLINE),
212 }
213
214
215def _jinja_env():
216 return Environment(loader=FileSystemLoader('templates'), trim_blocks=True, lstrip_blocks=True)
217
218
219def write_font_html(fonts: List[Font]):
220 out_path = "dist/index.html"
221 data = prepare_template_data(fonts)
222 html = _jinja_env().get_template('index.html.jinja').render(**data)
223 with open(out_path, 'w') as f:
224 f.write(html)
225 print(f"Wrote HTML at {out_path}")
226
227
228def write_font_readme(fonts: List[Font]):
229 out_path = "dist/README.md"
230 data = prepare_template_data(fonts)
231 md = _jinja_env().get_template('README.md.jinja').render(**data)
232 with open(out_path, 'w') as f:
233 f.write(md)
234 print(f"Wrote README at {out_path}")
235
236
237def write_font_license():
238 out_path = "dist/LICENSE.md"
239 md = _jinja_env().get_template('LICENSE.md.jinja').render(author=AUTHOR)
240 with open(out_path, 'w') as f:
241 f.write(md)
242 print(f"Wrote OFL 1.1 license at {out_path}")
243
244def dashing(value: str):
245 return re.sub(r'(?<!^)(?=[A-Z])', '-', value).lower()
246
247if __name__ == "__main__":
248 main()