• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1"""Helpers for instantiating name table records."""
2
3from contextlib import contextmanager
4from copy import deepcopy
5from enum import IntEnum
6import re
7
8
9class NameID(IntEnum):
10    FAMILY_NAME = 1
11    SUBFAMILY_NAME = 2
12    UNIQUE_FONT_IDENTIFIER = 3
13    FULL_FONT_NAME = 4
14    VERSION_STRING = 5
15    POSTSCRIPT_NAME = 6
16    TYPOGRAPHIC_FAMILY_NAME = 16
17    TYPOGRAPHIC_SUBFAMILY_NAME = 17
18    VARIATIONS_POSTSCRIPT_NAME_PREFIX = 25
19
20
21ELIDABLE_AXIS_VALUE_NAME = 2
22
23
24def getVariationNameIDs(varfont):
25    used = []
26    if "fvar" in varfont:
27        fvar = varfont["fvar"]
28        for axis in fvar.axes:
29            used.append(axis.axisNameID)
30        for instance in fvar.instances:
31            used.append(instance.subfamilyNameID)
32            if instance.postscriptNameID != 0xFFFF:
33                used.append(instance.postscriptNameID)
34    if "STAT" in varfont:
35        stat = varfont["STAT"].table
36        for axis in stat.DesignAxisRecord.Axis if stat.DesignAxisRecord else ():
37            used.append(axis.AxisNameID)
38        for value in stat.AxisValueArray.AxisValue if stat.AxisValueArray else ():
39            used.append(value.ValueNameID)
40    # nameIDs <= 255 are reserved by OT spec so we don't touch them
41    return {nameID for nameID in used if nameID > 255}
42
43
44@contextmanager
45def pruningUnusedNames(varfont):
46    from . import log
47
48    origNameIDs = getVariationNameIDs(varfont)
49
50    yield
51
52    log.info("Pruning name table")
53    exclude = origNameIDs - getVariationNameIDs(varfont)
54    varfont["name"].names[:] = [
55        record for record in varfont["name"].names if record.nameID not in exclude
56    ]
57    if "ltag" in varfont:
58        # Drop the whole 'ltag' table if all the language-dependent Unicode name
59        # records that reference it have been dropped.
60        # TODO: Only prune unused ltag tags, renumerating langIDs accordingly.
61        # Note ltag can also be used by feat or morx tables, so check those too.
62        if not any(
63            record
64            for record in varfont["name"].names
65            if record.platformID == 0 and record.langID != 0xFFFF
66        ):
67            del varfont["ltag"]
68
69
70def updateNameTable(varfont, axisLimits):
71    """Update instatiated variable font's name table using STAT AxisValues.
72
73    Raises ValueError if the STAT table is missing or an Axis Value table is
74    missing for requested axis locations.
75
76    First, collect all STAT AxisValues that match the new default axis locations
77    (excluding "elided" ones); concatenate the strings in design axis order,
78    while giving priority to "synthetic" values (Format 4), to form the
79    typographic subfamily name associated with the new default instance.
80    Finally, update all related records in the name table, making sure that
81    legacy family/sub-family names conform to the the R/I/B/BI (Regular, Italic,
82    Bold, Bold Italic) naming model.
83
84    Example: Updating a partial variable font:
85    | >>> ttFont = TTFont("OpenSans[wdth,wght].ttf")
86    | >>> updateNameTable(ttFont, {"wght": AxisRange(400, 900), "wdth": 75})
87
88    The name table records will be updated in the following manner:
89    NameID 1 familyName: "Open Sans" --> "Open Sans Condensed"
90    NameID 2 subFamilyName: "Regular" --> "Regular"
91    NameID 3 Unique font identifier: "3.000;GOOG;OpenSans-Regular" --> \
92        "3.000;GOOG;OpenSans-Condensed"
93    NameID 4 Full font name: "Open Sans Regular" --> "Open Sans Condensed"
94    NameID 6 PostScript name: "OpenSans-Regular" --> "OpenSans-Condensed"
95    NameID 16 Typographic Family name: None --> "Open Sans"
96    NameID 17 Typographic Subfamily name: None --> "Condensed"
97
98    References:
99    https://docs.microsoft.com/en-us/typography/opentype/spec/stat
100    https://docs.microsoft.com/en-us/typography/opentype/spec/name#name-ids
101    """
102    from . import AxisRange, axisValuesFromAxisLimits
103
104    if "STAT" not in varfont:
105        raise ValueError("Cannot update name table since there is no STAT table.")
106    stat = varfont["STAT"].table
107    if not stat.AxisValueArray:
108        raise ValueError("Cannot update name table since there are no STAT Axis Values")
109    fvar = varfont["fvar"]
110
111    # The updated name table will reflect the new 'zero origin' of the font.
112    # If we're instantiating a partial font, we will populate the unpinned
113    # axes with their default axis values.
114    fvarDefaults = {a.axisTag: a.defaultValue for a in fvar.axes}
115    defaultAxisCoords = deepcopy(axisLimits)
116    for axisTag, val in fvarDefaults.items():
117        if axisTag not in defaultAxisCoords or isinstance(
118            defaultAxisCoords[axisTag], AxisRange
119        ):
120            defaultAxisCoords[axisTag] = val
121
122    axisValueTables = axisValuesFromAxisLimits(stat, defaultAxisCoords)
123    checkAxisValuesExist(stat, axisValueTables, defaultAxisCoords)
124
125    # ignore "elidable" axis values, should be omitted in application font menus.
126    axisValueTables = [
127        v for v in axisValueTables if not v.Flags & ELIDABLE_AXIS_VALUE_NAME
128    ]
129    axisValueTables = _sortAxisValues(axisValueTables)
130    _updateNameRecords(varfont, axisValueTables)
131
132
133def checkAxisValuesExist(stat, axisValues, axisCoords):
134    seen = set()
135    designAxes = stat.DesignAxisRecord.Axis
136    for axisValueTable in axisValues:
137        axisValueFormat = axisValueTable.Format
138        if axisValueTable.Format in (1, 2, 3):
139            axisTag = designAxes[axisValueTable.AxisIndex].AxisTag
140            if axisValueFormat == 2:
141                axisValue = axisValueTable.NominalValue
142            else:
143                axisValue = axisValueTable.Value
144            if axisTag in axisCoords and axisValue == axisCoords[axisTag]:
145                seen.add(axisTag)
146        elif axisValueTable.Format == 4:
147            for rec in axisValueTable.AxisValueRecord:
148                axisTag = designAxes[rec.AxisIndex].AxisTag
149                if axisTag in axisCoords and rec.Value == axisCoords[axisTag]:
150                    seen.add(axisTag)
151
152    missingAxes = set(axisCoords) - seen
153    if missingAxes:
154        missing = ", ".join(f"'{i}={axisCoords[i]}'" for i in missingAxes)
155        raise ValueError(f"Cannot find Axis Values [{missing}]")
156
157
158def _sortAxisValues(axisValues):
159    # Sort by axis index, remove duplicates and ensure that format 4 AxisValues
160    # are dominant.
161    # The MS Spec states: "if a format 1, format 2 or format 3 table has a
162    # (nominal) value used in a format 4 table that also has values for
163    # other axes, the format 4 table, being the more specific match, is used",
164    # https://docs.microsoft.com/en-us/typography/opentype/spec/stat#axis-value-table-format-4
165    results = []
166    seenAxes = set()
167    # Sort format 4 axes so the tables with the most AxisValueRecords are first
168    format4 = sorted(
169        [v for v in axisValues if v.Format == 4],
170        key=lambda v: len(v.AxisValueRecord),
171        reverse=True,
172    )
173
174    for val in format4:
175        axisIndexes = set(r.AxisIndex for r in val.AxisValueRecord)
176        minIndex = min(axisIndexes)
177        if not seenAxes & axisIndexes:
178            seenAxes |= axisIndexes
179            results.append((minIndex, val))
180
181    for val in axisValues:
182        if val in format4:
183            continue
184        axisIndex = val.AxisIndex
185        if axisIndex not in seenAxes:
186            seenAxes.add(axisIndex)
187            results.append((axisIndex, val))
188
189    return [axisValue for _, axisValue in sorted(results)]
190
191
192def _updateNameRecords(varfont, axisValues):
193    # Update nametable based on the axisValues using the R/I/B/BI model.
194    nametable = varfont["name"]
195    stat = varfont["STAT"].table
196
197    axisValueNameIDs = [a.ValueNameID for a in axisValues]
198    ribbiNameIDs = [n for n in axisValueNameIDs if _isRibbi(nametable, n)]
199    nonRibbiNameIDs = [n for n in axisValueNameIDs if n not in ribbiNameIDs]
200    elidedNameID = stat.ElidedFallbackNameID
201    elidedNameIsRibbi = _isRibbi(nametable, elidedNameID)
202
203    getName = nametable.getName
204    platforms = set((r.platformID, r.platEncID, r.langID) for r in nametable.names)
205    for platform in platforms:
206        if not all(getName(i, *platform) for i in (1, 2, elidedNameID)):
207            # Since no family name and subfamily name records were found,
208            # we cannot update this set of name Records.
209            continue
210
211        subFamilyName = " ".join(
212            getName(n, *platform).toUnicode() for n in ribbiNameIDs
213        )
214        if nonRibbiNameIDs:
215            typoSubFamilyName = " ".join(
216                getName(n, *platform).toUnicode() for n in axisValueNameIDs
217            )
218        else:
219            typoSubFamilyName = None
220
221        # If neither subFamilyName and typographic SubFamilyName exist,
222        # we will use the STAT's elidedFallbackName
223        if not typoSubFamilyName and not subFamilyName:
224            if elidedNameIsRibbi:
225                subFamilyName = getName(elidedNameID, *platform).toUnicode()
226            else:
227                typoSubFamilyName = getName(elidedNameID, *platform).toUnicode()
228
229        familyNameSuffix = " ".join(
230            getName(n, *platform).toUnicode() for n in nonRibbiNameIDs
231        )
232
233        _updateNameTableStyleRecords(
234            varfont,
235            familyNameSuffix,
236            subFamilyName,
237            typoSubFamilyName,
238            *platform,
239        )
240
241
242def _isRibbi(nametable, nameID):
243    englishRecord = nametable.getName(nameID, 3, 1, 0x409)
244    return (
245        True
246        if englishRecord is not None
247        and englishRecord.toUnicode() in ("Regular", "Italic", "Bold", "Bold Italic")
248        else False
249    )
250
251
252def _updateNameTableStyleRecords(
253    varfont,
254    familyNameSuffix,
255    subFamilyName,
256    typoSubFamilyName,
257    platformID=3,
258    platEncID=1,
259    langID=0x409,
260):
261    # TODO (Marc F) It may be nice to make this part a standalone
262    # font renamer in the future.
263    nametable = varfont["name"]
264    platform = (platformID, platEncID, langID)
265
266    currentFamilyName = nametable.getName(
267        NameID.TYPOGRAPHIC_FAMILY_NAME, *platform
268    ) or nametable.getName(NameID.FAMILY_NAME, *platform)
269
270    currentStyleName = nametable.getName(
271        NameID.TYPOGRAPHIC_SUBFAMILY_NAME, *platform
272    ) or nametable.getName(NameID.SUBFAMILY_NAME, *platform)
273
274    if not all([currentFamilyName, currentStyleName]):
275        raise ValueError(f"Missing required NameIDs 1 and 2 for platform {platform}")
276
277    currentFamilyName = currentFamilyName.toUnicode()
278    currentStyleName = currentStyleName.toUnicode()
279
280    nameIDs = {
281        NameID.FAMILY_NAME: currentFamilyName,
282        NameID.SUBFAMILY_NAME: subFamilyName or "Regular",
283    }
284    if typoSubFamilyName:
285        nameIDs[NameID.FAMILY_NAME] = f"{currentFamilyName} {familyNameSuffix}".strip()
286        nameIDs[NameID.TYPOGRAPHIC_FAMILY_NAME] = currentFamilyName
287        nameIDs[NameID.TYPOGRAPHIC_SUBFAMILY_NAME] = typoSubFamilyName
288    else:
289        # Remove previous Typographic Family and SubFamily names since they're
290        # no longer required
291        for nameID in (
292            NameID.TYPOGRAPHIC_FAMILY_NAME,
293            NameID.TYPOGRAPHIC_SUBFAMILY_NAME,
294        ):
295            nametable.removeNames(nameID=nameID)
296
297    newFamilyName = (
298        nameIDs.get(NameID.TYPOGRAPHIC_FAMILY_NAME) or nameIDs[NameID.FAMILY_NAME]
299    )
300    newStyleName = (
301        nameIDs.get(NameID.TYPOGRAPHIC_SUBFAMILY_NAME) or nameIDs[NameID.SUBFAMILY_NAME]
302    )
303
304    nameIDs[NameID.FULL_FONT_NAME] = f"{newFamilyName} {newStyleName}"
305    nameIDs[NameID.POSTSCRIPT_NAME] = _updatePSNameRecord(
306        varfont, newFamilyName, newStyleName, platform
307    )
308
309    uniqueID = _updateUniqueIdNameRecord(varfont, nameIDs, platform)
310    if uniqueID:
311        nameIDs[NameID.UNIQUE_FONT_IDENTIFIER] = uniqueID
312
313    for nameID, string in nameIDs.items():
314        assert string, nameID
315        nametable.setName(string, nameID, *platform)
316
317    if "fvar" not in varfont:
318        nametable.removeNames(NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX)
319
320
321def _updatePSNameRecord(varfont, familyName, styleName, platform):
322    # Implementation based on Adobe Technical Note #5902 :
323    # https://wwwimages2.adobe.com/content/dam/acom/en/devnet/font/pdfs/5902.AdobePSNameGeneration.pdf
324    nametable = varfont["name"]
325
326    family_prefix = nametable.getName(
327        NameID.VARIATIONS_POSTSCRIPT_NAME_PREFIX, *platform
328    )
329    if family_prefix:
330        family_prefix = family_prefix.toUnicode()
331    else:
332        family_prefix = familyName
333
334    psName = f"{family_prefix}-{styleName}"
335    # Remove any characters other than uppercase Latin letters, lowercase
336    # Latin letters, digits and hyphens.
337    psName = re.sub(r"[^A-Za-z0-9-]", r"", psName)
338
339    if len(psName) > 127:
340        # Abbreviating the stylename so it fits within 127 characters whilst
341        # conforming to every vendor's specification is too complex. Instead
342        # we simply truncate the psname and add the required "..."
343        return f"{psName[:124]}..."
344    return psName
345
346
347def _updateUniqueIdNameRecord(varfont, nameIDs, platform):
348    nametable = varfont["name"]
349    currentRecord = nametable.getName(NameID.UNIQUE_FONT_IDENTIFIER, *platform)
350    if not currentRecord:
351        return None
352
353    # Check if full name and postscript name are a substring of currentRecord
354    for nameID in (NameID.FULL_FONT_NAME, NameID.POSTSCRIPT_NAME):
355        nameRecord = nametable.getName(nameID, *platform)
356        if not nameRecord:
357            continue
358        if nameRecord.toUnicode() in currentRecord.toUnicode():
359            return currentRecord.toUnicode().replace(
360                nameRecord.toUnicode(), nameIDs[nameRecord.nameID]
361            )
362
363    # Create a new string since we couldn't find any substrings.
364    fontVersion = _fontVersion(varfont, platform)
365    achVendID = varfont["OS/2"].achVendID
366    # Remove non-ASCII characers and trailing spaces
367    vendor = re.sub(r"[^\x00-\x7F]", "", achVendID).strip()
368    psName = nameIDs[NameID.POSTSCRIPT_NAME]
369    return f"{fontVersion};{vendor};{psName}"
370
371
372def _fontVersion(font, platform=(3, 1, 0x409)):
373    nameRecord = font["name"].getName(NameID.VERSION_STRING, *platform)
374    if nameRecord is None:
375        return f'{font["head"].fontRevision:.3f}'
376    # "Version 1.101; ttfautohint (v1.8.1.43-b0c9)" --> "1.101"
377    # Also works fine with inputs "Version 1.101" or "1.101" etc
378    versionNumber = nameRecord.toUnicode().split(";")[0]
379    return versionNumber.lstrip("Version ").strip()
380