Repository navigation
Expand file tree
/
Copy pathgen_font_metrics.py
More file actions
193 lines (163 loc) · 9.13 KB
/
Copy pathgen_font_metrics.py
File metadata and controls
193 lines (163 loc) · 9.13 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
#
# Copyright 2026 Ramil Nugmanov <nougmanoff@protonmail.com>
# This file is part of chython.
#
# chython is free software; you can redistribute it and/or modify
# it under the terms of the GNU Lesser General Public License as published by
# the Free Software Foundation; either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, see <https://www.gnu.org/licenses/>.
#
"""Derive `chython/depict/metrics/*.tsv` from the AFM files matplotlib ships.
WHY A SHIPPED TABLE. The depictor must know a label's width before it places anything -- to centre a
symbol, to trim a bond at the label's box, to size a page. Asking the renderer at draw time is not an
option: SVG has no measurement API a library can call, and the three backends would each measure
differently. So the metrics are DATA, generated once, checked in, and identical for all three.
WHY THESE TWO FAMILIES. Helvetica and Times-Roman are PDF base-14: the PDF backend names them and
embeds no font file, PostScript has them as standard fonts, and SVG resolves them through
`font-family`. One table is exact in all three outputs, which no shipped TTF would be.
WHY MATPLOTLIB IS NOT A DEPENDENCY. It is the source of the AFMs, so it is a dependency of THIS
SCRIPT, run by a developer, and of one drift test that skips when it is absent -- the `needs_inchi`
shape. Nothing at runtime imports it.
Adobe's AFM licence permits redistribution provided the copyright notices are retained, and each
generated TSV carries the notice in its header for that reason.
"""
from argparse import ArgumentParser
from pathlib import Path
# Three parts, in this order, because Adobe's readme.txt requires all three: the copyright notice
# retained; the permission paragraph UNMODIFIED (it says so itself, so it is a licence condition and
# not prose we may tighten); and a prominent note that this file is a modification -- a derived
# numeric extraction -- rather than an Adobe AFM. Do not reflow or reword ADOBE_PARAGRAPH.
ADOBE_COPYRIGHT = 'Copyright 1985-1997 Adobe Systems Incorporated. All rights reserved.'
ADOBE_PARAGRAPH = (
'This file and the 14 PostScript(R) AFM files it accompanies may be used, copied, and '
'distributed for any purpose and without charge, with or without modification, provided that '
'all copyright notices are retained; that the AFM files are not distributed without this file; '
'that all modifications to this file or any of the AFM files are prominently noted in the '
'modified file(s); and that this paragraph is not modified. Adobe Systems has no responsibility '
'or obligation to support the use of the AFM files.')
ADOBE_DERIVATION = ('MODIFICATION NOTICE: this file is not an Adobe AFM. It is a derived numeric '
'table extracted from Helvetica.afm / Times-Roman.afm as distributed with '
'matplotlib (mpl-data/fonts/pdfcorefonts), generated by '
'scripts/gen_font_metrics.py. Only the glyphs chython labels need are kept, '
'and only the advance width and bounding box of each.')
# family -> AFM basename. The two PDF base-14 faces the depictor names; adding a third means adding it
# here AND in `metrics.FAMILIES` AND in `PDF_BASE_FONT`, and the test below checks the three agree.
FAMILIES = {'helvetica': 'Helvetica.afm', 'times': 'Times-Roman.afm'}
HEADER = ('char', 'name', 'wx', 'llx', 'lly', 'urx', 'ury')
def afm_directory() -> Path:
from matplotlib import get_data_path
return Path(get_data_path(), 'fonts', 'pdfcorefonts')
def read_afm(path: Path) -> list:
"""Parse an AFM's CharMetrics block.
Lines look like `C 67 ; WX 722 ; N C ; B 44 -19 681 737 ;` -- the code, the advance width, the
glyph name and the bounding box, all in 1/1000 em. `C -1` means the glyph is in the font but not
in the default encoding, and those are kept: `minus` is `C -1` in Helvetica and a charge sign needs
it. The key is the glyph NAME, and encoding is this script's problem, not the runtime's.
"""
rows = []
inside = False
for raw in path.read_text(encoding='latin-1').splitlines():
line = raw.strip()
if line.startswith('StartCharMetrics'):
inside = True
continue
if line.startswith('EndCharMetrics'):
break
if not inside or not line:
continue
fields = {}
for part in line.split(';'):
part = part.strip()
if not part:
continue
key, _, value = part.partition(' ')
fields[key] = value.strip()
if 'N' not in fields or 'WX' not in fields:
continue
box = fields.get('B', '0 0 0 0').split()
rows.append((fields['N'], int(float(fields['WX'])), *(int(float(v)) for v in box)))
rows.sort()
return rows
def compile_tables(destination: Path) -> None:
for family, basename in FAMILIES.items():
rows = read_afm(afm_directory() / basename)
lines = [f'# {ADOBE_COPYRIGHT}', '#']
# The paragraph is wrapped for line length but never reworded: `_wrap_comment` splits on
# spaces only, so joining the emitted lines back reproduces it character for character.
# A test below asserts exactly that, because "this paragraph is not modified" is a licence
# condition and a stray reflow that drops a word would breach it silently.
for line in _wrap_comment(ADOBE_PARAGRAPH):
lines.append(f'# {line}')
lines.append('#')
for line in _wrap_comment(ADOBE_DERIVATION):
lines.append(f'# {line}')
lines.append('#')
lines.append(f'# Generated by scripts/gen_font_metrics.py from {basename}. Do not hand-edit.')
lines.append('# Units are 1/1000 em. `char` is the character the glyph encodes, or - when it '
'encodes none.')
lines.append('\t'.join(HEADER))
for name, wx, llx, lly, urx, ury in rows:
char = _character_for(name)
lines.append('\t'.join((char, name, str(wx), str(llx), str(lly), str(urx), str(ury))))
(destination / f'{family}.tsv').write_text('\n'.join(lines) + '\n', encoding='utf-8')
def _wrap_comment(text: str, width: int = 96) -> list:
"""Wrap on spaces only, so ' '.join(result) == text exactly.
Not `textwrap.fill`: that collapses runs of whitespace and can break on hyphens, either of which
would alter Adobe's paragraph. The licence says the paragraph is not modified, so the wrap has
to be information-preserving, and `test_the_adobe_paragraph_survives_the_round_trip` proves it is.
"""
out = []
current = ''
for word in text.split(' '):
if not current:
current = word
elif len(current) + 1 + len(word) <= width:
current += ' ' + word
else:
out.append(current)
current = word
if current:
out.append(current)
return out
def _character_for(name: str) -> str:
"""The character an AFM glyph name encodes, for the names the depictor can actually emit.
Not a general Adobe-glyph-list implementation. What a chemical label contains is letters, digits,
the four charge and bond signs, brackets, a dot and a comma -- plus the typographic minus, which is
what a charge should be set in rather than a hyphen. Anything else keeps `-` in the `char` column
and is reachable by NAME only, which is how `text_box` handles a glyph a caller asked for by name.
"""
if len(name) == 1:
return name
return _NAMED_GLYPHS.get(name, '-')
_NAMED_GLYPHS = {
'space': ' ', 'exclam': '!', 'quotedbl': '"', 'numbersign': '#', 'dollar': '$', 'percent': '%',
'ampersand': '&', 'quoteright': '’', 'parenleft': '(', 'parenright': ')', 'asterisk': '*',
'plus': '+', 'comma': ',', 'hyphen': '-', 'period': '.', 'slash': '/',
'zero': '0', 'one': '1', 'two': '2', 'three': '3', 'four': '4',
'five': '5', 'six': '6', 'seven': '7', 'eight': '8', 'nine': '9',
'colon': ':', 'semicolon': ';', 'less': '<', 'equal': '=', 'greater': '>', 'question': '?',
'at': '@', 'bracketleft': '[', 'backslash': '\\', 'bracketright': ']', 'asciicircum': '^',
'underscore': '_', 'quoteleft': '‘', 'braceleft': '{', 'bar': '|', 'braceright': '}',
'asciitilde': '~', 'endash': '–', 'emdash': '—', 'minus': '−',
'periodcentered': '·', 'bullet': '•', 'degree': '°', 'plusminus': '±',
'multiply': '×', 'dagger': '†',
}
def main():
parser = ArgumentParser(description=__doc__)
parser.add_argument('verb', choices=['compile'])
parser.add_argument('-o', '--output', type=Path,
default=Path(__file__).resolve().parent.parent / 'chython' / 'depict' / 'metrics')
args = parser.parse_args()
compile_tables(args.output)
if __name__ == '__main__':
main()