mapotofu 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mapotofu/__init__.py +2 -0
- mapotofu/__main__.py +5 -0
- mapotofu/assemble.py +429 -0
- mapotofu/build.py +348 -0
- mapotofu/charsets.py +145 -0
- mapotofu/cli.py +85 -0
- mapotofu/config.py +125 -0
- mapotofu/cut.py +102 -0
- mapotofu/fetch.py +74 -0
- mapotofu/gen_charsets.py +50 -0
- mapotofu/glyphs.py +142 -0
- mapotofu/hint.py +248 -0
- mapotofu/hintcache.py +398 -0
- mapotofu/instance.py +114 -0
- mapotofu/layout.py +460 -0
- mapotofu/man/man1/mapo.1 +1 -0
- mapotofu/man/man1/mapotofu.1 +1022 -0
- mapotofu/man/zh_CN/man1/mapo.1 +1 -0
- mapotofu/man/zh_CN/man1/mapotofu.1 +443 -0
- mapotofu/manual.py +50 -0
- mapotofu/meta.py +295 -0
- mapotofu/package.py +465 -0
- mapotofu/parts.py +288 -0
- mapotofu/project.py +42 -0
- mapotofu/release.py +73 -0
- mapotofu/shards.py +87 -0
- mapotofu/slices.py +119 -0
- mapotofu/sources.py +235 -0
- mapotofu/stamps.py +137 -0
- mapotofu/tiers.py +47 -0
- mapotofu/tools/__init__.py +63 -0
- mapotofu/tools/change.py +258 -0
- mapotofu/tools/points.py +117 -0
- mapotofu/tools/proof.py +143 -0
- mapotofu/tools/raster.py +145 -0
- mapotofu/tools/same.py +123 -0
- mapotofu/tools/sheet.py +113 -0
- mapotofu/tools/skeleton.py +411 -0
- mapotofu/tools/specimen.py +94 -0
- mapotofu/tools/stems.py +138 -0
- mapotofu/tools/ttf2otf.py +110 -0
- mapotofu/tools/weights.py +105 -0
- mapotofu/zones.py +303 -0
- mapotofu-0.1.0.data/data/share/man/man1/mapo.1 +1 -0
- mapotofu-0.1.0.data/data/share/man/man1/mapotofu.1 +1022 -0
- mapotofu-0.1.0.data/data/share/man/zh_CN/man1/mapo.1 +1 -0
- mapotofu-0.1.0.data/data/share/man/zh_CN/man1/mapotofu.1 +443 -0
- mapotofu-0.1.0.dist-info/METADATA +310 -0
- mapotofu-0.1.0.dist-info/RECORD +52 -0
- mapotofu-0.1.0.dist-info/WHEEL +4 -0
- mapotofu-0.1.0.dist-info/entry_points.txt +3 -0
- mapotofu-0.1.0.dist-info/licenses/LICENSE +202 -0
mapotofu/__init__.py
ADDED
mapotofu/__main__.py
ADDED
mapotofu/assemble.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Assemble the parts of one font at every master and build the variable font (first build step)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import math
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
|
|
9
|
+
from fontTools.designspaceLib import AxisDescriptor, DesignSpaceDocument, SourceDescriptor
|
|
10
|
+
from fontTools.fontBuilder import FontBuilder
|
|
11
|
+
from fontTools.otlLib import builder as otl
|
|
12
|
+
from fontTools.ttLib import TTFont, newTable
|
|
13
|
+
from fontTools.ttLib.tables import otTables as ot
|
|
14
|
+
from fontTools.varLib import build as varlib_build
|
|
15
|
+
from fontTools.varLib.featureVars import addFeatureVariations
|
|
16
|
+
from fontTools.varLib.models import piecewiseLinearMap
|
|
17
|
+
|
|
18
|
+
from . import layout, meta, sources, zones
|
|
19
|
+
from .charsets import KANA_VERTICAL, Cat, category, is_tiered, source_of
|
|
20
|
+
from .config import AXIS_DEFAULT, AXIS_MAX, AXIS_MIN, ITALIC_CJK, LATIN, MASTERS
|
|
21
|
+
from .glyphs import Glyph, GlyphStore, Master, bounds, charstring
|
|
22
|
+
from .parts import ItalicCJKPart, Part, cjk_part, italic_cjk_part, latin_part
|
|
23
|
+
from .tiers import shs_cmap, tiers
|
|
24
|
+
|
|
25
|
+
log = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass(frozen=True)
|
|
29
|
+
class View:
|
|
30
|
+
"""One output font. family is Gothic, UI, Mono or Ext; region is SC, TC, J, or CJK for the
|
|
31
|
+
shared extension."""
|
|
32
|
+
|
|
33
|
+
family: str
|
|
34
|
+
region: str
|
|
35
|
+
italic: bool
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def is_ext(self) -> bool:
|
|
39
|
+
return self.family == "Ext"
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def shs_region(self) -> str:
|
|
43
|
+
return "J" if self.region == "CJK" else self.region
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def key(self) -> str:
|
|
47
|
+
return f"{self.family}-{self.region}-{'Italic' if self.italic else 'Upright'}"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Assembly:
|
|
52
|
+
view: View
|
|
53
|
+
store: GlyphStore
|
|
54
|
+
cjk: Part
|
|
55
|
+
latin: Part | None
|
|
56
|
+
italic_cjk: ItalicCJKPart | None
|
|
57
|
+
cmap: dict[int, str]
|
|
58
|
+
uvs: list
|
|
59
|
+
order: list[str]
|
|
60
|
+
cont: tuple[str, str | None] | None = None # continuation dash and its vertical form
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# --- choosing code points -----------------------------------------------------------------------------
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _codepoints(view: View) -> tuple[set[int], set[int]]:
|
|
67
|
+
"""Code points taken from Source Han Sans and from the Latin source."""
|
|
68
|
+
t = tiers()
|
|
69
|
+
if view.is_ext:
|
|
70
|
+
cps = t.shared if view.region == "CJK" else t.ext[view.region]
|
|
71
|
+
return set(cps), set()
|
|
72
|
+
shs = shs_cmap(view.region)
|
|
73
|
+
lat_font = sources.latin(LATIN[(view.family, view.italic)]).font
|
|
74
|
+
lat = set(lat_font.getBestCmap())
|
|
75
|
+
main = t.main[view.region]
|
|
76
|
+
cjk, latin = set(), set()
|
|
77
|
+
for cp in shs.keys() | lat:
|
|
78
|
+
if is_tiered(cp) and cp not in main:
|
|
79
|
+
continue
|
|
80
|
+
src = source_of(cp, view.family, cp in shs, cp in lat)
|
|
81
|
+
if src == "cjk":
|
|
82
|
+
cjk.add(cp)
|
|
83
|
+
elif src == "latin":
|
|
84
|
+
latin.add(cp)
|
|
85
|
+
return cjk, latin
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def prepare(view: View, store: GlyphStore | None = None) -> Assembly:
|
|
89
|
+
"""Choose and load the glyphs of a font; fonts sharing a store share glyphs by name."""
|
|
90
|
+
store = store if store is not None else GlyphStore()
|
|
91
|
+
cjk_cps, lat_cps = _codepoints(view)
|
|
92
|
+
ital = None
|
|
93
|
+
if view.italic and view.region in ITALIC_CJK:
|
|
94
|
+
ital = italic_cjk_part(view.region, cjk_cps, KANA_VERTICAL, store)
|
|
95
|
+
cjk_cps -= ital.cmap.keys()
|
|
96
|
+
cjk = cjk_part(view.family, view.shs_region, cjk_cps, store)
|
|
97
|
+
latin = latin_part(LATIN[(view.family, view.italic)], view.family, lat_cps, store) if lat_cps else None
|
|
98
|
+
cmap = dict(cjk.cmap)
|
|
99
|
+
if latin:
|
|
100
|
+
cmap.update(latin.cmap)
|
|
101
|
+
if ital:
|
|
102
|
+
cmap.update(ital.cmap)
|
|
103
|
+
uvs = [] if ital else list(cjk.uvs)
|
|
104
|
+
order = _glyph_order(cmap, cjk, latin, ital)
|
|
105
|
+
asm = Assembly(view, store, cjk, latin, ital, cmap, uvs, order)
|
|
106
|
+
if view.family in ("Gothic", "Mono"):
|
|
107
|
+
_continuation_dash(asm)
|
|
108
|
+
return asm
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _glyph_order(cmap, cjk: Part, latin: Part | None, ital: ItalicCJKPart | None) -> list[str]:
|
|
112
|
+
""".notdef, Latin, other glyphs of Source Han Sans, then ideographs by code point (SPEC 5.4)."""
|
|
113
|
+
ideo: dict[str, int] = {}
|
|
114
|
+
for cp in sorted(cmap):
|
|
115
|
+
if category(cp) is Cat.IDEO:
|
|
116
|
+
ideo.setdefault(cmap[cp], cp)
|
|
117
|
+
out = [".notdef"]
|
|
118
|
+
seen = {".notdef"}
|
|
119
|
+
|
|
120
|
+
def add(names):
|
|
121
|
+
for n in names:
|
|
122
|
+
if n not in seen and n not in ideo:
|
|
123
|
+
seen.add(n)
|
|
124
|
+
out.append(n)
|
|
125
|
+
|
|
126
|
+
if latin:
|
|
127
|
+
add(latin.order)
|
|
128
|
+
add(cjk.order)
|
|
129
|
+
if ital:
|
|
130
|
+
add(ital.order)
|
|
131
|
+
out.extend(sorted(ideo, key=ideo.get))
|
|
132
|
+
return out
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- continuation dash (SPEC 3.5) -------------------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _continuation_dash(asm: Assembly) -> None:
|
|
139
|
+
g = asm.cmap.get(0x2014)
|
|
140
|
+
if g is None:
|
|
141
|
+
return
|
|
142
|
+
glyph = asm.store[g]
|
|
143
|
+
if not all((b := bounds(m.outline)) and b[0] > 0 for m in glyph.masters):
|
|
144
|
+
return
|
|
145
|
+
cont = f"{g}.cont"
|
|
146
|
+
masters = []
|
|
147
|
+
for m in glyph.masters:
|
|
148
|
+
x0, y0, x1, y1 = bounds(m.outline)
|
|
149
|
+
left = -(m.width - x1) # meets the right end of the dash before it
|
|
150
|
+
t = y1 - y0
|
|
151
|
+
mid = (y0 + y1) / 2
|
|
152
|
+
outline = [
|
|
153
|
+
("moveTo", ((x1, y0),)),
|
|
154
|
+
("lineTo", ((x1, y1),)),
|
|
155
|
+
("lineTo", ((left, y1),)),
|
|
156
|
+
("lineTo", ((left - t / 2, mid),)),
|
|
157
|
+
("lineTo", ((left, y0),)),
|
|
158
|
+
("closePath", ()),
|
|
159
|
+
]
|
|
160
|
+
masters.append(Master(outline, m.width, m.height, m.vorg))
|
|
161
|
+
asm.store.add(cont, Glyph(masters, glyph.linear))
|
|
162
|
+
vcont = None
|
|
163
|
+
vert = _vertical_form(asm, 0x2015)
|
|
164
|
+
if vert is not None:
|
|
165
|
+
vglyph = asm.store[vert]
|
|
166
|
+
if all(bounds(m.outline) for m in vglyph.masters):
|
|
167
|
+
vcont = f"{vert}.cont"
|
|
168
|
+
vmasters = []
|
|
169
|
+
for m in vglyph.masters:
|
|
170
|
+
x0, y0, x1, y1 = bounds(m.outline)
|
|
171
|
+
top_gap = m.vorg - y1 # space above the bar inside its em box
|
|
172
|
+
bottom_gap = y0 - (m.vorg - m.height)
|
|
173
|
+
top = y1 + top_gap + bottom_gap # meets the lower end of the bar above
|
|
174
|
+
t = x1 - x0
|
|
175
|
+
mid = (x0 + x1) / 2
|
|
176
|
+
outline = [
|
|
177
|
+
("moveTo", ((x0, y0),)),
|
|
178
|
+
("lineTo", ((x1, y0),)),
|
|
179
|
+
("lineTo", ((x1, top),)),
|
|
180
|
+
("lineTo", ((mid, top + t / 2),)),
|
|
181
|
+
("lineTo", ((x0, top),)),
|
|
182
|
+
("closePath", ()),
|
|
183
|
+
]
|
|
184
|
+
vmasters.append(Master(outline, m.width, m.height, m.vorg))
|
|
185
|
+
asm.store.add(vcont, Glyph(vmasters, vglyph.linear))
|
|
186
|
+
asm.cont = (cont, vcont)
|
|
187
|
+
asm.order.append(cont)
|
|
188
|
+
if vcont:
|
|
189
|
+
asm.order.append(vcont)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _vertical_form(asm: Assembly, cp: int) -> str | None:
|
|
193
|
+
g = asm.cjk.cmap.get(cp)
|
|
194
|
+
if g is None:
|
|
195
|
+
return None
|
|
196
|
+
font = sources.load(asm.cjk.layout, asm.cjk.layout_order)
|
|
197
|
+
gsub = font["GSUB"].table
|
|
198
|
+
return layout.single_mapping(gsub, layout.feature_lookups(gsub, "vert")).get(g)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- one master ----------------------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _edit_layout(asm: Assembly, tables: dict) -> None:
|
|
205
|
+
gsub = tables["GSUB"].table
|
|
206
|
+
for tag in ("vert", "vrt2"):
|
|
207
|
+
if tag not in layout.feature_tags(gsub):
|
|
208
|
+
continue
|
|
209
|
+
lookups = layout.feature_lookups(gsub, tag)
|
|
210
|
+
mapping = layout.single_mapping(gsub, lookups)
|
|
211
|
+
extra = {}
|
|
212
|
+
g14, g15 = asm.cmap.get(0x2014), asm.cmap.get(0x2015)
|
|
213
|
+
if g14 and g15 and g15 in mapping: # SPEC 3.4
|
|
214
|
+
extra[g14] = mapping[g15]
|
|
215
|
+
if asm.italic_cjk:
|
|
216
|
+
extra.update(asm.italic_cjk.vert)
|
|
217
|
+
if asm.cont and asm.cont[1]:
|
|
218
|
+
extra[asm.cont[0]] = asm.cont[1]
|
|
219
|
+
if all(layout.lookup_type(gsub.LookupList.Lookup[i], "GSUB") == 1 for i in lookups):
|
|
220
|
+
mapping.update(extra)
|
|
221
|
+
layout.replace_single_feature(gsub, tag, mapping) # SPEC 5.2
|
|
222
|
+
elif extra:
|
|
223
|
+
gsub.LookupList.Lookup.append(layout.single_lookup(extra))
|
|
224
|
+
layout.every_langsys_feature(gsub, tag, len(gsub.LookupList.Lookup) - 1)
|
|
225
|
+
if asm.cont:
|
|
226
|
+
dash, cont = asm.cmap[0x2014], asm.cont[0]
|
|
227
|
+
layout.insert_lookup(gsub, 0, layout.single_lookup({dash: cont}))
|
|
228
|
+
st = ot.ChainContextSubst()
|
|
229
|
+
st.Format = 3
|
|
230
|
+
st.BacktrackCoverage = [_coverage([dash, cont])]
|
|
231
|
+
st.InputCoverage = [_coverage([dash])]
|
|
232
|
+
st.LookAheadCoverage = []
|
|
233
|
+
rec = ot.SubstLookupRecord()
|
|
234
|
+
rec.SequenceIndex = 0
|
|
235
|
+
rec.LookupListIndex = 1
|
|
236
|
+
st.SubstLookupRecord = [rec]
|
|
237
|
+
st.BacktrackGlyphCount, st.InputGlyphCount, st.LookAheadGlyphCount, st.SubstCount = 1, 1, 0, 1
|
|
238
|
+
layout.insert_lookup(gsub, 0, otl.buildLookup([st]))
|
|
239
|
+
layout.every_langsys_feature(gsub, "calt", 0)
|
|
240
|
+
for tag in ("GSUB", "GPOS"):
|
|
241
|
+
layout.share_default_features(tables[tag].table)
|
|
242
|
+
layout.merge_same_tag_features(tables[tag].table)
|
|
243
|
+
layout.sort_features(tables[tag].table)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _coverage(glyphs: list[str]) -> ot.Coverage:
|
|
247
|
+
cov = ot.Coverage()
|
|
248
|
+
cov.glyphs = list(glyphs)
|
|
249
|
+
return cov
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def master_font(asm: Assembly, i: int, order: list[str] | None = None, outlines: bool = True) -> TTFont:
|
|
253
|
+
"""The font at master i. With outlines False every glyph is empty: the layout and metrics
|
|
254
|
+
are what matters, the outlines come from a shared pool (see build_group)."""
|
|
255
|
+
order = order or asm.order
|
|
256
|
+
w = MASTERS[i]
|
|
257
|
+
cjk = sources.instantiate(asm.cjk.layout, asm.cjk.layout_order, asm.cjk.location(w))
|
|
258
|
+
if asm.latin:
|
|
259
|
+
lat = sources.instantiate(asm.latin.layout, asm.latin.layout_order, asm.latin.location(w))
|
|
260
|
+
tables = layout.merge_fonts_layout(cjk, lat)
|
|
261
|
+
else:
|
|
262
|
+
tables = {t: cjk[t] for t in ("GSUB", "GPOS", "GDEF") if t in cjk}
|
|
263
|
+
_edit_layout(asm, tables)
|
|
264
|
+
|
|
265
|
+
_notdef(asm.store, i)
|
|
266
|
+
fb = _glyph_font(asm.store, order, i, outlines)
|
|
267
|
+
fb.setupCharacterMap(asm.cmap, uvs=asm.uvs or None)
|
|
268
|
+
gid = {n: i for i, n in enumerate(order)}
|
|
269
|
+
for tag, table in tables.items():
|
|
270
|
+
layout.sort_coverages(table.table, gid)
|
|
271
|
+
fb.font[tag] = table
|
|
272
|
+
if "BASE" in cjk:
|
|
273
|
+
fb.font["BASE"] = cjk["BASE"]
|
|
274
|
+
if asm.latin:
|
|
275
|
+
# At each master its own, so that what the source varies with the weight varies in the
|
|
276
|
+
# font (varLib writes MVAR for it) and reaches the static weights when they are instanced.
|
|
277
|
+
latin = sources.latin(LATIN[(asm.view.family, asm.view.italic)])
|
|
278
|
+
for field, value in latin.os2(w).items():
|
|
279
|
+
setattr(fb.font["OS/2"], field, value)
|
|
280
|
+
return fb.font
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _notdef(store: GlyphStore, i: int) -> Master:
|
|
284
|
+
if ".notdef" not in store:
|
|
285
|
+
store.add(".notdef", sources.shs().glyph(0))
|
|
286
|
+
return store[".notdef"].at(i)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _glyph_font(store: GlyphStore, order: list[str], i: int, outlines: bool) -> FontBuilder:
|
|
290
|
+
"""A font with the glyphs and metrics of master i, without cmap or layout."""
|
|
291
|
+
w = MASTERS[i]
|
|
292
|
+
sparse = w not in (AXIS_MIN, AXIS_DEFAULT, AXIS_MAX)
|
|
293
|
+
fb = FontBuilder(1000, isTTF=False)
|
|
294
|
+
fb.setupGlyphOrder(order)
|
|
295
|
+
charstrings, hmtx, vmtx, vorg = {}, {}, {}, {}
|
|
296
|
+
for n in order:
|
|
297
|
+
g = store[n]
|
|
298
|
+
# An empty charstring in a non-default master marks the glyph as absent there.
|
|
299
|
+
empty = not outlines or (g.static and w != AXIS_DEFAULT) or (g.linear and sparse)
|
|
300
|
+
charstrings[n] = charstring(g.at(i), empty=empty)
|
|
301
|
+
fb.setupCFF("mapotofu", {"FullName": "mapotofu"}, charstrings, {})
|
|
302
|
+
for n in order:
|
|
303
|
+
m = store[n].at(i)
|
|
304
|
+
# The side bearings are those of the glyph as its charstring draws it (the curves, their
|
|
305
|
+
# points rounded), not of its control points. Only the default master's reach the
|
|
306
|
+
# variable font, where every glyph has its outline.
|
|
307
|
+
b = charstrings[n].calcBounds(None)
|
|
308
|
+
vorg[n] = round(m.vorg)
|
|
309
|
+
hmtx[n] = (round(m.width), math.floor(b[0]) if b else 0)
|
|
310
|
+
vmtx[n] = (round(m.height), vorg[n] - math.ceil(b[3]) if b else 0)
|
|
311
|
+
fb.setupHorizontalMetrics(hmtx)
|
|
312
|
+
fb.setupHorizontalHeader(ascent=1160, descent=-288)
|
|
313
|
+
fb.setupVerticalMetrics(vmtx)
|
|
314
|
+
fb.setupVerticalHeader(ascent=500, descent=-500)
|
|
315
|
+
fb.setupCharacterMap({})
|
|
316
|
+
fb.setupOS2()
|
|
317
|
+
fb.setupPost(keepGlyphNames=False)
|
|
318
|
+
fb.setupNameTable({"familyName": "mapotofu", "styleName": "Regular"})
|
|
319
|
+
t = newTable("VORG")
|
|
320
|
+
t.majorVersion, t.minorVersion = 1, 0
|
|
321
|
+
t.defaultVertOriginY = 880
|
|
322
|
+
t.VOriginRecords = {n: v for n, v in vorg.items() if v != 880}
|
|
323
|
+
t.numVertOriginYMetrics = len(t.VOriginRecords)
|
|
324
|
+
fb.font["VORG"] = t
|
|
325
|
+
return fb
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# --- variable font -------------------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _designspace(fonts: list[TTFont]) -> DesignSpaceDocument:
|
|
332
|
+
ds = DesignSpaceDocument()
|
|
333
|
+
axis = AxisDescriptor()
|
|
334
|
+
axis.name, axis.tag = "Weight", "wght"
|
|
335
|
+
axis.minimum, axis.default, axis.maximum = AXIS_MIN, AXIS_DEFAULT, AXIS_MAX
|
|
336
|
+
# Design coordinates are the normalized coordinate times 1000.
|
|
337
|
+
design = {w: n * 1000 for w, n in sources.axis_map()}
|
|
338
|
+
axis.map = [(w, design[w]) for w in MASTERS]
|
|
339
|
+
ds.addAxis(axis)
|
|
340
|
+
for w, font in zip(MASTERS, fonts):
|
|
341
|
+
src = SourceDescriptor()
|
|
342
|
+
src.font = font
|
|
343
|
+
src.location = {"Weight": design[w]}
|
|
344
|
+
src.name = f"master{w}"
|
|
345
|
+
ds.addSource(src)
|
|
346
|
+
return ds
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
POOLED = ("CFF2", "hmtx", "HVAR", "vmtx", "VVAR", "VORG", "maxp")
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _bbox(font: TTFont, tag: str) -> tuple[int, int, int, int]:
|
|
353
|
+
"""The bounding box of the glyphs of a CFF or CFF2 table (of a CFF2, at the default weight),
|
|
354
|
+
as fontTools sets head's when saving."""
|
|
355
|
+
from fontTools.misc.arrayTools import intRect, unionRect
|
|
356
|
+
|
|
357
|
+
charstrings = font[tag].cff.topDictIndex[0].CharStrings
|
|
358
|
+
box = None
|
|
359
|
+
for cs in charstrings.values():
|
|
360
|
+
bounds = cs.calcBounds(charstrings)
|
|
361
|
+
if bounds is not None:
|
|
362
|
+
box = bounds if box is None else unionRect(box, bounds)
|
|
363
|
+
return intRect(box or (0, 0, 0, 0))
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _extent(masters: list[TTFont]) -> tuple[int, int]:
|
|
367
|
+
"""The lowest and the highest point of the pool's glyphs over the whole weight axis. Between
|
|
368
|
+
two masters every point of a glyph moves in a straight line, so a glyph reaches furthest at
|
|
369
|
+
one of the masters that hold it (the others have an empty charstring, which has no box)."""
|
|
370
|
+
boxes = [_bbox(m, "CFF ") for m in masters]
|
|
371
|
+
return min(b[1] for b in boxes), max(b[3] for b in boxes)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def build_group(views: list[View]) -> tuple[list[tuple[View, TTFont]], tuple[int, int]]:
|
|
375
|
+
"""Variable fonts of views that share one glyph pool: the same glyph order and byte-identical
|
|
376
|
+
glyph tables (POOLED), so that a collection of them stores each glyph once. Each font maps
|
|
377
|
+
only its own characters; a standalone font is cut from it by subsetting. With them, the
|
|
378
|
+
lowest and the highest point of the pool's glyphs over all weights (see _extent)."""
|
|
379
|
+
store = GlyphStore()
|
|
380
|
+
asms = []
|
|
381
|
+
for view in views:
|
|
382
|
+
asms.append(prepare(view, store))
|
|
383
|
+
log.info("%s: %d glyphs", view.key, len(asms[-1].order))
|
|
384
|
+
order = list(dict.fromkeys(n for asm in asms for n in asm.order))
|
|
385
|
+
log.info("pool of %s: %d glyphs", "+".join(v.key for v in views), len(order))
|
|
386
|
+
if len(order) > 0xFFFF:
|
|
387
|
+
raise ValueError(f"{len(order)} glyphs in the pool of {views}")
|
|
388
|
+
pool_masters = []
|
|
389
|
+
for i in range(len(MASTERS)):
|
|
390
|
+
_notdef(store, i)
|
|
391
|
+
pool_masters.append(_glyph_font(store, order, i, True).font)
|
|
392
|
+
extent = _extent(pool_masters)
|
|
393
|
+
pool, _, _ = varlib_build(_designspace(pool_masters))
|
|
394
|
+
del pool_masters
|
|
395
|
+
zones.apply(pool, order)
|
|
396
|
+
bbox = _bbox(pool, "CFF2")
|
|
397
|
+
out = []
|
|
398
|
+
for asm in asms:
|
|
399
|
+
vf, _, _ = varlib_build(_designspace([master_font(asm, i, order, outlines=False) for i in range(len(MASTERS))]))
|
|
400
|
+
for tag in POOLED:
|
|
401
|
+
vf[tag] = pool[tag]
|
|
402
|
+
# Of the default weight, as in every variable font; fontTools would set it only when
|
|
403
|
+
# saving.
|
|
404
|
+
head = vf["head"]
|
|
405
|
+
head.xMin, head.yMin, head.xMax, head.yMax = bbox
|
|
406
|
+
if asm.italic_cjk and asm.italic_cjk.subs:
|
|
407
|
+
_italic_cjk_variations(vf, asm.italic_cjk)
|
|
408
|
+
meta.finalize(vf, asm.view, extent=extent)
|
|
409
|
+
out.append((asm.view, vf))
|
|
410
|
+
return out, extent
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def build(view: View) -> TTFont:
|
|
414
|
+
return build_group([view])[0][0][1]
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _normalize(w: float) -> float:
|
|
418
|
+
"""Normalized coordinate of a weight after avar, which FeatureVariations conditions use."""
|
|
419
|
+
return piecewiseLinearMap(w, dict(sources.axis_map()))
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _italic_cjk_variations(vf: TTFont, ital: ItalicCJKPart) -> None:
|
|
423
|
+
"""Switch the CJK italic weight with the weight axis (PLAN 5)."""
|
|
424
|
+
eps = 1 / 16384
|
|
425
|
+
conds = []
|
|
426
|
+
for lo, hi, mapping in ital.subs:
|
|
427
|
+
top = _normalize(hi) - (eps if hi < AXIS_MAX else 0)
|
|
428
|
+
conds.append(([{"wght": (_normalize(lo), top)}], mapping))
|
|
429
|
+
addFeatureVariations(vf, conds, featureTag="rvrn")
|