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.
Files changed (52) hide show
  1. mapotofu/__init__.py +2 -0
  2. mapotofu/__main__.py +5 -0
  3. mapotofu/assemble.py +429 -0
  4. mapotofu/build.py +348 -0
  5. mapotofu/charsets.py +145 -0
  6. mapotofu/cli.py +85 -0
  7. mapotofu/config.py +125 -0
  8. mapotofu/cut.py +102 -0
  9. mapotofu/fetch.py +74 -0
  10. mapotofu/gen_charsets.py +50 -0
  11. mapotofu/glyphs.py +142 -0
  12. mapotofu/hint.py +248 -0
  13. mapotofu/hintcache.py +398 -0
  14. mapotofu/instance.py +114 -0
  15. mapotofu/layout.py +460 -0
  16. mapotofu/man/man1/mapo.1 +1 -0
  17. mapotofu/man/man1/mapotofu.1 +1022 -0
  18. mapotofu/man/zh_CN/man1/mapo.1 +1 -0
  19. mapotofu/man/zh_CN/man1/mapotofu.1 +443 -0
  20. mapotofu/manual.py +50 -0
  21. mapotofu/meta.py +295 -0
  22. mapotofu/package.py +465 -0
  23. mapotofu/parts.py +288 -0
  24. mapotofu/project.py +42 -0
  25. mapotofu/release.py +73 -0
  26. mapotofu/shards.py +87 -0
  27. mapotofu/slices.py +119 -0
  28. mapotofu/sources.py +235 -0
  29. mapotofu/stamps.py +137 -0
  30. mapotofu/tiers.py +47 -0
  31. mapotofu/tools/__init__.py +63 -0
  32. mapotofu/tools/change.py +258 -0
  33. mapotofu/tools/points.py +117 -0
  34. mapotofu/tools/proof.py +143 -0
  35. mapotofu/tools/raster.py +145 -0
  36. mapotofu/tools/same.py +123 -0
  37. mapotofu/tools/sheet.py +113 -0
  38. mapotofu/tools/skeleton.py +411 -0
  39. mapotofu/tools/specimen.py +94 -0
  40. mapotofu/tools/stems.py +138 -0
  41. mapotofu/tools/ttf2otf.py +110 -0
  42. mapotofu/tools/weights.py +105 -0
  43. mapotofu/zones.py +303 -0
  44. mapotofu-0.1.0.data/data/share/man/man1/mapo.1 +1 -0
  45. mapotofu-0.1.0.data/data/share/man/man1/mapotofu.1 +1022 -0
  46. mapotofu-0.1.0.data/data/share/man/zh_CN/man1/mapo.1 +1 -0
  47. mapotofu-0.1.0.data/data/share/man/zh_CN/man1/mapotofu.1 +443 -0
  48. mapotofu-0.1.0.dist-info/METADATA +310 -0
  49. mapotofu-0.1.0.dist-info/RECORD +52 -0
  50. mapotofu-0.1.0.dist-info/WHEEL +4 -0
  51. mapotofu-0.1.0.dist-info/entry_points.txt +3 -0
  52. mapotofu-0.1.0.dist-info/licenses/LICENSE +202 -0
mapotofu/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """mapotofu: builds CJK composite fonts from Source Han Sans, a Latin font and, optionally,
2
+ CJK italic sources, as described by a project's mapotofu.toml."""
mapotofu/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ """python -m mapotofu: the same as the commands mapo and mapotofu."""
2
+
3
+ from .cli import main
4
+
5
+ main()
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")