summaryrefslogtreecommitdiffstats
path: root/lib/silfont/scripts/psfsyncmasters.py
blob: 4302d5010e4a40995c691ee1b3af6ae445c129c7 (plain)
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
#!/usr/bin/env python
__doc__ = '''Sync metadata across a family of fonts based on designspace files'''
__url__ = 'http://github.com/silnrsi/pysilfont'
__copyright__ = 'Copyright (c) 2018 SIL International (http://www.sil.org)'
__license__ = 'Released under the MIT License (http://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': 'Indicates complex set of fonts rather than RIBBI', '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", "trademark",
                 "woffMetadataCredits", "woffMetadataDescription")
    fispecial = ("italicAngle", "openTypeOS2WeightClass", "openTypeNamePreferredSubfamilyName", "openTypeNameUniqueID",
                 "styleMapFamilyName", "styleMapStyleName", "styleName", "unitsPerEm")
    fiall = sorted(set(ficopyreq) | set(ficopyopt) | set(fispecial))
    required = ficopyreq + ("openTypeOS2WeightClass", "styleName", "unitsPerEm")
    libcopy = ("com.schriftgestaltung.glyphOrder", "public.glyphOrder", "public.postscriptNames")
    logger = args.logger
    complex = args.complex

    pds = DSD.DesignSpaceDocument()
    pds.read(args.primaryds)
    if args.secondds is not None:
        sds = DSD.DesignSpaceDocument()
        sds.read(args.secondds)
    else:
        sds = None

    # 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("Primary font: Italic angle must be non-zero for italic fonts", "E")
            else:
                if pval is not None and pval != 0 :
                    logger.log("Primary font: Italic angle must be zero for non-italic fonts", "E")
                pval = None
        elif field == "openTypeOS2WeightClass":
            pval = int(psource.source.location["weight"])
        elif field == "styleMapFamilyName":
            if not complex and pval is None: logger.log("styleMapFamilyName missing from primary font", "E")
        elif field == "styleMapStyleName":
            if not complex and pval not in ('regular', 'bold', 'italic', 'bold italic'):
                logger.log("styleMapStyleName must be 'regular', 'bold', 'italic', 'bold italic'", "E")
        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 required:
            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, "Primary font: " + 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 libcopy:
        pval = psource.lib.getval(field) if field in psource.lib else None
        if pval is None:
            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":
                sval = int(dsource.source.location["weight"])
            elif field == "styleMapStyleName":
                if not complex and sval not in ('regular', 'bold', 'italic', 'bold italic'):
                    logger.log(dsource.source.filename + ": styleMapStyleName must be 'regular', 'bold', 'italic', 'bold italic'", "E")
            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 libcopy:
            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

'''