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
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
|
#!/usr/bin/env python3
__doc__ = '''Sync metadata across a family of fonts based on designspace files'''
__url__ = 'https://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2018 SIL International (https://www.sil.org)'
__license__ = 'Released under the MIT License (https://opensource.org/licenses/MIT)'
__author__ = 'David Raymond'
from silfont.core import execute
import silfont.ufo as UFO
import silfont.etutil as ETU
import os, datetime
import fontTools.designspaceLib as DSD
from xml.etree import ElementTree as ET
argspec = [
('primaryds', {'help': 'Primary design space file'}, {'type': 'filename'}),
('secondds', {'help': 'Second design space file', 'nargs': '?', 'default': None}, {'type': 'filename', 'def': None}),
('--complex', {'help': 'Obsolete - here for backwards compatibility only', 'action': 'store_true', 'default': False},{}),
('-l','--log', {'help': 'Log file'}, {'type': 'outfile', 'def': '_sync.log'}),
('-n','--new', {'help': 'append "_new" to file names', 'action': 'store_true', 'default': False},{}) # For testing/debugging
]
def doit(args) :
ficopyreq = ("ascender", "copyright", "descender", "familyName", "openTypeHheaAscender",
"openTypeHheaDescender", "openTypeHheaLineGap", "openTypeNameDescription", "openTypeNameDesigner",
"openTypeNameDesignerURL", "openTypeNameLicense", "openTypeNameLicenseURL",
"openTypeNameManufacturer", "openTypeNameManufacturerURL", "openTypeNamePreferredFamilyName",
"openTypeNameVersion", "openTypeOS2CodePageRanges", "openTypeOS2TypoAscender",
"openTypeOS2TypoDescender", "openTypeOS2TypoLineGap", "openTypeOS2UnicodeRanges",
"openTypeOS2VendorID", "openTypeOS2WinAscent", "openTypeOS2WinDescent", "versionMajor",
"versionMinor")
ficopyopt = ("openTypeNameSampleText", "postscriptFamilyBlues", "postscriptFamilyOtherBlues", "styleMapFamilyName",
"trademark", "woffMetadataCredits", "woffMetadataDescription")
fispecial = ("italicAngle", "openTypeOS2WeightClass", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID",
"styleName", "unitsPerEm")
fiall = sorted(set(ficopyreq) | set(ficopyopt) | set(fispecial))
firequired = ficopyreq + ("openTypeOS2WeightClass", "styleName", "unitsPerEm")
libcopyreq = ("com.schriftgestaltung.glyphOrder", "public.glyphOrder", "public.postscriptNames")
libcopyopt = ("public.skipExportGlyphs",)
liball = sorted(set(libcopyreq) | set(libcopyopt))
logger = args.logger
pds = DSD.DesignSpaceDocument()
pds.read(args.primaryds)
if args.secondds is not None:
sds = DSD.DesignSpaceDocument()
sds.read(args.secondds)
else:
sds = None
# Extract weight mappings from axes
pwmap = swmap = {}
for (ds, wmap, name) in ((pds, pwmap, "primary"),(sds, swmap, "secondary")):
if ds:
rawmap = None
for descriptor in ds.axes:
if descriptor.name == "weight":
rawmap = descriptor.map
break
if rawmap:
for (cssw, xvalue) in rawmap:
wmap[int(xvalue)] = int(cssw)
else:
logger.log(f"No weight axes mapping in {name} design space", "W")
# Process all the sources
psource = None
dsources = []
for source in pds.sources:
if source.copyInfo:
if psource: logger.log('Multiple fonts with <info copy="1" />', "S")
psource = Dsource(pds, source, logger, frompds=True, psource = True, args = args)
else:
dsources.append(Dsource(pds, source, logger, frompds=True, psource = False, args = args))
if sds is not None:
for source in sds.sources:
dsources.append(Dsource(sds, source, logger, frompds=False, psource = False, args=args))
# Process values in psource
fipval = {}
libpval = {}
changes = False
reqmissing = False
for field in fiall:
pval = psource.fontinfo.getval(field) if field in psource.fontinfo else None
oval = pval
# Set values or do other checks for special cases
if field == "italicAngle":
if "italic" in psource.source.filename.lower():
if pval is None or pval == 0 :
logger.log(f"{psource.source.filename}: Italic angle must be non-zero for italic fonts", "E")
else:
if pval is not None and pval != 0 :
logger.log(f"{psource.source.filename}: Italic angle must be zero for non-italic fonts", "E")
pval = None
elif field == "openTypeOS2WeightClass":
desweight = int(psource.source.location["weight"])
if desweight in pwmap:
pval = pwmap[desweight]
else:
logger.log(f"Design weight {desweight} not in axes mapping so openTypeOS2WeightClass not updated", "I")
elif field in ("styleName", "openTypeNamePreferredSubfamilyName"):
pval = psource.source.styleName
elif field == "openTypeNameUniqueID":
nm = str(fipval["openTypeNameManufacturer"]) # Need to wrap with str() just in case missing from
fn = str(fipval["familyName"]) # fontinfo so would have been set to None
sn = psource.source.styleName
pval = nm + ": " + fn + " " + sn + ": " + datetime.datetime.now().strftime("%Y")
elif field == "unitsperem":
if pval is None or pval <= 0: logger.log("unitsperem must be non-zero", "S")
# After processing special cases, all required fields should have values
if pval is None and field in firequired:
reqmissing = True
logger.log("Required fontinfo field " + field + " missing from " + psource.source.filename, "E")
elif oval != pval:
changes = True
if pval is None:
if field in psource.fontinfo: psource.fontinfo.remove(field)
else:
psource.fontinfo[field][1].text = str(pval)
logchange(logger, f"{psource.source.filename}: {field} updated:", oval, pval)
fipval[field] = pval
if reqmissing: logger.log("Required fontinfo fields missing from " + psource.source.filename, "S")
if changes:
psource.fontinfo.setval("openTypeHeadCreated", "string",
datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"))
psource.write("fontinfo")
for field in liball:
pval = psource.lib.getval(field) if field in psource.lib else None
if pval is None:
if field in libcopyreq:
logtype = "W" if field[0:7] == "public." else "I"
logger.log("lib.plist field " + field + " missing from " + psource.source.filename, logtype)
libpval[field] = pval
# Now update values in other source fonts
for dsource in dsources:
logger.log("Processing " + dsource.ufodir, "I")
fchanges = False
for field in fiall:
sval = dsource.fontinfo.getval(field) if field in dsource.fontinfo else None
oval = sval
pval = fipval[field]
# Set values or do other checks for special cases
if field == "italicAngle":
if "italic" in dsource.source.filename.lower():
if sval is None or sval == 0:
logger.log(dsource.source.filename + ": Italic angle must be non-zero for italic fonts", "E")
else:
if sval is not None and sval != 0:
logger.log(dsource.source.filename + ": Italic angle must be zero for non-italic fonts", "E")
sval = None
elif field == "openTypeOS2WeightClass":
desweight = int(dsource.source.location["weight"])
if desweight in swmap:
sval = swmap[desweight]
else:
logger.log(f"Design weight {desweight} not in axes mapping so openTypeOS2WeightClass not updated", "I")
elif field in ("styleName", "openTypeNamePreferredSubfamilyName"):
sval = dsource.source.styleName
elif field == "openTypeNameUniqueID":
sn = dsource.source.styleName
sval = nm + ": " + fn + " " + sn + ": " + datetime.datetime.now().strftime("%Y")
else:
sval = pval
if oval != sval:
if field == "unitsPerEm": logger.log("unitsPerEm inconsistent across fonts", "S")
fchanges = True
if sval is None:
dsource.fontinfo.remove(field)
logmess = " removed: "
else:
logmess = " added: " if oval is None else " updated: "
# Copy value from primary. This will add if missing.
dsource.fontinfo.setelem(field, ET.fromstring(ET.tostring(psource.fontinfo[field][1])))
# For fields where it is not a copy from primary...
if field in ("italicAngle", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID",
"openTypeOS2WeightClass", "styleName"):
dsource.fontinfo[field][1].text = str(sval)
logchange(logger, dsource.source.filename + " " + field + logmess, oval, sval)
lchanges = False
for field in liball:
oval = dsource.lib.getval(field) if field in dsource.lib else None
pval = libpval[field]
if oval != pval:
lchanges = True
if pval is None:
dsource.lib.remove(field)
logmess = " removed: "
else:
dsource.lib.setelem(field, ET.fromstring(ET.tostring(psource.lib[field][1])))
logmess = " updated: "
logchange(logger, dsource.source.filename + " " + field + logmess, oval, pval)
if lchanges:
dsource.write("lib")
fchanges = True # Force fontinfo to update so openTypeHeadCreated is set
if fchanges:
dsource.fontinfo.setval("openTypeHeadCreated", "string",
datetime.datetime.now().strftime("%Y/%m/%d %H:%M:%S"))
dsource.write("fontinfo")
logger.log("psfsyncmasters completed", "P")
class Dsource(object):
def __init__(self, ds, source, logger, frompds, psource, args):
self.ds = ds
self.source = source
self.logger = logger
self.frompds = frompds # Boolean to say if came from pds
self.newfile = "_new" if args.new else ""
self.ufodir = source.path
if not os.path.isdir(self.ufodir): logger.log(self.ufodir + " in designspace doc does not exist", "S")
try:
self.fontinfo = UFO.Uplist(font=None, dirn=self.ufodir, filen="fontinfo.plist")
except Exception as e:
logger.log("Unable to open fontinfo.plist in " + self.ufodir, "S")
try:
self.lib = UFO.Uplist(font=None, dirn=self.ufodir, filen="lib.plist")
except Exception as e:
if psource:
logger.log("Unable to open lib.plist in " + self.ufodir, "E")
self.lib = {} # Just need empty dict, so all vals will be set to None
else:
logger.log("Unable to open lib.plist in " + self.ufodir + "; creating empty one", "E")
self.lib = UFO.Uplist()
self.lib.logger=logger
self.lib.etree = ET.fromstring("<plist>\n<dict/>\n</plist>")
self.lib.populate_dict()
self.lib.dirn = self.ufodir
self.lib.filen = "lib.plist"
# Process parameters with similar logic to that in ufo.py. primarily to create outparams for writeXMLobject
libparams = {}
params = args.paramsobj
if "org.sil.pysilfontparams" in self.lib:
elem = self.lib["org.sil.pysilfontparams"][1]
if elem.tag != "array":
logger.log("Invalid parameter XML lib.plist - org.sil.pysilfontparams must be an array", "S")
for param in elem:
parn = param.tag
if not (parn in params.paramclass) or params.paramclass[parn] not in ("outparams", "ufometadata"):
logger.log(
"lib.plist org.sil.pysilfontparams must only contain outparams or ufometadata values: " + parn + " invalid",
"S")
libparams[parn] = param.text
# Create font-specific parameter set (with updates from lib.plist) Prepend names with ufodir to ensure uniqueness if multiple fonts open
params.addset(self.ufodir + "lib", "lib.plist in " + self.ufodir, inputdict=libparams)
if "command line" in params.sets:
params.sets[self.ufodir + "lib"].updatewith("command line", log=False) # Command line parameters override lib.plist ones
copyset = "main" if "main" in params.sets else "default"
params.addset(self.ufodir, copyset=copyset)
params.sets[self.ufodir].updatewith(self.ufodir + "lib", sourcedesc="lib.plist")
self.paramset = params.sets[self.ufodir]
# Validate specific parameters
if sorted(self.paramset["glifElemOrder"]) != sorted(params.sets["default"]["glifElemOrder"]):
logger.log("Invalid values for glifElemOrder", "S")
# Create outparams based on values in paramset, building attriborders from separate attriborders.<type> parameters.
self.outparams = {"attribOrders": {}}
for parn in params.classes["outparams"]:
value = self.paramset[parn]
if parn[0:12] == 'attribOrders':
elemname = parn.split(".")[1]
self.outparams["attribOrders"][elemname] = ETU.makeAttribOrder(value)
else:
self.outparams[parn] = value
self.outparams["UFOversion"] = 9 # Dummy value since not currently needed
def write(self, plistn):
filen = plistn + self.newfile + ".plist"
self.logger.log("Writing updated " + plistn + ".plist to " + filen, "P")
exists = True if os.path.isfile(os.path.join(self.ufodir, filen)) else False
plist = getattr(self, plistn)
UFO.writeXMLobject(plist, self.outparams, self.ufodir, filen, exists, fobject=True)
def logchange(logger, logmess, old, new):
oldstr = str(old) if len(str(old)) < 22 else str(old)[0:20] + "..."
newstr = str(new) if len(str(new)) < 22 else str(new)[0:20] + "..."
if old is None:
logmess = logmess + " New value: " + newstr
else:
if new is None:
logmess = logmess + " Old value: " + oldstr
else:
logmess = logmess + " Old value: " + oldstr + ", new value: " + newstr
logger.log(logmess, "W")
# Extra verbose logging
if len(str(old)) > 21 :
logger.log("Full old value: " + str(old), "V")
if len(str(new)) > 21 :
logger.log("Full new value: " + str(new), "V")
logger.log("Types: Old - " + str(type(old)) + ", New - " + str(type(new)), "V")
def cmd() : execute(None,doit, argspec)
if __name__ == "__main__": cmd()
''' *** Code notes ***
Does not check precision for float, since no float values are currently processed
- see processnum in psfsyncmeta if needed later
'''
|